mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bcca2767d | ||
|
|
5b6c0ef45c | ||
|
|
d245da20a4 | ||
|
|
730ea11235 | ||
|
|
31859b66b6 | ||
|
|
0b4e1d18bc | ||
|
|
229d6de4c6 | ||
|
|
72784ee914 | ||
|
|
8f34c958fb | ||
|
|
aee631c08d | ||
|
|
62409898c1 | ||
|
|
cf272c4edc | ||
|
|
c92d28c9dd | ||
|
|
7ce754f95a | ||
|
|
5f5ad85dff | ||
|
|
3c0372cceb | ||
|
|
e33784ddd3 | ||
|
|
81e6189ebe | ||
|
|
069a0f7f19 | ||
|
|
86d0d06e76 | ||
|
|
526373c10a | ||
|
|
7b17e33545 | ||
|
|
6cfc1d29f1 |
@@ -9,6 +9,6 @@ runs:
|
|||||||
extra_nix_config: experimental-features = nix-command flakes
|
extra_nix_config: experimental-features = nix-command flakes
|
||||||
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
|
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
|
||||||
with:
|
with:
|
||||||
primary-key: nix-v2-${{ hashFiles('flake.lock', '**/*.nix') }}
|
primary-key: nix-v3-${{ hashFiles('flake.lock', '**/*.nix') }}
|
||||||
restore-prefixes-first-match: nix-v2-
|
restore-prefixes-first-match: nix-v3-
|
||||||
gc-max-store-size-linux: 6G
|
gc-max-store-size-linux: 10G
|
||||||
|
|||||||
+1656
File diff suppressed because it is too large
Load Diff
@@ -384,6 +384,16 @@ val MIGRATION_26_27 =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_27_28 =
|
||||||
|
object : Migration(27, 28) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `trackGain` REAL")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `albumGain` REAL")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `trackPeak` REAL")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `albumPeak` REAL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
||||||
private suspend fun createSearchIndex(
|
private suspend fun createSearchIndex(
|
||||||
connection: SQLiteConnection,
|
connection: SQLiteConnection,
|
||||||
@@ -444,4 +454,5 @@ val MIGRATIONS: Array<Migration> =
|
|||||||
MIGRATION_24_25,
|
MIGRATION_24_25,
|
||||||
MIGRATION_25_26,
|
MIGRATION_25_26,
|
||||||
MIGRATION_26_27,
|
MIGRATION_26_27,
|
||||||
|
MIGRATION_27_28,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
SongDownload::class,
|
SongDownload::class,
|
||||||
PendingAction::class,
|
PendingAction::class,
|
||||||
],
|
],
|
||||||
version = 27,
|
version = 28,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@ColumnTypeConverters(
|
@ColumnTypeConverters(
|
||||||
|
|||||||
@@ -264,6 +264,10 @@ data class Song(
|
|||||||
@ColumnInfo(defaultValue = "0") val created: Long = 0,
|
@ColumnInfo(defaultValue = "0") val created: Long = 0,
|
||||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||||
val played: Long? = null,
|
val played: Long? = null,
|
||||||
|
val trackGain: Float? = null,
|
||||||
|
val albumGain: Float? = null,
|
||||||
|
val trackPeak: Float? = null,
|
||||||
|
val albumPeak: Float? = null,
|
||||||
@Ignore val genres: List<String> = emptyList(),
|
@Ignore val genres: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
|
|||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
@@ -51,8 +52,13 @@ private val Context.preferences: DataStore<Preferences> by preferencesDataStore(
|
|||||||
|
|
||||||
const val DEFAULT_SYNC_INTERVAL_MINUTES = 24 * 60
|
const val DEFAULT_SYNC_INTERVAL_MINUTES = 24 * 60
|
||||||
|
|
||||||
|
const val MIN_REPLAY_GAIN_PREAMP_DB = -15f
|
||||||
|
const val MAX_REPLAY_GAIN_PREAMP_DB = 15f
|
||||||
|
|
||||||
enum class SyncMode { Off, WifiOnly, Always }
|
enum class SyncMode { Off, WifiOnly, Always }
|
||||||
|
|
||||||
|
enum class ReplayGainMode { Off, Track, Album }
|
||||||
|
|
||||||
data class ArtworkSeedValue(
|
data class ArtworkSeedValue(
|
||||||
val cacheKey: String,
|
val cacheKey: String,
|
||||||
val primary: Int,
|
val primary: Int,
|
||||||
@@ -146,6 +152,28 @@ class UserPreferences(
|
|||||||
|
|
||||||
private val scrobblingKey = booleanPreferencesKey("scrobbling")
|
private val scrobblingKey = booleanPreferencesKey("scrobbling")
|
||||||
|
|
||||||
|
fun replayGainMode(): Flow<ReplayGainMode> =
|
||||||
|
store.data.map { prefs ->
|
||||||
|
prefs[replayGainModeKey]?.let { stored -> ReplayGainMode.entries.firstOrNull { it.name == stored } } ?: ReplayGainMode.Track
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setReplayGainMode(mode: ReplayGainMode) {
|
||||||
|
store.edit { prefs -> prefs[replayGainModeKey] = mode.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val replayGainModeKey = stringPreferencesKey("replay_gain_mode")
|
||||||
|
|
||||||
|
fun replayGainPreampDb(): Flow<Float> = store.data.map { prefs -> prefs[replayGainPreampKey].sanitizedPreampDb() }
|
||||||
|
|
||||||
|
suspend fun setReplayGainPreampDb(value: Float) {
|
||||||
|
store.edit { prefs -> prefs[replayGainPreampKey] = value.sanitizedPreampDb() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Float?.sanitizedPreampDb(): Float =
|
||||||
|
this?.takeIf { it.isFinite() }?.coerceIn(MIN_REPLAY_GAIN_PREAMP_DB, MAX_REPLAY_GAIN_PREAMP_DB) ?: 0f
|
||||||
|
|
||||||
|
private val replayGainPreampKey = floatPreferencesKey("replay_gain_preamp_db")
|
||||||
|
|
||||||
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
|
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
|
||||||
|
|
||||||
suspend fun setOfflineMode(enabled: Boolean) {
|
suspend fun setOfflineMode(enabled: Boolean) {
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import kotlinx.coroutines.flow.flow
|
|||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.xml.sax.Attributes
|
import org.xml.sax.Attributes
|
||||||
@@ -205,7 +207,7 @@ class SubsonicSource(
|
|||||||
),
|
),
|
||||||
tag = "song",
|
tag = "song",
|
||||||
create = { SubsonicXml.songDraft(id, it) },
|
create = { SubsonicXml.songDraft(id, it) },
|
||||||
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
|
onChild = SubsonicXml::songChild,
|
||||||
).collect { batch ->
|
).collect { batch ->
|
||||||
raw += batch.size
|
raw += batch.size
|
||||||
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
||||||
@@ -224,6 +226,23 @@ class SubsonicSource(
|
|||||||
private fun albumSongs(): Flow<List<Song>> =
|
private fun albumSongs(): Flow<List<Song>> =
|
||||||
channelFlow {
|
channelFlow {
|
||||||
val permits = Semaphore(maxConcurrentFetches)
|
val permits = Semaphore(maxConcurrentFetches)
|
||||||
|
val lock = Mutex()
|
||||||
|
val pending = ArrayList<Song>(PAGE_SIZE)
|
||||||
|
|
||||||
|
suspend fun accept(songs: List<Song>) {
|
||||||
|
lock
|
||||||
|
.withLock {
|
||||||
|
pending.addAll(songs)
|
||||||
|
if (pending.size < PAGE_SIZE) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val batch = ArrayList(pending.subList(0, PAGE_SIZE))
|
||||||
|
pending.subList(0, PAGE_SIZE).clear()
|
||||||
|
batch
|
||||||
|
}
|
||||||
|
}?.let { send(it) }
|
||||||
|
}
|
||||||
|
|
||||||
var offset = 0
|
var offset = 0
|
||||||
var pages = 0
|
var pages = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -248,10 +267,10 @@ class SubsonicSource(
|
|||||||
params = mapOf("id" to albumId),
|
params = mapOf("id" to albumId),
|
||||||
tag = "song",
|
tag = "song",
|
||||||
create = { SubsonicXml.songDraft(id, it) },
|
create = { SubsonicXml.songDraft(id, it) },
|
||||||
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
|
onChild = SubsonicXml::songChild,
|
||||||
).collect { batch ->
|
).collect { batch ->
|
||||||
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
||||||
if (accepted.isNotEmpty()) send(accepted)
|
accept(accepted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,7 +284,14 @@ class SubsonicSource(
|
|||||||
if (pages > maxPages) throw pageCapExceeded()
|
if (pages > maxPages) throw pageCapExceeded()
|
||||||
offset += PAGE_SIZE
|
offset += PAGE_SIZE
|
||||||
}
|
}
|
||||||
|
val tail =
|
||||||
|
lock.withLock {
|
||||||
|
val held = pending.toList()
|
||||||
|
pending.clear()
|
||||||
|
held
|
||||||
}
|
}
|
||||||
|
if (tail.isNotEmpty()) send(tail)
|
||||||
|
}.buffer(1)
|
||||||
|
|
||||||
private fun supportsEmptyQuerySearch(): Boolean {
|
private fun supportsEmptyQuerySearch(): Boolean {
|
||||||
emptyQuerySearchSupported?.let { return it }
|
emptyQuerySearchSupported?.let { return it }
|
||||||
|
|||||||
@@ -78,6 +78,17 @@ object SubsonicXml {
|
|||||||
played = attrs.dateAttr("played"),
|
played = attrs.dateAttr("played"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
internal fun songChild(
|
||||||
|
entity: SongDraft,
|
||||||
|
name: String,
|
||||||
|
attrs: Attributes,
|
||||||
|
) {
|
||||||
|
when (name) {
|
||||||
|
"genres" -> entity.addGenre(attrs)
|
||||||
|
"replayGain" -> entity.addReplayGain(attrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal fun playlist(
|
internal fun playlist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
attrs: Attributes,
|
attrs: Attributes,
|
||||||
@@ -184,6 +195,10 @@ internal class SongDraft(
|
|||||||
private val created: Long = 0L,
|
private val created: Long = 0L,
|
||||||
private val playCount: Long = 0L,
|
private val playCount: Long = 0L,
|
||||||
private val played: Long? = null,
|
private val played: Long? = null,
|
||||||
|
private var trackGain: Float? = null,
|
||||||
|
private var albumGain: Float? = null,
|
||||||
|
private var trackPeak: Float? = null,
|
||||||
|
private var albumPeak: Float? = null,
|
||||||
private val genres: MutableList<String> = mutableListOf(),
|
private val genres: MutableList<String> = mutableListOf(),
|
||||||
) {
|
) {
|
||||||
fun addGenre(attrs: Attributes) {
|
fun addGenre(attrs: Attributes) {
|
||||||
@@ -194,6 +209,13 @@ internal class SongDraft(
|
|||||||
?.let { genres += it }
|
?.let { genres += it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun addReplayGain(attrs: Attributes) {
|
||||||
|
attrs.floatAttr("trackGain")?.let { trackGain = it }
|
||||||
|
attrs.floatAttr("albumGain")?.let { albumGain = it }
|
||||||
|
attrs.floatAttr("trackPeak")?.let { trackPeak = it }
|
||||||
|
attrs.floatAttr("albumPeak")?.let { albumPeak = it }
|
||||||
|
}
|
||||||
|
|
||||||
fun toSong(): Song =
|
fun toSong(): Song =
|
||||||
Song(
|
Song(
|
||||||
sourceId = sourceId,
|
sourceId = sourceId,
|
||||||
@@ -211,6 +233,10 @@ internal class SongDraft(
|
|||||||
created = created,
|
created = created,
|
||||||
playCount = playCount,
|
playCount = playCount,
|
||||||
played = played,
|
played = played,
|
||||||
|
trackGain = trackGain,
|
||||||
|
albumGain = albumGain,
|
||||||
|
trackPeak = trackPeak,
|
||||||
|
albumPeak = albumPeak,
|
||||||
genres = genres.toList(),
|
genres = genres.toList(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -219,6 +245,8 @@ internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
|
|||||||
|
|
||||||
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
|
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
|
||||||
|
|
||||||
|
internal fun Attributes.floatAttr(name: String): Float? = attr(name).takeIf { it.isNotEmpty() }?.toFloatOrNull()?.takeIf { it.isFinite() }
|
||||||
|
|
||||||
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
|
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
|
||||||
|
|
||||||
private const val ROOT_TAG = "subsonic-response"
|
private const val ROOT_TAG = "subsonic-response"
|
||||||
|
|||||||
@@ -314,6 +314,10 @@ private val songColumns =
|
|||||||
"created",
|
"created",
|
||||||
"playCount",
|
"playCount",
|
||||||
"played",
|
"played",
|
||||||
|
"trackGain",
|
||||||
|
"albumGain",
|
||||||
|
"trackPeak",
|
||||||
|
"albumPeak",
|
||||||
)
|
)
|
||||||
|
|
||||||
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
|
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
|
||||||
@@ -358,6 +362,10 @@ private fun Song.values() =
|
|||||||
created,
|
created,
|
||||||
playCount,
|
playCount,
|
||||||
played,
|
played,
|
||||||
|
trackGain,
|
||||||
|
albumGain,
|
||||||
|
trackPeak,
|
||||||
|
albumPeak,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
||||||
@@ -374,6 +382,7 @@ private suspend fun <T> PooledConnection.upsertChanged(
|
|||||||
values: (T) -> List<Any?>,
|
values: (T) -> List<Any?>,
|
||||||
) {
|
) {
|
||||||
if (rows.isEmpty()) return
|
if (rows.isEmpty()) return
|
||||||
|
check(values(rows.first()).size == columns.size) { "column/value mismatch for $table" }
|
||||||
val updates = columns.filterNot { it in key }
|
val updates = columns.filterNot { it in key }
|
||||||
val sql =
|
val sql =
|
||||||
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
|
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
|
||||||
@@ -401,6 +410,7 @@ private fun SQLiteStatement.bind(
|
|||||||
is Int -> bindLong(index, value.toLong())
|
is Int -> bindLong(index, value.toLong())
|
||||||
is Boolean -> bindLong(index, if (value) 1L else 0L)
|
is Boolean -> bindLong(index, if (value) 1L else 0L)
|
||||||
is Double -> bindDouble(index, value)
|
is Double -> bindDouble(index, value)
|
||||||
|
is Float -> bindDouble(index, value.toDouble())
|
||||||
is String -> bindText(index, value)
|
is String -> bindText(index, value)
|
||||||
else -> error("Cannot bind ${value::class}")
|
else -> error("Cannot bind ${value::class}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,11 +234,35 @@ class Media3PlayerHandle(
|
|||||||
mediaItem.mediaMetadata.extras
|
mediaItem.mediaMetadata.extras
|
||||||
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
||||||
?.getLong(EXTRA_DURATION_MS),
|
?.getLong(EXTRA_DURATION_MS),
|
||||||
|
replayGain = mediaItem.replayGain(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
||||||
private const val EXTRA_DURATION_MS = "durationMs"
|
private const val EXTRA_DURATION_MS = "durationMs"
|
||||||
|
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
|
||||||
|
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
|
||||||
|
private const val EXTRA_REPLAY_GAIN_TRACK_PEAK = "replayGainTrackPeak"
|
||||||
|
private const val EXTRA_REPLAY_GAIN_ALBUM_PEAK = "replayGainAlbumPeak"
|
||||||
|
|
||||||
|
internal fun MediaItem?.replayGain(): ReplayGain {
|
||||||
|
val extras = this?.mediaMetadata?.extras ?: return ReplayGain()
|
||||||
|
return ReplayGain(
|
||||||
|
trackGain = extras.gain(EXTRA_REPLAY_GAIN_TRACK_GAIN),
|
||||||
|
albumGain = extras.gain(EXTRA_REPLAY_GAIN_ALBUM_GAIN),
|
||||||
|
trackPeak = extras.gain(EXTRA_REPLAY_GAIN_TRACK_PEAK),
|
||||||
|
albumPeak = extras.gain(EXTRA_REPLAY_GAIN_ALBUM_PEAK),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Bundle.putGain(
|
||||||
|
key: String,
|
||||||
|
value: Float?,
|
||||||
|
) {
|
||||||
|
if (value != null) putFloat(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Bundle.gain(key: String): Float? = if (containsKey(key)) getFloat(key) else null
|
||||||
|
|
||||||
internal fun queueMediaItem(
|
internal fun queueMediaItem(
|
||||||
item: QueueItem,
|
item: QueueItem,
|
||||||
@@ -260,6 +284,10 @@ internal fun queueMediaItem(
|
|||||||
Bundle().apply {
|
Bundle().apply {
|
||||||
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
||||||
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
||||||
|
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
|
||||||
|
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
|
||||||
|
putGain(EXTRA_REPLAY_GAIN_TRACK_PEAK, item.replayGain.trackPeak)
|
||||||
|
putGain(EXTRA_REPLAY_GAIN_ALBUM_PEAK, item.replayGain.albumPeak)
|
||||||
},
|
},
|
||||||
).build()
|
).build()
|
||||||
val builder =
|
val builder =
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ data class QueueItem(
|
|||||||
val album: String?,
|
val album: String?,
|
||||||
val coverArtId: String?,
|
val coverArtId: String?,
|
||||||
val durationMs: Long? = null,
|
val durationMs: Long? = null,
|
||||||
|
val replayGain: ReplayGain = ReplayGain(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class QueueContext(
|
data class QueueContext(
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import com.google.common.util.concurrent.ListenableFuture
|
|||||||
import com.subtracks.MainActivity
|
import com.subtracks.MainActivity
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UserAgent
|
import com.subtracks.UserAgent
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
@@ -43,6 +44,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koin.core.context.GlobalContext
|
import org.koin.core.context.GlobalContext
|
||||||
@@ -60,6 +62,9 @@ class PlaybackService : MediaSessionService() {
|
|||||||
private var starred = false
|
private var starred = false
|
||||||
private var starJob: Job? = null
|
private var starJob: Job? = null
|
||||||
|
|
||||||
|
private val gainProcessor: ReplayGainProcessor = ReplayGainProcessor { replayGain.applyItem(it) }
|
||||||
|
private val replayGain: ReplayGainController = ReplayGainController { gainProcessor.setLinearGain(it) }
|
||||||
|
|
||||||
private val starCallback =
|
private val starCallback =
|
||||||
object : MediaSession.Callback {
|
object : MediaSession.Callback {
|
||||||
override fun onConnect(
|
override fun onConnect(
|
||||||
@@ -97,10 +102,18 @@ class PlaybackService : MediaSessionService() {
|
|||||||
val sources = GlobalContext.get().get<SourceRepository>()
|
val sources = GlobalContext.get().get<SourceRepository>()
|
||||||
sourceRepository = sources
|
sourceRepository = sources
|
||||||
library = GlobalContext.get().get<LibraryRepository>()
|
library = GlobalContext.get().get<LibraryRepository>()
|
||||||
|
val preferences = GlobalContext.get().get<UserPreferences>()
|
||||||
val cache = StreamCache.open(this)
|
val cache = StreamCache.open(this)
|
||||||
|
scope.launch {
|
||||||
|
preferences
|
||||||
|
.replayGainMode()
|
||||||
|
.combine(preferences.replayGainPreampDb()) { mode, preamp -> ReplayGainSettings(mode, preamp) }
|
||||||
|
.collect(replayGain::setSettings)
|
||||||
|
}
|
||||||
val exoPlayer =
|
val exoPlayer =
|
||||||
ExoPlayer
|
ExoPlayer
|
||||||
.Builder(this)
|
.Builder(this)
|
||||||
|
.setRenderersFactory(ReplayGainRenderersFactory(this, gainProcessor))
|
||||||
.setAudioAttributes(
|
.setAudioAttributes(
|
||||||
AudioAttributes
|
AudioAttributes
|
||||||
.Builder()
|
.Builder()
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ fun SongItem.toQueueItem() =
|
|||||||
album = song.album,
|
album = song.album,
|
||||||
coverArtId = coverArt,
|
coverArtId = coverArt,
|
||||||
durationMs = song.duration?.let { it * 1000 },
|
durationMs = song.duration?.let { it * 1000 },
|
||||||
|
replayGain = song.replayGain(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
import kotlin.math.log10
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
|
data class ReplayGain(
|
||||||
|
val trackGain: Float? = null,
|
||||||
|
val albumGain: Float? = null,
|
||||||
|
val trackPeak: Float? = null,
|
||||||
|
val albumPeak: Float? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class ReplayGainSettings(
|
||||||
|
val mode: ReplayGainMode,
|
||||||
|
val preampDb: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun Song.replayGain(): ReplayGain =
|
||||||
|
ReplayGain(
|
||||||
|
trackGain = trackGain,
|
||||||
|
albumGain = albumGain,
|
||||||
|
trackPeak = trackPeak,
|
||||||
|
albumPeak = albumPeak,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun replayGainLinearGain(
|
||||||
|
gain: ReplayGain,
|
||||||
|
settings: ReplayGainSettings,
|
||||||
|
): Float {
|
||||||
|
// gonic sends a peak of 0 when it only has album gain, so a non-positive or non-finite peak means
|
||||||
|
// "no peak data" and is ignored rather than treated as silence.
|
||||||
|
val trackPeak = gain.trackPeak?.takeIf { it.isFinite() && it > 0f }
|
||||||
|
val albumPeak = gain.albumPeak?.takeIf { it.isFinite() && it > 0f }
|
||||||
|
val (requestedDb, peak) =
|
||||||
|
when (settings.mode) {
|
||||||
|
ReplayGainMode.Off -> return 1f
|
||||||
|
ReplayGainMode.Track -> (gain.trackGain ?: gain.albumGain) to (trackPeak ?: albumPeak)
|
||||||
|
ReplayGainMode.Album -> (gain.albumGain ?: gain.trackGain) to (albumPeak ?: trackPeak)
|
||||||
|
}
|
||||||
|
val gainDb = requestedDb?.takeIf { it.isFinite() } ?: return 1f
|
||||||
|
val requested = gainDb + settings.preampDb
|
||||||
|
val applied = peak?.let { min(requested, maxSafeGainDb(it)) } ?: requested
|
||||||
|
return 10f.pow(applied / 20f).takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maxSafeGainDb(peak: Float): Float = -20f * log10(peak)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
|
||||||
|
internal class ReplayGainController(
|
||||||
|
private val apply: (Float) -> Unit,
|
||||||
|
) {
|
||||||
|
private var track = ReplayGain()
|
||||||
|
private var settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun applyItem(item: MediaItem) {
|
||||||
|
track = item.replayGain()
|
||||||
|
applyGain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun setSettings(value: ReplayGainSettings) {
|
||||||
|
settings = value
|
||||||
|
applyGain()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyGain() {
|
||||||
|
apply(replayGainLinearGain(track, settings))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.Timeline
|
||||||
|
import androidx.media3.common.audio.AudioProcessor.AudioFormat
|
||||||
|
import androidx.media3.common.audio.AudioProcessor.StreamMetadata
|
||||||
|
import androidx.media3.common.audio.BaseAudioProcessor
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal class ReplayGainProcessor(
|
||||||
|
private val onItem: (MediaItem) -> Unit = {},
|
||||||
|
) : BaseAudioProcessor() {
|
||||||
|
@Volatile
|
||||||
|
private var linearGain = 1f
|
||||||
|
|
||||||
|
fun setLinearGain(value: Float) {
|
||||||
|
linearGain = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConfigure(inputAudioFormat: AudioFormat): AudioFormat = inputAudioFormat
|
||||||
|
|
||||||
|
// DefaultAudioSink flushes the processors on every configuration, which happens at each media
|
||||||
|
// item transition even when the format is unchanged, and hands over the timeline and the period
|
||||||
|
// being flushed (androidx/media#418). That is early enough to set the new item's gain before any
|
||||||
|
// of its buffers are processed, unlike Player.Listener.onMediaItemTransition, which the app
|
||||||
|
// thread receives afterwards.
|
||||||
|
override fun onFlush(streamMetadata: StreamMetadata) {
|
||||||
|
streamMetadata.currentItem()?.let(onItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queueInput(inputBuffer: ByteBuffer) {
|
||||||
|
val output = replaceOutputBuffer(inputBuffer.remaining())
|
||||||
|
val gain = linearGain.takeIf { it.isFinite() } ?: 1f
|
||||||
|
when (inputAudioFormat.encoding) {
|
||||||
|
C.ENCODING_PCM_16BIT -> {
|
||||||
|
while (inputBuffer.remaining() >= 2) {
|
||||||
|
output.putShort((inputBuffer.short * gain).roundToInt().coerceIn(MIN_SAMPLE, MAX_SAMPLE).toShort())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
C.ENCODING_PCM_FLOAT -> {
|
||||||
|
while (inputBuffer.remaining() >= 4) output.putFloat(inputBuffer.float * gain)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
if (inputBuffer.hasRemaining()) output.put(inputBuffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inputBuffer.position(inputBuffer.limit())
|
||||||
|
output.flip()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MIN_SAMPLE = -32768
|
||||||
|
const val MAX_SAMPLE = 32767
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal fun StreamMetadata.currentItem(): MediaItem? {
|
||||||
|
val uid = periodUid ?: return null
|
||||||
|
val index = timeline.getIndexOfPeriod(uid)
|
||||||
|
if (index == C.INDEX_UNSET) return null
|
||||||
|
val period = timeline.getPeriod(index, Timeline.Period())
|
||||||
|
return timeline.getWindow(period.windowIndex, Timeline.Window()).mediaItem
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
|
import androidx.media3.exoplayer.audio.AudioSink
|
||||||
|
import androidx.media3.exoplayer.audio.DefaultAudioSink
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal class ReplayGainRenderersFactory(
|
||||||
|
context: Context,
|
||||||
|
private val processor: ReplayGainProcessor,
|
||||||
|
) : DefaultRenderersFactory(context) {
|
||||||
|
// Mirrors the stock DefaultRenderersFactory.buildAudioSink. setAudioProcessors keeps the default
|
||||||
|
// chain and prepends ours; DefaultAudioSink omits the whole chain when float output is enabled,
|
||||||
|
// and AudioSink.setOffloadMode bypasses it entirely.
|
||||||
|
public override fun buildAudioSink(
|
||||||
|
context: Context,
|
||||||
|
enableFloatOutput: Boolean,
|
||||||
|
enableAudioOutputPlaybackParameters: Boolean,
|
||||||
|
): AudioSink {
|
||||||
|
if (enableFloatOutput) {
|
||||||
|
Log.w(FLOAT_OUTPUT_TAG, "float output is on, so replay gain is not applied to high-resolution tracks")
|
||||||
|
}
|
||||||
|
return DefaultAudioSink
|
||||||
|
.Builder(context)
|
||||||
|
.setEnableFloatOutput(enableFloatOutput)
|
||||||
|
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParameters)
|
||||||
|
.setAudioProcessors(arrayOf(processor))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val FLOAT_OUTPUT_TAG = "replaygain"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.subtracks.ui.settings
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
|
||||||
|
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
|
||||||
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
internal const val REPLAY_GAIN_PREAMP_STEP_DB = 0.5f
|
||||||
|
|
||||||
|
internal val REPLAY_GAIN_PREAMP_STEPS =
|
||||||
|
((MAX_REPLAY_GAIN_PREAMP_DB - MIN_REPLAY_GAIN_PREAMP_DB) / REPLAY_GAIN_PREAMP_STEP_DB).toInt() - 1
|
||||||
|
|
||||||
|
// Compose Material 3's Slider snaps in pixel space and converts back, so the value it reports is
|
||||||
|
// near, but not on, the grid it was configured with (for example -0.99999905 for -1.0). Snap before
|
||||||
|
// showing or storing it.
|
||||||
|
internal fun snapReplayGainPreampDb(value: Float): Float =
|
||||||
|
((value / REPLAY_GAIN_PREAMP_STEP_DB).roundToInt() * REPLAY_GAIN_PREAMP_STEP_DB)
|
||||||
|
.coerceIn(MIN_REPLAY_GAIN_PREAMP_DB, MAX_REPLAY_GAIN_PREAMP_DB)
|
||||||
|
|
||||||
|
internal fun stepReplayGainPreampDb(
|
||||||
|
value: Float,
|
||||||
|
steps: Int,
|
||||||
|
): Float = snapReplayGainPreampDb(value + steps * REPLAY_GAIN_PREAMP_STEP_DB)
|
||||||
|
|
||||||
|
internal fun replayGainModeLabel(
|
||||||
|
resources: Resources,
|
||||||
|
mode: ReplayGainMode,
|
||||||
|
): String =
|
||||||
|
when (mode) {
|
||||||
|
ReplayGainMode.Off -> resources.getString(R.string.settings_replay_gain_mode_off)
|
||||||
|
ReplayGainMode.Track -> resources.getString(R.string.settings_replay_gain_mode_track)
|
||||||
|
ReplayGainMode.Album -> resources.getString(R.string.settings_replay_gain_mode_album)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun replayGainPreampLabel(
|
||||||
|
resources: Resources,
|
||||||
|
decibels: Float,
|
||||||
|
): String {
|
||||||
|
val sign = if (decibels > 0f) "+" else ""
|
||||||
|
val magnitude = String.format(resources.configuration.locales[0], "%.1f", decibels)
|
||||||
|
return resources.getString(R.string.settings_replay_gain_preamp_value, "$sign$magnitude")
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import androidx.compose.material.icons.rounded.Edit
|
|||||||
import androidx.compose.material.icons.rounded.Favorite
|
import androidx.compose.material.icons.rounded.Favorite
|
||||||
import androidx.compose.material.icons.rounded.Info
|
import androidx.compose.material.icons.rounded.Info
|
||||||
import androidx.compose.material.icons.rounded.Language
|
import androidx.compose.material.icons.rounded.Language
|
||||||
|
import androidx.compose.material.icons.rounded.Remove
|
||||||
import androidx.compose.material.icons.rounded.Share
|
import androidx.compose.material.icons.rounded.Share
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -39,6 +40,8 @@ import androidx.compose.material3.ListItemDefaults
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.RadioButton
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.SwitchDefaults
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -46,6 +49,7 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -59,6 +63,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.platform.LocalResources
|
import androidx.compose.ui.platform.LocalResources
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -66,6 +71,9 @@ import com.subtracks.BuildConfig
|
|||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||||
|
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
|
||||||
|
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
|
||||||
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
import com.subtracks.data.prefs.SyncMode
|
import com.subtracks.data.prefs.SyncMode
|
||||||
import com.subtracks.log.Log
|
import com.subtracks.log.Log
|
||||||
@@ -84,7 +92,16 @@ private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "f
|
|||||||
private const val PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks"
|
private const val PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks"
|
||||||
private const val SUPPORT_URL = "https://ko-fi.com/austinried"
|
private const val SUPPORT_URL = "https://ko-fi.com/austinried"
|
||||||
|
|
||||||
private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency, SyncInterval, SyncMode }
|
private enum class SettingsDialog {
|
||||||
|
WifiQuality,
|
||||||
|
MobileQuality,
|
||||||
|
DownloadQuality,
|
||||||
|
SyncConcurrency,
|
||||||
|
SyncInterval,
|
||||||
|
SyncMode,
|
||||||
|
ReplayGain,
|
||||||
|
ReplayGainPreamp,
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsRoute(
|
fun SettingsRoute(
|
||||||
@@ -108,6 +125,8 @@ fun SettingsRoute(
|
|||||||
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
|
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
|
||||||
|
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
|
||||||
|
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
@@ -121,6 +140,8 @@ fun SettingsRoute(
|
|||||||
downloadQuality = downloadQuality,
|
downloadQuality = downloadQuality,
|
||||||
downloadOverMetered = downloadOverMetered,
|
downloadOverMetered = downloadOverMetered,
|
||||||
scrobbling = scrobbling,
|
scrobbling = scrobbling,
|
||||||
|
replayGainMode = replayGainMode,
|
||||||
|
replayGainPreamp = replayGainPreamp,
|
||||||
offline = offline,
|
offline = offline,
|
||||||
verboseLogging = verboseLogging,
|
verboseLogging = verboseLogging,
|
||||||
onSelectSource = viewModel::selectSource,
|
onSelectSource = viewModel::selectSource,
|
||||||
@@ -133,6 +154,8 @@ fun SettingsRoute(
|
|||||||
onDownloadQualityChange = viewModel::setDownloadQuality,
|
onDownloadQualityChange = viewModel::setDownloadQuality,
|
||||||
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
||||||
onScrobblingChange = viewModel::setScrobbling,
|
onScrobblingChange = viewModel::setScrobbling,
|
||||||
|
onReplayGainModeChange = viewModel::setReplayGainMode,
|
||||||
|
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
|
||||||
onOfflineChange = viewModel::setOfflineMode,
|
onOfflineChange = viewModel::setOfflineMode,
|
||||||
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
||||||
onAddServer = onAddServer,
|
onAddServer = onAddServer,
|
||||||
@@ -176,6 +199,8 @@ fun SettingsScreen(
|
|||||||
verboseLogging: Boolean = false,
|
verboseLogging: Boolean = false,
|
||||||
syncMode: SyncMode = SyncMode.WifiOnly,
|
syncMode: SyncMode = SyncMode.WifiOnly,
|
||||||
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
||||||
|
replayGainMode: ReplayGainMode = ReplayGainMode.Track,
|
||||||
|
replayGainPreamp: Float = 0f,
|
||||||
onSelectSource: (Long) -> Unit,
|
onSelectSource: (Long) -> Unit,
|
||||||
onEditServer: (Long) -> Unit,
|
onEditServer: (Long) -> Unit,
|
||||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||||
@@ -186,6 +211,8 @@ fun SettingsScreen(
|
|||||||
onDownloadQualityChange: (StreamQuality) -> Unit,
|
onDownloadQualityChange: (StreamQuality) -> Unit,
|
||||||
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
||||||
onScrobblingChange: (Boolean) -> Unit,
|
onScrobblingChange: (Boolean) -> Unit,
|
||||||
|
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
|
||||||
|
onReplayGainPreampChange: (Float) -> Unit = {},
|
||||||
onOfflineChange: (Boolean) -> Unit = {},
|
onOfflineChange: (Boolean) -> Unit = {},
|
||||||
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
||||||
onAddServer: () -> Unit,
|
onAddServer: () -> Unit,
|
||||||
@@ -337,6 +364,22 @@ fun SettingsScreen(
|
|||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.settings_replay_gain_title)) },
|
||||||
|
supportingContent = { Text(replayGainModeLabel(LocalResources.current, replayGainMode)) },
|
||||||
|
modifier = Modifier.clickable { dialog = SettingsDialog.ReplayGain },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.settings_replay_gain_preamp_title)) },
|
||||||
|
supportingContent = { Text(replayGainPreampLabel(LocalResources.current, replayGainPreamp)) },
|
||||||
|
modifier = Modifier.clickable { dialog = SettingsDialog.ReplayGainPreamp },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
item { SectionHeader(stringResource(R.string.downloads_title)) }
|
item { SectionHeader(stringResource(R.string.downloads_title)) }
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
@@ -519,6 +562,22 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsDialog.ReplayGain -> {
|
||||||
|
ReplayGainModeDialog(
|
||||||
|
selected = replayGainMode,
|
||||||
|
onSelect = onReplayGainModeChange,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsDialog.ReplayGainPreamp -> {
|
||||||
|
ReplayGainPreampDialog(
|
||||||
|
selected = replayGainPreamp,
|
||||||
|
onSelect = onReplayGainPreampChange,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
null -> {
|
null -> {
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
@@ -672,6 +731,118 @@ private fun SyncModeDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReplayGainModeDialog(
|
||||||
|
selected: ReplayGainMode,
|
||||||
|
onSelect: (ReplayGainMode) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val resources = LocalResources.current
|
||||||
|
var draft by remember(selected) { mutableStateOf(selected) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.settings_replay_gain_title)) },
|
||||||
|
text = {
|
||||||
|
ChoiceGroup(
|
||||||
|
header = stringResource(R.string.settings_replay_gain_prompt),
|
||||||
|
options = ReplayGainMode.entries.map { it to replayGainModeLabel(resources, it) },
|
||||||
|
selected = draft,
|
||||||
|
onSelect = { draft = it },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onSelect(draft)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.action_done)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ReplayGainPreampDialog(
|
||||||
|
selected: Float,
|
||||||
|
onSelect: (Float) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val resources = LocalResources.current
|
||||||
|
var draft by remember(selected) { mutableFloatStateOf(snapReplayGainPreampDb(selected)) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.settings_replay_gain_preamp_title)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.settings_replay_gain_preamp_prompt),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { draft = stepReplayGainPreampDb(draft, -1) },
|
||||||
|
enabled = draft > MIN_REPLAY_GAIN_PREAMP_DB,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.Remove,
|
||||||
|
contentDescription = stringResource(R.string.settings_replay_gain_preamp_decrease),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Slider(
|
||||||
|
value = draft,
|
||||||
|
onValueChange = { draft = snapReplayGainPreampDb(it) },
|
||||||
|
valueRange = MIN_REPLAY_GAIN_PREAMP_DB..MAX_REPLAY_GAIN_PREAMP_DB,
|
||||||
|
steps = REPLAY_GAIN_PREAMP_STEPS,
|
||||||
|
// Compose Material 3 draws one track tick per step, and at 59 steps they
|
||||||
|
// smear together, so the ticks are suppressed and the snapping kept.
|
||||||
|
track = { state -> SliderDefaults.Track(sliderState = state, drawTick = { _, _ -> }) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = { draft = stepReplayGainPreampDb(draft, 1) },
|
||||||
|
enabled = draft < MAX_REPLAY_GAIN_PREAMP_DB,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.Add,
|
||||||
|
contentDescription = stringResource(R.string.settings_replay_gain_preamp_increase),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
replayGainPreampLabel(resources, draft),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = { draft = 0f },
|
||||||
|
enabled = draft != 0f,
|
||||||
|
) { Text(stringResource(R.string.settings_replay_gain_preamp_reset)) }
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onSelect(draft)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.action_done)) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun QualityDialog(
|
private fun QualityDialog(
|
||||||
title: String,
|
title: String,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||||
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
import com.subtracks.data.prefs.SyncMode
|
import com.subtracks.data.prefs.SyncMode
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
@@ -65,6 +66,16 @@ class SettingsViewModel(
|
|||||||
.scrobbling()
|
.scrobbling()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
|
||||||
|
|
||||||
|
val replayGainMode: StateFlow<ReplayGainMode> =
|
||||||
|
userPreferences
|
||||||
|
.replayGainMode()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ReplayGainMode.Track)
|
||||||
|
|
||||||
|
val replayGainPreamp: StateFlow<Float> =
|
||||||
|
userPreferences
|
||||||
|
.replayGainPreampDb()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f)
|
||||||
|
|
||||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||||
|
|
||||||
val verboseLogging: StateFlow<Boolean> =
|
val verboseLogging: StateFlow<Boolean> =
|
||||||
@@ -115,4 +126,12 @@ class SettingsViewModel(
|
|||||||
fun setScrobbling(enabled: Boolean) {
|
fun setScrobbling(enabled: Boolean) {
|
||||||
viewModelScope.launch { userPreferences.setScrobbling(enabled) }
|
viewModelScope.launch { userPreferences.setScrobbling(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setReplayGainMode(mode: ReplayGainMode) {
|
||||||
|
viewModelScope.launch { userPreferences.setReplayGainMode(mode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setReplayGainPreamp(value: Float) {
|
||||||
|
viewModelScope.launch { userPreferences.setReplayGainPreampDb(value) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,17 @@
|
|||||||
<string name="settings_music_name">Playback</string>
|
<string name="settings_music_name">Playback</string>
|
||||||
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
|
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
|
||||||
<string name="settings_music_options_scrobble_title">Scrobble to server</string>
|
<string name="settings_music_options_scrobble_title">Scrobble to server</string>
|
||||||
|
<string name="settings_replay_gain_mode_album">Album gain</string>
|
||||||
|
<string name="settings_replay_gain_mode_off">Off</string>
|
||||||
|
<string name="settings_replay_gain_mode_track">Track gain</string>
|
||||||
|
<string name="settings_replay_gain_preamp_decrease">Lower preamp</string>
|
||||||
|
<string name="settings_replay_gain_preamp_increase">Raise preamp</string>
|
||||||
|
<string name="settings_replay_gain_preamp_prompt">Extra level on top of ReplayGain</string>
|
||||||
|
<string name="settings_replay_gain_preamp_reset">Reset</string>
|
||||||
|
<string name="settings_replay_gain_preamp_title">Preamp</string>
|
||||||
|
<string name="settings_replay_gain_preamp_value">%1$s dB</string>
|
||||||
|
<string name="settings_replay_gain_prompt">Normalise loudness using the ReplayGain tags from the server</string>
|
||||||
|
<string name="settings_replay_gain_title">ReplayGain</string>
|
||||||
<string name="settings_network_name">Network</string>
|
<string name="settings_network_name">Network</string>
|
||||||
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</string>
|
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</string>
|
||||||
<string name="settings_network_options_max_bitrate_wifi_title">Stream quality: Wi-Fi</string>
|
<string name="settings_network_options_max_bitrate_wifi_title">Stream quality: Wi-Fi</string>
|
||||||
|
|||||||
@@ -188,6 +188,34 @@ class UserPreferencesTest {
|
|||||||
prefs.setVerboseLogging(true)
|
prefs.setVerboseLogging(true)
|
||||||
assertEquals(true, prefs.verboseLogging().first())
|
assertEquals(true, prefs.verboseLogging().first())
|
||||||
|
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun replayGainDefaultsToTrackModeAndRoundTrips() =
|
||||||
|
runTest {
|
||||||
|
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
||||||
|
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||||
|
val prefs = UserPreferences(store)
|
||||||
|
|
||||||
|
assertEquals(ReplayGainMode.Track, prefs.replayGainMode().first())
|
||||||
|
assertEquals(0f, prefs.replayGainPreampDb().first(), 0f)
|
||||||
|
|
||||||
|
prefs.setReplayGainMode(ReplayGainMode.Album)
|
||||||
|
prefs.setReplayGainPreampDb(3.5f)
|
||||||
|
|
||||||
|
assertEquals(ReplayGainMode.Album, prefs.replayGainMode().first())
|
||||||
|
assertEquals(3.5f, prefs.replayGainPreampDb().first(), 0f)
|
||||||
|
|
||||||
|
prefs.setReplayGainPreampDb(99f)
|
||||||
|
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f)
|
||||||
|
|
||||||
|
prefs.setReplayGainPreampDb(-99f)
|
||||||
|
assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f)
|
||||||
|
|
||||||
|
prefs.setReplayGainPreampDb(Float.NaN)
|
||||||
|
assertEquals(0f, prefs.replayGainPreampDb().first(), 0f)
|
||||||
|
|
||||||
file.delete()
|
file.delete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,6 +256,75 @@ class SubsonicSourceTest {
|
|||||||
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
|
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun songsKeepTheOpenSubsonicReplayGain() =
|
||||||
|
runBlocking {
|
||||||
|
server.dispatcher =
|
||||||
|
object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||||
|
val url = request.requestUrl!!
|
||||||
|
if (url.encodedPath != "/rest/search3.view") return MockResponse().setResponseCode(404)
|
||||||
|
return MockResponse().setBody(
|
||||||
|
"<subsonic-response status=\"ok\"><searchResult3>" +
|
||||||
|
"<song id=\"s1\" title=\"Gained\">" +
|
||||||
|
"<replayGain trackGain=\"-3.2\" albumGain=\"-2.1\" trackPeak=\"0.98\" albumPeak=\"0.99\"/>" +
|
||||||
|
"</song></searchResult3></subsonic-response>",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val songs = SubsonicSource(1, client()).songs().toList().flatten()
|
||||||
|
|
||||||
|
assertEquals(-3.2f, songs.single().trackGain)
|
||||||
|
assertEquals(-2.1f, songs.single().albumGain)
|
||||||
|
assertEquals(0.98f, songs.single().trackPeak)
|
||||||
|
assertEquals(0.99f, songs.single().albumPeak)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun albumSongsKeepTheOpenSubsonicReplayGain() =
|
||||||
|
runBlocking {
|
||||||
|
server.dispatcher =
|
||||||
|
object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||||
|
val url = request.requestUrl!!
|
||||||
|
return when (url.encodedPath) {
|
||||||
|
"/rest/search3.view" -> {
|
||||||
|
MockResponse().setBody(failed(10, "Required parameter is missing"))
|
||||||
|
}
|
||||||
|
|
||||||
|
"/rest/getAlbumList2.view" -> {
|
||||||
|
MockResponse().setBody(
|
||||||
|
"<subsonic-response status=\"ok\"><albumList2>" +
|
||||||
|
"<album id=\"al1\" name=\"Album\" artist=\"Artist\" artistId=\"ar1\" songCount=\"1\"/>" +
|
||||||
|
"</albumList2></subsonic-response>",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"/rest/getAlbum.view" -> {
|
||||||
|
MockResponse().setBody(
|
||||||
|
"<subsonic-response status=\"ok\"><album id=\"al1\">" +
|
||||||
|
"<song id=\"s1\" title=\"Gained\">" +
|
||||||
|
"<replayGain trackGain=\"-3.2\" albumGain=\"-2.1\" trackPeak=\"0.98\" albumPeak=\"0.99\"/>" +
|
||||||
|
"</song></album></subsonic-response>",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
MockResponse().setResponseCode(404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val songs = SubsonicSource(1, client()).songs().toList().flatten()
|
||||||
|
|
||||||
|
assertEquals(-3.2f, songs.single().trackGain)
|
||||||
|
assertEquals(-2.1f, songs.single().albumGain)
|
||||||
|
assertEquals(0.98f, songs.single().trackPeak)
|
||||||
|
assertEquals(0.99f, songs.single().albumPeak)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun entitiesWithEmptyIdsAreDropped() =
|
fun entitiesWithEmptyIdsAreDropped() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
@@ -608,6 +677,40 @@ class SubsonicSourceTest {
|
|||||||
assertTrue(maxInFlight.get() in 2..DEFAULT_FETCH_CONCURRENCY)
|
assertTrue(maxInFlight.get() in 2..DEFAULT_FETCH_CONCURRENCY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun albumSongsAreEmittedInBoundedBatches() =
|
||||||
|
runBlocking {
|
||||||
|
val albumCount = 200
|
||||||
|
val songsPerAlbum = 3
|
||||||
|
server.dispatcher =
|
||||||
|
object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||||
|
val url = request.requestUrl!!
|
||||||
|
return when (url.encodedPath) {
|
||||||
|
"/rest/search3.view" -> {
|
||||||
|
MockResponse().setBody(failed(10, "Required parameter is missing"))
|
||||||
|
}
|
||||||
|
|
||||||
|
"/rest/getAlbumList2.view" -> {
|
||||||
|
MockResponse().setBody(albumListOf(albumCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
"/rest/getAlbum.view" -> {
|
||||||
|
MockResponse().setBody(albumWithSongs(url.queryParameter("id")!!, songsPerAlbum))
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
MockResponse().setResponseCode(404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val batches = SubsonicSource(1, client()).songs().toList().map { it.size }
|
||||||
|
|
||||||
|
assertEquals(listOf(500, 100), batches)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun playlistEntriesWithoutIdsAreSkipped() =
|
fun playlistEntriesWithoutIdsAreSkipped() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
@@ -705,6 +808,13 @@ class SubsonicSourceTest {
|
|||||||
"<song id=\"$albumId-s1\" title=\"One\"/>" +
|
"<song id=\"$albumId-s1\" title=\"One\"/>" +
|
||||||
"</album></subsonic-response>"
|
"</album></subsonic-response>"
|
||||||
|
|
||||||
|
private fun albumWithSongs(
|
||||||
|
albumId: String,
|
||||||
|
count: Int,
|
||||||
|
) = "<subsonic-response status=\"ok\"><album id=\"$albumId\">" +
|
||||||
|
(1..count).joinToString("") { "<song id=\"$albumId-s$it\" title=\"Title $it\"/>" } +
|
||||||
|
"</album></subsonic-response>"
|
||||||
|
|
||||||
private fun failed(
|
private fun failed(
|
||||||
code: Int,
|
code: Int,
|
||||||
message: String,
|
message: String,
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ class SubsonicXmlTest {
|
|||||||
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
|
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
|
||||||
).map { it.toAlbum() }
|
).map { it.toAlbum() }
|
||||||
|
|
||||||
|
private fun songs(xml: String) =
|
||||||
|
parse(
|
||||||
|
xml,
|
||||||
|
tag = "song",
|
||||||
|
create = { SubsonicXml.songDraft(1, it) },
|
||||||
|
onChild = SubsonicXml::songChild,
|
||||||
|
).map { it.toSong() }
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun mapsArtists() {
|
fun mapsArtists() {
|
||||||
val artists =
|
val artists =
|
||||||
@@ -156,7 +164,8 @@ class SubsonicXmlTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun mapsSongGenresAndIgnoresEmptyOnes() {
|
fun mapsSongGenresAndIgnoresEmptyOnes() {
|
||||||
val xml =
|
val songs =
|
||||||
|
songs(
|
||||||
"""
|
"""
|
||||||
<subsonic-response status="ok">
|
<subsonic-response status="ok">
|
||||||
<searchResult3>
|
<searchResult3>
|
||||||
@@ -168,22 +177,132 @@ class SubsonicXmlTest {
|
|||||||
</song>
|
</song>
|
||||||
</searchResult3>
|
</searchResult3>
|
||||||
</subsonic-response>
|
</subsonic-response>
|
||||||
""".trimIndent()
|
""".trimIndent(),
|
||||||
|
)
|
||||||
val songs =
|
|
||||||
parse(
|
|
||||||
xml,
|
|
||||||
tag = "song",
|
|
||||||
create = { SubsonicXml.songDraft(1, it) },
|
|
||||||
onChild = { draft, name, attrs -> if (name == "genres") draft.addGenre(attrs) },
|
|
||||||
).map { it.toSong() }
|
|
||||||
|
|
||||||
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
|
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapsSongReplayGain() {
|
||||||
|
val songs =
|
||||||
|
songs(
|
||||||
|
"""
|
||||||
|
<subsonic-response status="ok">
|
||||||
|
<searchResult3>
|
||||||
|
<song id="s1" title="Gained">
|
||||||
|
<replayGain trackGain="-3.2" albumGain="-2.1" trackPeak="0.98" albumPeak="0.99"/>
|
||||||
|
</song>
|
||||||
|
</searchResult3>
|
||||||
|
</subsonic-response>
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val song = songs.single()
|
||||||
|
assertEquals(-3.2f, song.trackGain)
|
||||||
|
assertEquals(-2.1f, song.albumGain)
|
||||||
|
assertEquals(0.98f, song.trackPeak)
|
||||||
|
assertEquals(0.99f, song.albumPeak)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun leavesMissingReplayGainFieldsUnset() {
|
||||||
|
val songs =
|
||||||
|
songs(
|
||||||
|
"""
|
||||||
|
<subsonic-response status="ok">
|
||||||
|
<searchResult3>
|
||||||
|
<song id="s1" title="Track gain only">
|
||||||
|
<replayGain trackGain="-3.2"/>
|
||||||
|
</song>
|
||||||
|
<song id="s2" title="No gain"/>
|
||||||
|
</searchResult3>
|
||||||
|
</subsonic-response>
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(-3.2f, songs[0].trackGain)
|
||||||
|
assertEquals(null, songs[0].albumGain)
|
||||||
|
assertEquals(null, songs[0].trackPeak)
|
||||||
|
assertEquals(null, songs[0].albumPeak)
|
||||||
|
assertEquals(null, songs[1].trackGain)
|
||||||
|
assertEquals(null, songs[1].albumGain)
|
||||||
|
assertEquals(null, songs[1].trackPeak)
|
||||||
|
assertEquals(null, songs[1].albumPeak)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapsReplayGainPeaksWithoutGains() {
|
||||||
|
val songs =
|
||||||
|
songs(
|
||||||
|
"""
|
||||||
|
<subsonic-response status="ok">
|
||||||
|
<searchResult3>
|
||||||
|
<song id="s1" title="Peaks only">
|
||||||
|
<replayGain trackPeak="0.98" albumPeak="0.99"/>
|
||||||
|
</song>
|
||||||
|
</searchResult3>
|
||||||
|
</subsonic-response>
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(null, songs.single().trackGain)
|
||||||
|
assertEquals(null, songs.single().albumGain)
|
||||||
|
assertEquals(0.98f, songs.single().trackPeak)
|
||||||
|
assertEquals(0.99f, songs.single().albumPeak)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun keepsTheFieldsOfEveryReplayGainElement() {
|
||||||
|
val songs =
|
||||||
|
songs(
|
||||||
|
"""
|
||||||
|
<subsonic-response status="ok">
|
||||||
|
<searchResult3>
|
||||||
|
<song id="s1" title="Split">
|
||||||
|
<replayGain trackGain="-3.2"/>
|
||||||
|
<replayGain trackPeak="0.98"/>
|
||||||
|
</song>
|
||||||
|
</searchResult3>
|
||||||
|
</subsonic-response>
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(-3.2f, songs.single().trackGain)
|
||||||
|
assertEquals(0.98f, songs.single().trackPeak)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ignoresNonFiniteReplayGainValues() {
|
||||||
|
val songs =
|
||||||
|
songs(
|
||||||
|
"""
|
||||||
|
<subsonic-response status="ok">
|
||||||
|
<searchResult3>
|
||||||
|
<song id="s1" title="Junk">
|
||||||
|
<replayGain trackGain="Infinity" albumGain="NaN" trackPeak="1e40" albumPeak="-Infinity"/>
|
||||||
|
</song>
|
||||||
|
<song id="s2" title="Mostly fine">
|
||||||
|
<replayGain trackGain="-3.2" albumGain="NaN" trackPeak="0.98"/>
|
||||||
|
</song>
|
||||||
|
</searchResult3>
|
||||||
|
</subsonic-response>
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(null, songs[0].trackGain)
|
||||||
|
assertEquals(null, songs[0].albumGain)
|
||||||
|
assertEquals(null, songs[0].trackPeak)
|
||||||
|
assertEquals(null, songs[0].albumPeak)
|
||||||
|
assertEquals(-3.2f, songs[1].trackGain)
|
||||||
|
assertEquals(null, songs[1].albumGain)
|
||||||
|
assertEquals(0.98f, songs[1].trackPeak)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun ignoresSongGenresThatAreNotDirectChildren() {
|
fun ignoresSongGenresThatAreNotDirectChildren() {
|
||||||
val xml =
|
val songs =
|
||||||
|
songs(
|
||||||
"""
|
"""
|
||||||
<subsonic-response status="ok">
|
<subsonic-response status="ok">
|
||||||
<searchResult3>
|
<searchResult3>
|
||||||
@@ -192,15 +311,8 @@ class SubsonicXmlTest {
|
|||||||
</song>
|
</song>
|
||||||
</searchResult3>
|
</searchResult3>
|
||||||
</subsonic-response>
|
</subsonic-response>
|
||||||
""".trimIndent()
|
""".trimIndent(),
|
||||||
|
)
|
||||||
val songs =
|
|
||||||
parse(
|
|
||||||
xml,
|
|
||||||
tag = "song",
|
|
||||||
create = { SubsonicXml.songDraft(1, it) },
|
|
||||||
onChild = { draft, name, attrs -> if (name == "genres") draft.addGenre(attrs) },
|
|
||||||
).map { it.toSong() }
|
|
||||||
|
|
||||||
assertEquals(emptyList<String>(), songs.single().genres)
|
assertEquals(emptyList<String>(), songs.single().genres)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -887,6 +887,47 @@ class SyncServiceTest {
|
|||||||
assertEquals(before, totalChanges())
|
assertEquals(before, totalChanges())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun storesReplayGainAndClearsItWhenTheServerStopsSendingIt() =
|
||||||
|
runTest {
|
||||||
|
insertSource()
|
||||||
|
val source =
|
||||||
|
FakeMusicSource(
|
||||||
|
albums = listOf(album("al1")),
|
||||||
|
songs =
|
||||||
|
listOf(
|
||||||
|
song("s1").copy(
|
||||||
|
trackGain = -3.2f,
|
||||||
|
albumGain = -2.1f,
|
||||||
|
trackPeak = 0.98f,
|
||||||
|
albumPeak = 0.99f,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
SyncService(db, source).sync()
|
||||||
|
|
||||||
|
val stored = db.libraryDao().song(1, "s1").first()!!
|
||||||
|
assertEquals(-3.2f, stored.trackGain)
|
||||||
|
assertEquals(-2.1f, stored.albumGain)
|
||||||
|
assertEquals(0.98f, stored.trackPeak)
|
||||||
|
assertEquals(0.99f, stored.albumPeak)
|
||||||
|
|
||||||
|
val before = totalChanges()
|
||||||
|
SyncService(db, source).sync()
|
||||||
|
assertEquals(before, totalChanges())
|
||||||
|
|
||||||
|
source.songs = listOf(song("s1"))
|
||||||
|
|
||||||
|
SyncService(db, source).sync()
|
||||||
|
|
||||||
|
val cleared = db.libraryDao().song(1, "s1").first()!!
|
||||||
|
assertEquals(null, cleared.trackGain)
|
||||||
|
assertEquals(null, cleared.albumGain)
|
||||||
|
assertEquals(null, cleared.trackPeak)
|
||||||
|
assertEquals(null, cleared.albumPeak)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun totalChanges(): Long =
|
private suspend fun totalChanges(): Long =
|
||||||
db.useWriterConnection { connection ->
|
db.useWriterConnection { connection ->
|
||||||
connection.usePrepared("SELECT total_changes()") { statement ->
|
connection.usePrepared("SELECT total_changes()") { statement ->
|
||||||
|
|||||||
@@ -54,4 +54,39 @@ class MediaItemTest {
|
|||||||
assertEquals("Song", item.mediaMetadata.title.toString())
|
assertEquals("Song", item.mediaMetadata.title.toString())
|
||||||
assertEquals(CoverArtArtwork.uri("al1"), item.mediaMetadata.artworkUri)
|
assertEquals(CoverArtArtwork.uri("al1"), item.mediaMetadata.artworkUri)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theItemCarriesTheReplayGain() {
|
||||||
|
val gain = ReplayGain(trackGain = -3.2f, albumGain = -2.1f, trackPeak = 0.98f, albumPeak = 0.99f)
|
||||||
|
|
||||||
|
val item =
|
||||||
|
queueMediaItem(
|
||||||
|
song.copy(replayGain = gain),
|
||||||
|
local = null,
|
||||||
|
stream = "http://server/stream?s=s1",
|
||||||
|
transcode = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(gain, item.replayGain())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anItemWithoutGainReadsBackAsNoGain() {
|
||||||
|
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false)
|
||||||
|
|
||||||
|
assertEquals(ReplayGain(), item.replayGain())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aZeroGainIsNotMistakenForNoGain() {
|
||||||
|
val item =
|
||||||
|
queueMediaItem(
|
||||||
|
song.copy(replayGain = ReplayGain(trackGain = 0f, trackPeak = 0.5f)),
|
||||||
|
local = null,
|
||||||
|
stream = "http://server/stream?s=s1",
|
||||||
|
transcode = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(ReplayGain(trackGain = 0f, trackPeak = 0.5f), item.replayGain())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class QueueItemTest {
|
||||||
|
@Test
|
||||||
|
fun carriesTheSongsReplayGain() {
|
||||||
|
val song =
|
||||||
|
Song(
|
||||||
|
sourceId = 1,
|
||||||
|
id = "s1",
|
||||||
|
albumId = "al1",
|
||||||
|
artistId = "ar1",
|
||||||
|
title = "Song",
|
||||||
|
album = "Album",
|
||||||
|
artist = "Artist",
|
||||||
|
duration = 100,
|
||||||
|
track = 1,
|
||||||
|
disc = 1,
|
||||||
|
starred = null,
|
||||||
|
trackGain = -3.2f,
|
||||||
|
albumGain = -2.1f,
|
||||||
|
trackPeak = 0.98f,
|
||||||
|
albumPeak = 0.99f,
|
||||||
|
)
|
||||||
|
|
||||||
|
val item = AlbumSongItem(song, coverArt = "al1").toQueueItem()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ReplayGain(trackGain = -3.2f, albumGain = -2.1f, trackPeak = 0.98f, albumPeak = 0.99f),
|
||||||
|
item.replayGain,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSongWithoutGainCarriesNoGain() {
|
||||||
|
val item =
|
||||||
|
AlbumSongItem(
|
||||||
|
Song(
|
||||||
|
sourceId = 1,
|
||||||
|
id = "s1",
|
||||||
|
albumId = "al1",
|
||||||
|
artistId = "ar1",
|
||||||
|
title = "Song",
|
||||||
|
album = "Album",
|
||||||
|
artist = "Artist",
|
||||||
|
duration = 100,
|
||||||
|
track = 1,
|
||||||
|
disc = 1,
|
||||||
|
starred = null,
|
||||||
|
),
|
||||||
|
coverArt = null,
|
||||||
|
).toQueueItem()
|
||||||
|
|
||||||
|
assertEquals(ReplayGain(), item.replayGain)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ReplayGainControllerTest {
|
||||||
|
private val applied = mutableListOf<Float>()
|
||||||
|
private val controller = ReplayGainController { applied += it }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appliesTheItemsGain() {
|
||||||
|
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
|
||||||
|
controller.applyItem(item("a", trackGain = -6.0206f))
|
||||||
|
|
||||||
|
assertEquals(0.5f, applied.last(), TOLERANCE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recomputesWhenTheSettingsChange() {
|
||||||
|
controller.applyItem(item("a", trackGain = -6.0206f))
|
||||||
|
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 6.0206f))
|
||||||
|
|
||||||
|
assertEquals(1f, applied.last(), TOLERANCE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun switchingOffRestoresUnityGain() {
|
||||||
|
controller.applyItem(item("a", trackGain = -6.0206f))
|
||||||
|
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
|
||||||
|
assertEquals(0.5f, applied.last(), TOLERANCE)
|
||||||
|
|
||||||
|
controller.setSettings(ReplayGainSettings(ReplayGainMode.Off, 0f))
|
||||||
|
|
||||||
|
assertEquals(1f, applied.last(), TOLERANCE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anItemWithoutGainPlaysAtUnity() {
|
||||||
|
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
|
||||||
|
controller.applyItem(item("a", trackGain = -6.0206f))
|
||||||
|
assertEquals(0.5f, applied.last(), TOLERANCE)
|
||||||
|
|
||||||
|
controller.applyItem(item("b", trackGain = null))
|
||||||
|
|
||||||
|
assertEquals(1f, applied.last(), TOLERANCE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theLatestItemLeavesItsGainApplied() {
|
||||||
|
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
|
||||||
|
|
||||||
|
controller.applyItem(item("a", trackGain = 6.0206f))
|
||||||
|
controller.applyItem(item("b", trackGain = -6.0206f))
|
||||||
|
|
||||||
|
assertEquals(0.5f, applied.last(), TOLERANCE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun item(
|
||||||
|
id: String,
|
||||||
|
trackGain: Float?,
|
||||||
|
) = queueMediaItem(
|
||||||
|
QueueItem(
|
||||||
|
id = id,
|
||||||
|
title = id,
|
||||||
|
artist = null,
|
||||||
|
album = null,
|
||||||
|
coverArtId = null,
|
||||||
|
replayGain = ReplayGain(trackGain = trackGain),
|
||||||
|
),
|
||||||
|
local = null,
|
||||||
|
stream = "http://server/$id",
|
||||||
|
transcode = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TOLERANCE = 0.001f
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.audio.AudioProcessor.AudioFormat
|
||||||
|
import androidx.media3.common.audio.AudioProcessor.StreamMetadata
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.source.SinglePeriodTimeline
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ReplayGainProcessorTest {
|
||||||
|
@Test
|
||||||
|
fun scalesSixteenBitSamples() {
|
||||||
|
val processor = processor(gain = 0.5f)
|
||||||
|
|
||||||
|
processor.queueInput(sixteenBit(100, -200, 1000, -1000))
|
||||||
|
|
||||||
|
assertEquals(listOf(50, -100, 500, -500), readShorts(processor.getOutput()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun passesSamplesThroughAtUnityGain() {
|
||||||
|
val processor = processor(gain = 1f)
|
||||||
|
|
||||||
|
processor.queueInput(sixteenBit(1234, -4321))
|
||||||
|
|
||||||
|
assertEquals(listOf(1234, -4321), readShorts(processor.getOutput()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun saturatesInsteadOfWrappingWhenTheGainOverflows() {
|
||||||
|
val processor = processor(gain = 4f)
|
||||||
|
|
||||||
|
processor.queueInput(sixteenBit(20000, -20000))
|
||||||
|
|
||||||
|
assertEquals(listOf(32767, -32768), readShorts(processor.getOutput()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scalesFloatSamples() {
|
||||||
|
val processor = processor(gain = 0.5f, encoding = C.ENCODING_PCM_FLOAT)
|
||||||
|
|
||||||
|
processor.queueInput(floatBuffer(1f, -0.5f))
|
||||||
|
|
||||||
|
assertEquals(listOf(0.5f, -0.25f), readFloats(processor.getOutput()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun passesAnUnsupportedEncodingThroughUnchanged() {
|
||||||
|
val processor = processor(gain = 2f, encoding = C.ENCODING_PCM_24BIT)
|
||||||
|
|
||||||
|
processor.queueInput(ByteBuffer.wrap(byteArrayOf(1, 2, 3)))
|
||||||
|
|
||||||
|
assertEquals(listOf<Byte>(1, 2, 3), readBytes(processor.getOutput()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun passesSamplesThroughWhenTheGainIsNaN() {
|
||||||
|
val processor = processor(gain = Float.NaN)
|
||||||
|
|
||||||
|
processor.queueInput(sixteenBit(1234, -4321))
|
||||||
|
|
||||||
|
assertEquals(listOf(1234, -4321), readShorts(processor.getOutput()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun passesSamplesThroughWhenTheGainIsInfinite() {
|
||||||
|
val processor = processor(gain = Float.POSITIVE_INFINITY)
|
||||||
|
|
||||||
|
processor.queueInput(sixteenBit(1234, -4321))
|
||||||
|
|
||||||
|
assertEquals(listOf(1234, -4321), readShorts(processor.getOutput()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun keepsTheInputFormatSoTheSinkKeepsItInThePipeline() {
|
||||||
|
val processor = ReplayGainProcessor()
|
||||||
|
|
||||||
|
val configured = processor.configure(AudioFormat(SAMPLE_RATE, CHANNELS, C.ENCODING_PCM_16BIT))
|
||||||
|
|
||||||
|
assertEquals(C.ENCODING_PCM_16BIT, configured.encoding)
|
||||||
|
assertEquals(true, processor.isActive())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFlushReportsTheItemBeingFlushed() {
|
||||||
|
val reported = mutableListOf<MediaItem>()
|
||||||
|
val item = item("a", trackGain = -6.0206f)
|
||||||
|
|
||||||
|
ReplayGainProcessor { reported += it }.flush(streamMetadata(item))
|
||||||
|
|
||||||
|
assertEquals(listOf(item), reported)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFlushWithoutAPeriodLeavesTheGainAlone() {
|
||||||
|
lateinit var processor: ReplayGainProcessor
|
||||||
|
val controller = ReplayGainController { processor.setLinearGain(it) }
|
||||||
|
processor = ReplayGainProcessor { controller.applyItem(it) }
|
||||||
|
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
|
||||||
|
processor.configure(AudioFormat(SAMPLE_RATE, CHANNELS, C.ENCODING_PCM_16BIT))
|
||||||
|
|
||||||
|
processor.flush(streamMetadata(item("a", trackGain = -6.0206f)))
|
||||||
|
processor.flush(StreamMetadata.DEFAULT)
|
||||||
|
processor.queueInput(sixteenBit(1000))
|
||||||
|
|
||||||
|
assertEquals(listOf(500), readShorts(processor.getOutput()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFlushMovesTheGainToTheNextItem() {
|
||||||
|
lateinit var processor: ReplayGainProcessor
|
||||||
|
val controller = ReplayGainController { processor.setLinearGain(it) }
|
||||||
|
processor = ReplayGainProcessor { controller.applyItem(it) }
|
||||||
|
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
|
||||||
|
processor.configure(AudioFormat(SAMPLE_RATE, CHANNELS, C.ENCODING_PCM_16BIT))
|
||||||
|
|
||||||
|
processor.flush(streamMetadata(item("a", trackGain = -6.0206f)))
|
||||||
|
processor.queueInput(sixteenBit(1000))
|
||||||
|
assertEquals(listOf(500), readShorts(processor.getOutput()))
|
||||||
|
|
||||||
|
processor.flush(streamMetadata(item("b", trackGain = 6.0206f)))
|
||||||
|
processor.queueInput(sixteenBit(1000))
|
||||||
|
assertEquals(listOf(2000), readShorts(processor.getOutput()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processor(
|
||||||
|
gain: Float,
|
||||||
|
encoding: Int = C.ENCODING_PCM_16BIT,
|
||||||
|
): ReplayGainProcessor =
|
||||||
|
ReplayGainProcessor().apply {
|
||||||
|
setLinearGain(gain)
|
||||||
|
configure(AudioFormat(SAMPLE_RATE, CHANNELS, encoding))
|
||||||
|
flush(StreamMetadata.DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun item(
|
||||||
|
id: String,
|
||||||
|
trackGain: Float,
|
||||||
|
): MediaItem =
|
||||||
|
queueMediaItem(
|
||||||
|
QueueItem(
|
||||||
|
id = id,
|
||||||
|
title = id,
|
||||||
|
artist = null,
|
||||||
|
album = null,
|
||||||
|
coverArtId = null,
|
||||||
|
replayGain = ReplayGain(trackGain = trackGain),
|
||||||
|
),
|
||||||
|
local = null,
|
||||||
|
stream = "http://server/$id",
|
||||||
|
transcode = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun streamMetadata(item: MediaItem): StreamMetadata {
|
||||||
|
val timeline = SinglePeriodTimeline(DURATION_US, false, false, false, null, item)
|
||||||
|
return StreamMetadata
|
||||||
|
.Builder()
|
||||||
|
.setTimeline(timeline)
|
||||||
|
.setPeriodUid(timeline.getUidOfPeriod(0))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sixteenBit(vararg samples: Int): ByteBuffer =
|
||||||
|
ByteBuffer.allocate(samples.size * 2).order(ByteOrder.nativeOrder()).apply {
|
||||||
|
samples.forEach { putShort(it.toShort()) }
|
||||||
|
flip()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun floatBuffer(vararg samples: Float): ByteBuffer =
|
||||||
|
ByteBuffer.allocate(samples.size * 4).order(ByteOrder.nativeOrder()).apply {
|
||||||
|
samples.forEach { putFloat(it) }
|
||||||
|
flip()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readShorts(buffer: ByteBuffer): List<Int> =
|
||||||
|
buildList {
|
||||||
|
while (buffer.remaining() >= 2) add(buffer.short.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readFloats(buffer: ByteBuffer): List<Float> =
|
||||||
|
buildList {
|
||||||
|
while (buffer.remaining() >= 4) add(buffer.float)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readBytes(buffer: ByteBuffer): List<Byte> =
|
||||||
|
buildList {
|
||||||
|
while (buffer.hasRemaining()) add(buffer.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SAMPLE_RATE = 44_100
|
||||||
|
const val CHANNELS = 2
|
||||||
|
const val DURATION_US = 1_000_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.audio.AudioProcessorChain
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.audio.DefaultAudioSink
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ReplayGainRenderersFactoryTest {
|
||||||
|
@Test
|
||||||
|
fun installsTheGainProcessorInTheAudioSink() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
val processor = ReplayGainProcessor()
|
||||||
|
|
||||||
|
val sink = ReplayGainRenderersFactory(context, processor).buildAudioSink(context, false, false)
|
||||||
|
|
||||||
|
// DefaultAudioSink exposes no accessor for the chain it was built with.
|
||||||
|
val chain =
|
||||||
|
DefaultAudioSink::class.java
|
||||||
|
.getDeclaredField("audioProcessorChain")
|
||||||
|
.apply { isAccessible = true }
|
||||||
|
.get(sink) as AudioProcessorChain
|
||||||
|
|
||||||
|
assertEquals(true, chain.audioProcessors.any { it === processor })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.audio.AudioSink
|
||||||
|
import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId
|
||||||
|
import androidx.media3.exoplayer.source.SinglePeriodTimeline
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
// Pins the media3 behaviour the whole feature rests on: DefaultAudioSink hands the processor the
|
||||||
|
// timeline and the period being configured, on every configuration, which is what lets the gain be
|
||||||
|
// set before a new item's buffers. See androidx/media#418.
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ReplayGainSinkTest {
|
||||||
|
@Test
|
||||||
|
fun theSinkHandsTheItemBeingConfiguredToTheProcessor() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
val reported = mutableListOf<MediaItem>()
|
||||||
|
val processor = ReplayGainProcessor { reported += it }
|
||||||
|
val sink = ReplayGainRenderersFactory(context, processor).buildAudioSink(context, false, false)
|
||||||
|
|
||||||
|
val item = MediaItem.Builder().setMediaId("b").build()
|
||||||
|
val timeline = SinglePeriodTimeline(DURATION_US, false, false, false, null, item)
|
||||||
|
sink.configure(
|
||||||
|
AudioSink.AudioSinkConfig
|
||||||
|
.Builder(pcmFormat())
|
||||||
|
.setTimeline(timeline)
|
||||||
|
.setMediaPeriodId(MediaPeriodId(timeline.getUidOfPeriod(0)))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
sink.handleBuffer(sixteenBit(1000), 0, 1)
|
||||||
|
|
||||||
|
assertEquals(listOf(item), reported)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pcmFormat() =
|
||||||
|
Format
|
||||||
|
.Builder()
|
||||||
|
.setSampleMimeType(MimeTypes.AUDIO_RAW)
|
||||||
|
.setPcmEncoding(C.ENCODING_PCM_16BIT)
|
||||||
|
.setChannelCount(2)
|
||||||
|
.setSampleRate(44_100)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun sixteenBit(vararg samples: Int): ByteBuffer =
|
||||||
|
ByteBuffer.allocate(samples.size * 2).order(ByteOrder.nativeOrder()).apply {
|
||||||
|
samples.forEach { putShort(it.toShort()) }
|
||||||
|
flip()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val DURATION_US = 1_000_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ReplayGainTest {
|
||||||
|
@Test
|
||||||
|
fun trackModeAppliesTheTrackGain() {
|
||||||
|
assertEquals(
|
||||||
|
0.5f,
|
||||||
|
replayGainLinearGain(
|
||||||
|
ReplayGain(trackGain = -6.0206f, albumGain = -1f, trackPeak = 1f, albumPeak = 1f),
|
||||||
|
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||||
|
),
|
||||||
|
TOLERANCE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trackModeFallsBackToTheAlbumGain() {
|
||||||
|
assertEquals(
|
||||||
|
0.5f,
|
||||||
|
replayGainLinearGain(
|
||||||
|
ReplayGain(albumGain = -6.0206f),
|
||||||
|
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||||
|
),
|
||||||
|
TOLERANCE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun albumModeAppliesTheAlbumGain() {
|
||||||
|
assertEquals(
|
||||||
|
0.5f,
|
||||||
|
replayGainLinearGain(
|
||||||
|
ReplayGain(trackGain = -1f, albumGain = -6.0206f, trackPeak = 1f, albumPeak = 1f),
|
||||||
|
ReplayGainSettings(ReplayGainMode.Album, 0f),
|
||||||
|
),
|
||||||
|
TOLERANCE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun albumModeFallsBackToTheTrackGain() {
|
||||||
|
assertEquals(
|
||||||
|
0.5f,
|
||||||
|
replayGainLinearGain(
|
||||||
|
ReplayGain(trackGain = -6.0206f),
|
||||||
|
ReplayGainSettings(ReplayGainMode.Album, 0f),
|
||||||
|
),
|
||||||
|
TOLERANCE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun offAppliesUnityGain() {
|
||||||
|
assertEquals(
|
||||||
|
1f,
|
||||||
|
replayGainLinearGain(
|
||||||
|
ReplayGain(trackGain = -6.0206f, albumGain = -6.0206f),
|
||||||
|
ReplayGainSettings(ReplayGainMode.Off, 0f),
|
||||||
|
),
|
||||||
|
TOLERANCE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSongWithoutGainPlaysAtUnity() {
|
||||||
|
assertEquals(
|
||||||
|
1f,
|
||||||
|
replayGainLinearGain(ReplayGain(), ReplayGainSettings(ReplayGainMode.Track, 3f)),
|
||||||
|
TOLERANCE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thePreampShiftsTheAppliedGain() {
|
||||||
|
assertEquals(
|
||||||
|
1f,
|
||||||
|
replayGainLinearGain(
|
||||||
|
ReplayGain(trackGain = -6.0206f),
|
||||||
|
ReplayGainSettings(ReplayGainMode.Track, 6.0206f),
|
||||||
|
),
|
||||||
|
TOLERANCE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thePeakCapsAPositiveGain() {
|
||||||
|
assertEquals(
|
||||||
|
2f,
|
||||||
|
replayGainLinearGain(
|
||||||
|
ReplayGain(trackGain = 12f, trackPeak = 0.5f),
|
||||||
|
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||||
|
),
|
||||||
|
TOLERANCE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thePeakCapsUsingTheAlbumPeakWhenTheTrackPeakIsMissing() {
|
||||||
|
assertEquals(
|
||||||
|
2f,
|
||||||
|
replayGainLinearGain(
|
||||||
|
ReplayGain(trackGain = 12f, albumPeak = 0.5f),
|
||||||
|
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||||
|
),
|
||||||
|
TOLERANCE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aMissingOrZeroPeakDoesNotCap() {
|
||||||
|
val settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
|
||||||
|
val expected = 3.9810717f
|
||||||
|
|
||||||
|
assertEquals(expected, replayGainLinearGain(ReplayGain(trackGain = 12f), settings), TOLERANCE)
|
||||||
|
assertEquals(expected, replayGainLinearGain(ReplayGain(trackGain = 12f, trackPeak = 0f), settings), TOLERANCE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTruePeakAboveFullScaleCapsBelowUnity() {
|
||||||
|
assertEquals(
|
||||||
|
0.5f,
|
||||||
|
replayGainLinearGain(
|
||||||
|
ReplayGain(trackGain = 6f, trackPeak = 2f),
|
||||||
|
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||||
|
),
|
||||||
|
TOLERANCE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thePeakNeverLowersAnAttenuation() {
|
||||||
|
assertEquals(
|
||||||
|
0.31622776f,
|
||||||
|
replayGainLinearGain(
|
||||||
|
ReplayGain(trackGain = -10f, trackPeak = 0.9f),
|
||||||
|
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||||
|
),
|
||||||
|
TOLERANCE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aNonFinitePeakDoesNotCap() {
|
||||||
|
assertEquals(
|
||||||
|
3.9810717f,
|
||||||
|
replayGainLinearGain(
|
||||||
|
ReplayGain(trackGain = 12f, trackPeak = Float.POSITIVE_INFINITY),
|
||||||
|
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||||
|
),
|
||||||
|
TOLERANCE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun playsAtUnityWhenTheGainIsNotFinite() {
|
||||||
|
val settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
|
||||||
|
|
||||||
|
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = Float.POSITIVE_INFINITY), settings), TOLERANCE)
|
||||||
|
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = Float.NEGATIVE_INFINITY), settings), TOLERANCE)
|
||||||
|
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = Float.NaN), settings), TOLERANCE)
|
||||||
|
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = -Float.MAX_VALUE), settings), TOLERANCE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun playsAtUnityWhenANonFiniteGainWouldOtherwiseBeCapped() {
|
||||||
|
assertEquals(
|
||||||
|
1f,
|
||||||
|
replayGainLinearGain(
|
||||||
|
ReplayGain(trackGain = Float.POSITIVE_INFINITY, trackPeak = 0.001f),
|
||||||
|
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||||
|
),
|
||||||
|
TOLERANCE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thePeakFallsBackWhenThePreferredOneCarriesNoData() {
|
||||||
|
assertEquals(
|
||||||
|
2f,
|
||||||
|
replayGainLinearGain(
|
||||||
|
ReplayGain(trackGain = 12f, trackPeak = 0f, albumPeak = 0.5f),
|
||||||
|
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||||
|
),
|
||||||
|
TOLERANCE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TOLERANCE = 0.001f
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,11 +12,15 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.test.assertCountEquals
|
import androidx.compose.ui.test.assertCountEquals
|
||||||
|
import androidx.compose.ui.test.hasScrollAction
|
||||||
|
import androidx.compose.ui.test.hasText
|
||||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||||
import androidx.compose.ui.test.onAllNodesWithTag
|
import androidx.compose.ui.test.onAllNodesWithTag
|
||||||
import androidx.compose.ui.test.onAllNodesWithText
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performScrollToNode
|
||||||
import androidx.compose.ui.test.performTouchInput
|
import androidx.compose.ui.test.performTouchInput
|
||||||
import androidx.compose.ui.test.swipeUp
|
import androidx.compose.ui.test.swipeUp
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
@@ -340,6 +344,22 @@ class AppScreenshotTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun settings() {
|
fun settings() {
|
||||||
|
setSettingsContent()
|
||||||
|
awaitText("Navidrome")
|
||||||
|
composeRule.onRoot().captureRoboImage("src/test/screenshots/settings.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun replayGainPreampDialog() {
|
||||||
|
setSettingsContent()
|
||||||
|
composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Preamp"))
|
||||||
|
composeRule.onAllNodesWithText("Preamp")[0].performClick()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onAllNodesWithText("Reset").assertCountEquals(1)
|
||||||
|
composeRule.onRoot().captureRoboImage("src/test/screenshots/replay_gain_preamp.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setSettingsContent() {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
SubtracksTheme {
|
SubtracksTheme {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
@@ -367,8 +387,6 @@ class AppScreenshotTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
awaitText("Navidrome")
|
|
||||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/settings.png")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.subtracks.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
|
||||||
|
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
|
||||||
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ReplayGainControlsTest {
|
||||||
|
private val resources = ApplicationProvider.getApplicationContext<Context>().resources
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun modeLabelsNameTheGainEachOneUses() {
|
||||||
|
assertEquals("Off", replayGainModeLabel(resources, ReplayGainMode.Off))
|
||||||
|
assertEquals("Track gain", replayGainModeLabel(resources, ReplayGainMode.Track))
|
||||||
|
assertEquals("Album gain", replayGainModeLabel(resources, ReplayGainMode.Album))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun preampLabelsCarryTheSignAndTheUnit() {
|
||||||
|
assertEquals("+3.0 dB", replayGainPreampLabel(resources, 3f))
|
||||||
|
assertEquals("-2.5 dB", replayGainPreampLabel(resources, -2.5f))
|
||||||
|
assertEquals("0.0 dB", replayGainPreampLabel(resources, 0f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theSliderHasOneStepPerHalfDecibel() {
|
||||||
|
assertEquals(59, REPLAY_GAIN_PREAMP_STEPS)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thePreampStepsAlongTheGridAndStopsAtTheEnds() {
|
||||||
|
assertEquals(-1.5f, stepReplayGainPreampDb(-1f, -1), 0f)
|
||||||
|
assertEquals(-0.5f, stepReplayGainPreampDb(-1f, 1), 0f)
|
||||||
|
assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, stepReplayGainPreampDb(MIN_REPLAY_GAIN_PREAMP_DB, -1), 0f)
|
||||||
|
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, stepReplayGainPreampDb(MAX_REPLAY_GAIN_PREAMP_DB, 1), 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thePreampSnapsBackOntoTheHalfDecibelGrid() {
|
||||||
|
assertEquals(0f, snapReplayGainPreampDb(-0f), 0f)
|
||||||
|
assertEquals(-1f, snapReplayGainPreampDb(-0.99999905f), 0f)
|
||||||
|
assertEquals(-3f, snapReplayGainPreampDb(-3.000001f), 0f)
|
||||||
|
assertEquals(14.5f, snapReplayGainPreampDb(14.6f), 0f)
|
||||||
|
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, snapReplayGainPreampDb(14.9f), 0f)
|
||||||
|
assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, snapReplayGainPreampDb(-14.9f), 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thePreampIsClampedToItsRange() {
|
||||||
|
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, snapReplayGainPreampDb(20f), 0f)
|
||||||
|
assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, snapReplayGainPreampDb(-20f), 0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,6 +98,8 @@ Server writes — now-playing, scrobbles and stars — go through a `ServerActio
|
|||||||
|
|
||||||
The media notification carries a star button for the playing track. `PlaybackService` adds a `com.subtracks.STAR` session command and a `CommandButton` to the `MediaSession`, and drives its filled/unfilled icon from the playing song's Room row. That path — `MediaSession.Callback`, `SessionCommand`, `CommandButton`, `SessionResult`/`SessionError` and `MediaSession.setMediaButtonPreferences` — is annotated `@UnstableApi` in Media3 1.11.1 and is therefore opted into explicitly: when bumping Media3, re-check those signatures and the notification's button resolution. The stable `setCustomLayout` is not a substitute here, because the notification manager only refreshes on `onMediaButtonPreferencesChanged`, so the icon would not update mid-playback.
|
The media notification carries a star button for the playing track. `PlaybackService` adds a `com.subtracks.STAR` session command and a `CommandButton` to the `MediaSession`, and drives its filled/unfilled icon from the playing song's Room row. That path — `MediaSession.Callback`, `SessionCommand`, `CommandButton`, `SessionResult`/`SessionError` and `MediaSession.setMediaButtonPreferences` — is annotated `@UnstableApi` in Media3 1.11.1 and is therefore opted into explicitly: when bumping Media3, re-check those signatures and the notification's button resolution. The stable `setCustomLayout` is not a substitute here, because the notification manager only refreshes on `onMediaButtonPreferencesChanged`, so the icon would not update mid-playback.
|
||||||
|
|
||||||
|
ReplayGain is applied at the audio sink. The gain the server reports per song (`trackGain`/`albumGain` and their peaks; `baseGain` and `fallbackGain` are ignored) is synced into the song row, carried onto the media item, and applied by a `ReplayGainProcessor` in `DefaultAudioSink`'s processor chain, installed through a `RenderersFactory`. The gain is set from `AudioProcessor.flush(StreamMetadata)`, which Media3 1.11 populates with the playback `Timeline` and the current period id (`androidx/media#418`) and calls on every sink configuration — including a media-item transition at an unchanged format — so a new item's gain is in place before any of its buffers are processed. That timing is the point: `Player.Listener.onMediaItemTransition` arrives on the app thread after the playback thread has already rendered, which is too late. When bumping Media3, re-check that `flush` still carries the timeline and period, and that the chain is reached at all: float output and audio offload both bypass it, which is why float output stays off. The mode (track/album/off) and a preamp are DataStore preferences read by `PlaybackService`, and the anti-clip cap uses the peak when the server sends one.
|
||||||
|
|
||||||
## Data model
|
## Data model
|
||||||
|
|
||||||
`sources` and `subsonic_sources` hold the configured servers. Library tables are keyed by `(sourceId, id)` and cascade from `sources`: `artists`, `albums`, `playlists`, `playlist_songs`, `songs`.
|
`sources` and `subsonic_sources` hold the configured servers. Library tables are keyed by `(sourceId, id)` and cascade from `sources`: `artists`, `albums`, `playlists`, `playlist_songs`, `songs`.
|
||||||
@@ -120,7 +122,7 @@ Images: grids and lists request a 256 px `getCoverArt` thumbnail; the 120 dp det
|
|||||||
|
|
||||||
A run is triggered by the user (pull-to-refresh or a Sync action) or by `AutoSync`, a foreground-only timer. While the app has a visible Activity `AutoSync` re-checks at most every 15 minutes (the configured interval, capped) whether `syncIntervalMinutes` has elapsed since the last attempt and, if the mode allows (`Off`, `Wi-Fi only` or `Always`, default `Wi-Fi only`), the app is online and a server is configured, it asks `SyncManager` for a silent sync; nothing runs while the app is backgrounded, and a missed interval catches up on the next open. Only the time of the last attempt is persisted, so a server that keeps failing is retried once per interval rather than on every poll. A silent sync still shows the progress notification and the Settings spinner, but never a toast.
|
A run is triggered by the user (pull-to-refresh or a Sync action) or by `AutoSync`, a foreground-only timer. While the app has a visible Activity `AutoSync` re-checks at most every 15 minutes (the configured interval, capped) whether `syncIntervalMinutes` has elapsed since the last attempt and, if the mode allows (`Off`, `Wi-Fi only` or `Always`, default `Wi-Fi only`), the app is online and a server is configured, it asks `SyncManager` for a silent sync; nothing runs while the app is backgrounded, and a missed interval catches up on the next open. Only the time of the last attempt is persisted, so a server that keeps failing is retried once per interval rather than on every poll. A silent sync still shows the progress notification and the Settings spinner, but never a toast.
|
||||||
|
|
||||||
Songs have a primary and a fallback fetch. The primary probes `search3` with an empty query once (memoized per source); if the server supports it, the library is paged through `search3` 500 songs at a time. Otherwise the fallback pages `getAlbumList2` and then issues one `getAlbum` per album — it works everywhere but costs a request per album instead of per 500 songs. Every paged loop stops on a short page and is capped at 1000 pages. Playlists are fetched whole, and their songs are stored by position so a playlist that got shorter (or was emptied) is trimmed at the end.
|
Songs have a primary and a fallback fetch. The primary probes `search3` with an empty query once (memoized per source); if the server supports it, the library is paged through `search3` 500 songs at a time. Otherwise the fallback pages `getAlbumList2` and then issues one `getAlbum` per album — it works everywhere but costs a request per album instead of per 500 songs, though it still coalesces the songs into 500-row batches before writing them. Every paged loop stops on a short page and is capped at 20,000 pages. Playlists are fetched whole, and their songs are stored by position so a playlist that got shorter (or was emptied) is trimmed at the end.
|
||||||
|
|
||||||
Cleanup is the "everything I had, minus everything the server just sent" step described above: artists, albums, songs and playlists delete the leftover IDs in 500-row chunks, disc titles are deleted per `(album, disc)` for albums that still exist, and playlist entries beyond the new count are removed along with entries for playlists that disappeared.
|
Cleanup is the "everything I had, minus everything the server just sent" step described above: artists, albums, songs and playlists delete the leftover IDs in 500-row chunks, disc titles are deleted per `(album, disc)` for albums that still exist, and playlist entries beyond the new count are removed along with entries for playlists that disappeared.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user