diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index cb35f8cf..236d6a6e 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -57,7 +57,7 @@ const val MAX_REPLAY_GAIN_PREAMP_DB = 15f enum class SyncMode { Off, WifiOnly, Always } -enum class ReplayGainMode { Off, Track, Album } +enum class ReplayGainMode { Off, Track, Album, Dynamic } data class ArtworkSeedValue( val cacheKey: String, diff --git a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt index 92b70794..79e79772 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -164,6 +164,8 @@ data class QueueSnapshot( } fun locateContext(contextPlay: Long): Pair? = locateIn(entries, flatContext(contextPlay) ?: return null) + + fun isAlbumContext(position: Long): Boolean = locate(position)?.first?.kind == QueueKind.Album } private fun entryIndexAfter( @@ -295,8 +297,8 @@ class QueueRepository( ?.takeIf { it.isNotBlank() } } - // An explicit song entry in the middle of a queue is a reordered context track, so - // label it by its album rather than losing the context name. + // A song entry can be a single or a track reordered out of a playlist, artist or + // genre, so label it by its album rather than losing the context name. QueueKind.Song -> { library .song(sourceId, refId) @@ -581,9 +583,9 @@ class QueueRepository( val toUpNext = snapshot.isUpNext(to) if (fromUpNext && toUpNext) { val song = itemAt(snapshot, from) ?: return false - val sourceId = snapshot.locate(from)?.first?.sourceId ?: return false + val located = snapshot.locate(from) ?: return false val removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from)) - val moved = ResolvedQueueEntry(songEntry(sourceId, song.song.id), 1L) + val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L) writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry }) return true } @@ -597,7 +599,7 @@ class QueueRepository( val located = snapshot.locateContext(playFrom) ?: return false val song = itemAt(snapshot, from) ?: return false val removed = removeEntry(snapshot.entries, playFrom) - val moved = ResolvedQueueEntry(songEntry(located.first.sourceId, song.song.id), 1L) + val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L) val insertAt = playTo.coerceIn(0, removed.sumOf { it.length }) write(compact(insertEntry(removed, insertAt, moved)).map { it.entry }) adjustAnchorOnMove(playFrom, insertAt) @@ -929,6 +931,18 @@ class QueueRepository( return count?.coerceAtMost(available) ?: available } + private fun movedEntry( + source: QueueEntry, + songId: String, + offsetInEntry: Long, + ): QueueEntry = + if (source.kind == QueueKind.Album) { + val offset = source.offset + offsetInEntry + source.range(offset, offset) + } else { + songEntry(source.sourceId, songId) + } + private fun QueueEntry.range( start: Long, end: Long, diff --git a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt index 770c51db..8c2a20b8 100644 --- a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt @@ -235,6 +235,7 @@ class Media3PlayerHandle( ?.takeIf { it.containsKey(EXTRA_DURATION_MS) } ?.getLong(EXTRA_DURATION_MS), replayGain = mediaItem.replayGain(), + albumContext = mediaItem.albumContext(), ) } @@ -244,6 +245,7 @@ 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" +private const val EXTRA_ALBUM_CONTEXT = "albumContext" internal fun MediaItem?.replayGain(): ReplayGain { val extras = this?.mediaMetadata?.extras ?: return ReplayGain() @@ -255,6 +257,8 @@ internal fun MediaItem?.replayGain(): ReplayGain { ) } +internal fun MediaItem?.albumContext(): Boolean = this?.mediaMetadata?.extras?.getBoolean(EXTRA_ALBUM_CONTEXT) ?: false + private fun Bundle.putGain( key: String, value: Float?, @@ -288,6 +292,7 @@ internal fun queueMediaItem( 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) + putBoolean(EXTRA_ALBUM_CONTEXT, item.albumContext) }, ).build() val builder = diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 5def03de..9449b2a5 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -35,6 +35,7 @@ data class QueueItem( val coverArtId: String?, val durationMs: Long? = null, val replayGain: ReplayGain = ReplayGain(), + val albumContext: Boolean = false, ) data class QueueContext( @@ -393,7 +394,7 @@ class PlaybackController( val current = currentPosition() ?: return@withLock val playing = player.playWhenReady val wasUpNext = snapshot.isUpNext(current) - val removed = if (position == current) null else queueRepository.itemAt(snapshot, position)?.toQueueItem() + val removed = if (position == current) null else queueItemAt(snapshot, position) queueRepository.removeAt(snapshot, position) var updated = readSnapshot() this.snapshot = updated @@ -455,7 +456,7 @@ class PlaybackController( if (snapshot.upNextSize > 0L || updated.upNextSize > 0L || snapshot.shuffled) { queueRepository.setCursor(target) if (position != current) { - rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target) + rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target, updated) refresh(target) } else { loadWindow(target, autoplay = playing, startPositionMs = 0) @@ -568,7 +569,7 @@ class PlaybackController( } else -> { - rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target) + rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target, updated) } } shiftWindowLocked(target) @@ -767,7 +768,7 @@ class PlaybackController( if (wasUpNext) { loadWindow(target, autoplay = true) } else { - rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target) + rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target, updated) refresh(target) } } @@ -863,7 +864,7 @@ class PlaybackController( layoutVersion++ queueRepository.setCursor(position) windowJob?.cancel() - rebuildWindow(queueRepository.window(updated, position, QUEUE_WINDOW_RADIUS), position) + rebuildWindow(queueRepository.window(updated, position, QUEUE_WINDOW_RADIUS), position, updated) refresh(position) } @@ -912,7 +913,11 @@ class PlaybackController( if (anchor.changed) layoutVersion++ this@PlaybackController.snapshot = finalSnapshot queueRepository.setCursor(anchor.position) - rebuildWindow(queueRepository.window(finalSnapshot, anchor.position, QUEUE_WINDOW_RADIUS), anchor.position) + rebuildWindow( + queueRepository.window(finalSnapshot, anchor.position, QUEUE_WINDOW_RADIUS), + anchor.position, + finalSnapshot, + ) endedHandled = false refresh(anchor.position) } @@ -944,13 +949,13 @@ class PlaybackController( if (!isOffline) { return nextPosition(snapshot, at) ?.takeIf { it != at } - ?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() } + ?.let { queueItemAt(snapshot, it) } } downloads.awaitLoaded() val target = downloadedAtOrAfter(snapshot, at + 1) ?: if (repeatMode != RepeatMode.Off) downloadedAtOrAfter(snapshot, 0L) else null - return target?.takeIf { it != at }?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() } + return target?.takeIf { it != at }?.let { queueItemAt(snapshot, it) } } suspend fun previousItem(): QueueItem? { @@ -967,13 +972,13 @@ class PlaybackController( if (!isOffline) { return previousPosition(snapshot, at) ?.takeIf { it != at } - ?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() } + ?.let { queueItemAt(snapshot, it) } } downloads.awaitLoaded() val target = downloadedAtOrBefore(snapshot, at - 1) ?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null - return target?.takeIf { it != at }?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() } + return target?.takeIf { it != at }?.let { queueItemAt(snapshot, it) } } private fun play( @@ -1132,7 +1137,7 @@ class PlaybackController( lastPosition = actual if (actual != requested) queueRepository.setCursor(actual) updating = true - player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs) + player.setWindow(window.map { it.playerItem(snapshot) }, startIndex, startPositionMs) updating = false if (autoplay) { player.prepare() @@ -1172,7 +1177,7 @@ class PlaybackController( window.forEachIndexed { index, entry -> val local = downloads.localUri(entry.item.song.id) != null if (local != (uris[index]?.startsWith(FILE_SCHEME) == true)) { - player.replaceItem(index, entry.item.toQueueItem()) + player.replaceItem(index, entry.playerItem(snapshot)) replaced = true } } @@ -1277,7 +1282,7 @@ class PlaybackController( (currentId?.let { queueRepository.combinedIndexOf(snapshot, it) } ?: center) .coerceIn(0, snapshot.size - 1) queueRepository.setCursor(position) - rebuildWindow(queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS), position) + rebuildWindow(queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS), position, snapshot) refresh(position) return } @@ -1287,8 +1292,8 @@ class PlaybackController( updating = true try { while (windowStart > desiredStart) { - val item = queueRepository.itemAt(snapshot, windowStart - 1) ?: break - player.addFirst(item.toQueueItem()) + val item = queueItemAt(snapshot, windowStart - 1) ?: break + player.addFirst(item) windowStart-- } while (windowStart < desiredStart) { @@ -1296,8 +1301,8 @@ class PlaybackController( windowStart++ } while (windowEnd < desiredEnd) { - val item = queueRepository.itemAt(snapshot, windowEnd + 1) ?: break - player.addLast(item.toQueueItem()) + val item = queueItemAt(snapshot, windowEnd + 1) ?: break + player.addLast(item) windowEnd++ } while (windowEnd > desiredEnd) { @@ -1313,6 +1318,7 @@ class PlaybackController( private suspend fun rebuildWindow( window: List, position: Long, + snapshot: QueueSnapshot, ) { val player = player ?: return downloads.awaitLoaded() @@ -1328,7 +1334,7 @@ class PlaybackController( val current = ordered[index] val wantsLocal = downloads.localUri(current.item.song.id) != null val isLocal = player.itemUris().getOrNull(player.currentIndex)?.startsWith(FILE_SCHEME) == true - if (wantsLocal != isLocal) player.replaceItem(player.currentIndex, current.item.toQueueItem()) + if (wantsLocal != isLocal) player.replaceItem(player.currentIndex, current.playerItem(snapshot)) windowPositions = LongArray(ordered.size) { ordered[it].position } windowFiltered = offline windowStart = ordered.first().position @@ -1338,8 +1344,8 @@ class PlaybackController( try { repeat(player.currentIndex) { player.removeFirst() } while (player.itemCount > 1) player.removeLast() - for (i in index - 1 downTo 0) player.addFirst(ordered[i].item.toQueueItem()) - for (i in index + 1 until ordered.size) player.addLast(ordered[i].item.toQueueItem()) + for (i in index - 1 downTo 0) player.addFirst(ordered[i].playerItem(snapshot)) + for (i in index + 1 until ordered.size) player.addLast(ordered[i].playerItem(snapshot)) } finally { updating = false } @@ -1414,7 +1420,7 @@ class PlaybackController( if (target == current) { // Keep the playing item in place and only add/remove its neighbours, so a downloaded // track does not re-prepare (and audibly gap) when offline mode is toggled. - rebuildWindow(queueRepository.window(snapshot, target, QUEUE_WINDOW_RADIUS), target) + rebuildWindow(queueRepository.window(snapshot, target, QUEUE_WINDOW_RADIUS), target, snapshot) refresh(target) } else { loadWindow(target, autoplay = player.playWhenReady, startPositionMs = 0) @@ -1481,6 +1487,17 @@ class PlaybackController( return snapshot } + private fun QueueWindowItem.playerItem(snapshot: QueueSnapshot): QueueItem = + item.toQueueItem(albumContext = snapshot.isAlbumContext(position)) + + private suspend fun queueItemAt( + snapshot: QueueSnapshot, + position: Long, + ): QueueItem? = + queueRepository + .itemAt(snapshot, position) + ?.toQueueItem(albumContext = snapshot.isAlbumContext(position)) + private fun contextAt(position: Long?): QueueContext? { val snapshot = snapshot ?: return null val play = position?.let { snapshot.anchorContextPlay(it) } ?: return null diff --git a/app/src/main/kotlin/com/subtracks/playback/QueueItem.kt b/app/src/main/kotlin/com/subtracks/playback/QueueItem.kt index b72fba25..ceff896d 100644 --- a/app/src/main/kotlin/com/subtracks/playback/QueueItem.kt +++ b/app/src/main/kotlin/com/subtracks/playback/QueueItem.kt @@ -2,7 +2,7 @@ package com.subtracks.playback import com.subtracks.data.model.SongItem -fun SongItem.toQueueItem() = +fun SongItem.toQueueItem(albumContext: Boolean = false) = QueueItem( id = song.id, title = song.title, @@ -11,4 +11,5 @@ fun SongItem.toQueueItem() = coverArtId = coverArt, durationMs = song.duration?.let { it * 1000 }, replayGain = song.replayGain(), + albumContext = albumContext, ) diff --git a/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt b/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt index b0287fa7..d69798c0 100644 --- a/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt +++ b/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt @@ -29,17 +29,20 @@ internal fun Song.replayGain(): ReplayGain = internal fun replayGainLinearGain( gain: ReplayGain, settings: ReplayGainSettings, + albumContext: Boolean = false, ): Float { + if (settings.mode == ReplayGainMode.Off) return 1f // 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. A boost cannot be checked for // headroom without a peak, so a peakless track is only ever attenuated. val trackPeak = gain.trackPeak?.takeIf { it.isFinite() && it > 0f } val albumPeak = gain.albumPeak?.takeIf { it.isFinite() && it > 0f } + val album = settings.mode == ReplayGainMode.Album || (settings.mode == ReplayGainMode.Dynamic && albumContext) 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) + if (album) { + (gain.albumGain ?: gain.trackGain) to (albumPeak ?: trackPeak) + } else { + (gain.trackGain ?: gain.albumGain) to (trackPeak ?: albumPeak) } val gainDb = requestedDb?.takeIf { it.isFinite() } ?: return 1f val requested = gainDb + settings.preampDb diff --git a/app/src/main/kotlin/com/subtracks/playback/ReplayGainController.kt b/app/src/main/kotlin/com/subtracks/playback/ReplayGainController.kt index 602f53e7..0cacd474 100644 --- a/app/src/main/kotlin/com/subtracks/playback/ReplayGainController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/ReplayGainController.kt @@ -7,11 +7,13 @@ internal class ReplayGainController( private val apply: (Float) -> Unit, ) { private var track = ReplayGain() + private var albumContext = false private var settings = ReplayGainSettings(ReplayGainMode.Track, 0f) @Synchronized fun applyItem(item: MediaItem) { track = item.replayGain() + albumContext = item.albumContext() applyGain() } @@ -22,6 +24,6 @@ internal class ReplayGainController( } private fun applyGain() { - apply(replayGainLinearGain(track, settings)) + apply(replayGainLinearGain(track, settings, albumContext)) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/ReplayGainControls.kt b/app/src/main/kotlin/com/subtracks/ui/settings/ReplayGainControls.kt index 393caa0f..bbbc8eec 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/ReplayGainControls.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/ReplayGainControls.kt @@ -32,6 +32,7 @@ internal fun replayGainModeLabel( 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) + ReplayGainMode.Dynamic -> resources.getString(R.string.settings_replay_gain_mode_dynamic) } internal fun replayGainPreampLabel( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a2303c1e..fd59d375 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,6 +83,7 @@ Send now playing and play counts Scrobble to server Album gain + Dynamic Off Track gain Lower preamp diff --git a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt index ae540e09..a1c846d6 100644 --- a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt @@ -225,6 +225,9 @@ class UserPreferencesTest { assertEquals(ReplayGainMode.Album, prefs.replayGainMode().first()) assertEquals(3.5f, prefs.replayGainPreampDb().first(), 0f) + prefs.setReplayGainMode(ReplayGainMode.Dynamic) + assertEquals(ReplayGainMode.Dynamic, prefs.replayGainMode().first()) + prefs.setReplayGainPreampDb(99f) assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f) diff --git a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt index c20b4aaa..605052dc 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt @@ -485,6 +485,43 @@ class QueueRepositoryTest { repository.move(repository.snapshot(), from = 2, to = 1) assertEquals(listOf("s1", "s5", "s4", "s2", "s3"), resolveAll(repository.snapshot())) + val (entry, _) = repository.snapshot().locate(1)!! + assertEquals(QueueKind.Song, entry.kind) + } + + @Test + fun movingAQueuedAlbumTrackKeepsItsAlbumEntry() = + runTest { + seedLibrary() + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.addUpNext(snapshot, currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false) + + repository.move(repository.snapshot(), from = 2, to = 1) + + val (entry, _) = repository.snapshot().locate(1)!! + assertEquals(QueueKind.Album, entry.kind) + assertEquals("al2", entry.refId) + assertEquals(1L, entry.rangeStart) + assertEquals(1L, entry.rangeEnd) + } + + @Test + fun movingAPlaylistTrackKeepsItAPinnedSong() = + runTest { + seedLibrary() + db.libraryDao().upsertPlaylistSongs( + listOf( + PlaylistSong(1, "p1", "s1", 0), + PlaylistSong(1, "p1", "s4", 1), + ), + ) + repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1"))) + + repository.move(repository.snapshot(), from = 0, to = 1) + + val (entry, _) = repository.snapshot().locate(1)!! + assertEquals(QueueKind.Song, entry.kind) + assertEquals("s1", entry.refId) } @Test @@ -853,11 +890,30 @@ class QueueRepositoryTest { repository.move(repository.snapshot(), from = 0, to = 2) val (entry, _) = repository.snapshot().locate(2)!! - assertEquals(QueueKind.Song, entry.kind) - assertEquals("s1", entry.refId) + assertEquals(QueueKind.Album, entry.kind) + assertEquals("al1", entry.refId) + assertEquals(0L, entry.rangeStart) + assertEquals(0L, entry.rangeEnd) assertEquals("First Album", repository.sourceName(entry.kind, entry.sourceId, entry.refId)) } + @Test + fun movingAnAlbumTrackBackCompactsTheAlbumEntry() = + runTest { + seedLibrary() + repository.replace(listOf(repository.albumEntry(1, "al1"))) + + repository.move(repository.snapshot(), from = 0, to = 2) + repository.move(repository.snapshot(), from = 2, to = 0) + + val snapshot = repository.snapshot() + assertEquals(listOf("s1", "s2", "s3"), resolveAll(snapshot)) + val entry = snapshot.entries.single().entry + assertEquals(QueueKind.Album, entry.kind) + assertEquals(0L, entry.rangeStart) + assertEquals(2L, entry.rangeEnd) + } + @Test fun anAlbumSplitByRemovalIsStillLabeledByItsAlbum() = runTest { diff --git a/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt b/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt index 93cff3c2..612b94ea 100644 --- a/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt @@ -70,6 +70,20 @@ class MediaItemTest { assertEquals(gain, item.replayGain()) } + @Test + fun theItemCarriesItsGainContext() { + val item = + queueMediaItem( + song.copy(albumContext = true), + local = null, + stream = "http://server/stream?s=s1", + transcode = false, + ) + + assertEquals(true, item.albumContext()) + assertEquals(false, queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false).albumContext()) + } + @Test fun anItemWithoutGainReadsBackAsNoGain() { val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false) diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 222863b4..5f42d339 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -479,6 +479,67 @@ class PlaybackControllerTest { } } + @Test + fun albumTracksAndQueuedSinglesCarryDifferentGainContexts() { + seedAlbum(3, sourceId = 1) + seedSong("x1", "al2") + + controller.playAlbum(1, "al1", 0) + await { handle.items.any { it.id == "s1" } } + assertTrue(handle.items.first { it.id == "s1" }.albumContext) + + controller.playNext(1, QueueKind.Song, "x1") + await { handle.items.any { it.id == "x1" } } + + assertFalse(handle.items.first { it.id == "x1" }.albumContext) + } + + @Test + fun queuedAlbumsCarryAlbumGain() { + seedAlbum(3, sourceId = 1) + seedSong("x1", "al2") + + controller.playAlbum(1, "al1", 0) + await { handle.items.any { it.id == "s1" } } + + controller.playNext(1, QueueKind.Album, "al2") + await { handle.items.any { it.id == "x1" } } + + assertTrue(handle.items.first { it.id == "x1" }.albumContext) + } + + @Test + fun movingAnAlbumTrackKeepsItsAlbumGainContext() { + seedAlbum(4, sourceId = 1) + + controller.playAlbum(1, "al1", 0) + await { handle.items.any { it.id == "s1" } } + + runBlocking { controller.move(0, 2) } + await { controller.state.value.position == 2L } + + assertTrue(handle.items.first { it.id == "s1" }.albumContext) + val (entry, _) = runBlocking { queues.snapshot().locate(2)!! } + assertEquals(QueueKind.Album, entry.kind) + assertEquals("al1", entry.refId) + } + + @Test + fun undoingARemovalRestoresTheTracksAlbumGainContext() { + seedAlbum(3, sourceId = 1) + + controller.playAlbum(1, "al1", 0) + await { handle.items.any { it.id == "s2" } } + + runBlocking { controller.removeAt(1) } + await { handle.items.none { it.id == "s2" } } + + runBlocking { controller.undo() } + await { handle.items.any { it.id == "s2" } } + + assertTrue(handle.items.first { it.id == "s2" }.albumContext) + } + @Test fun tappingAContextTrackWhileAQueuedTrackPlaysKeepsTheQueue() { seedAlbum(3, sourceId = 1) @@ -1268,6 +1329,25 @@ class PlaybackControllerTest { @Test fun movingThePlayingContextTrackStillResolvesAnAlbumTitle() { seedAlbum(3, sourceId = 1) + runBlocking { + db.libraryDao().upsertAlbums( + listOf( + Album( + sourceId = 1, + id = "al1", + artistId = "ar1", + name = "Album", + albumArtist = "Artist", + created = 0, + coverArt = null, + genre = null, + year = null, + starred = null, + songCount = 3, + ), + ), + ) + } controller.playAlbum(1, "al1", 1) await { diff --git a/app/src/test/kotlin/com/subtracks/playback/QueueItemTest.kt b/app/src/test/kotlin/com/subtracks/playback/QueueItemTest.kt index 7f2e96a0..5d993848 100644 --- a/app/src/test/kotlin/com/subtracks/playback/QueueItemTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/QueueItemTest.kt @@ -35,6 +35,28 @@ class QueueItemTest { ) } + @Test + fun carriesTheAlbumsGainContextWhenAsked() { + 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, + ) + + val item = AlbumSongItem(song, coverArt = null).toQueueItem(albumContext = true) + + assertEquals(true, item.albumContext) + } + @Test fun aSongWithoutGainCarriesNoGain() { val item = diff --git a/app/src/test/kotlin/com/subtracks/playback/ReplayGainControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/ReplayGainControllerTest.kt index 9db9e099..2c164376 100644 --- a/app/src/test/kotlin/com/subtracks/playback/ReplayGainControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/ReplayGainControllerTest.kt @@ -38,6 +38,17 @@ class ReplayGainControllerTest { assertEquals(1f, applied.last(), TOLERANCE) } + @Test + fun dynamicModeFollowsTheItemsGainContext() { + controller.setSettings(ReplayGainSettings(ReplayGainMode.Dynamic, 0f)) + + controller.applyItem(item("a", trackGain = -6.0206f, albumGain = -1f)) + assertEquals(0.5f, applied.last(), TOLERANCE) + + controller.applyItem(item("b", trackGain = -1f, albumGain = -6.0206f, albumContext = true)) + assertEquals(0.5f, applied.last(), TOLERANCE) + } + @Test fun anItemWithoutGainPlaysAtUnity() { controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f)) @@ -65,6 +76,8 @@ class ReplayGainControllerTest { id: String, trackGain: Float?, trackPeak: Float? = null, + albumGain: Float? = null, + albumContext: Boolean = false, ) = queueMediaItem( QueueItem( id = id, @@ -72,7 +85,8 @@ class ReplayGainControllerTest { artist = null, album = null, coverArtId = null, - replayGain = ReplayGain(trackGain = trackGain, trackPeak = trackPeak), + replayGain = ReplayGain(trackGain = trackGain, albumGain = albumGain, trackPeak = trackPeak), + albumContext = albumContext, ), local = null, stream = "http://server/$id", diff --git a/app/src/test/kotlin/com/subtracks/playback/ReplayGainTest.kt b/app/src/test/kotlin/com/subtracks/playback/ReplayGainTest.kt index 79290d48..4fdafc99 100644 --- a/app/src/test/kotlin/com/subtracks/playback/ReplayGainTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/ReplayGainTest.kt @@ -53,6 +53,40 @@ class ReplayGainTest { ) } + @Test + fun dynamicModeAppliesTheAlbumGainInAnAlbumContext() { + assertEquals( + 0.5f, + replayGainLinearGain( + ReplayGain(trackGain = -1f, albumGain = -6.0206f, trackPeak = 1f, albumPeak = 1f), + ReplayGainSettings(ReplayGainMode.Dynamic, 0f), + albumContext = true, + ), + TOLERANCE, + ) + } + + @Test + fun dynamicModeAppliesTheTrackGainInAMixedContext() { + assertEquals( + 0.5f, + replayGainLinearGain( + ReplayGain(trackGain = -6.0206f, albumGain = -1f, trackPeak = 1f, albumPeak = 1f), + ReplayGainSettings(ReplayGainMode.Dynamic, 0f), + albumContext = false, + ), + TOLERANCE, + ) + } + + @Test + fun dynamicModeFallsBackInEitherContext() { + val settings = ReplayGainSettings(ReplayGainMode.Dynamic, 0f) + + assertEquals(0.5f, replayGainLinearGain(ReplayGain(trackGain = -6.0206f), settings, albumContext = true), TOLERANCE) + assertEquals(0.5f, replayGainLinearGain(ReplayGain(albumGain = -6.0206f), settings, albumContext = false), TOLERANCE) + } + @Test fun offAppliesUnityGain() { assertEquals( diff --git a/app/src/test/kotlin/com/subtracks/ui/settings/ReplayGainControlsTest.kt b/app/src/test/kotlin/com/subtracks/ui/settings/ReplayGainControlsTest.kt index b58e2250..5210d9b5 100644 --- a/app/src/test/kotlin/com/subtracks/ui/settings/ReplayGainControlsTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/settings/ReplayGainControlsTest.kt @@ -19,6 +19,7 @@ class ReplayGainControlsTest { assertEquals("Off", replayGainModeLabel(resources, ReplayGainMode.Off)) assertEquals("Track gain", replayGainModeLabel(resources, ReplayGainMode.Track)) assertEquals("Album gain", replayGainModeLabel(resources, ReplayGainMode.Album)) + assertEquals("Dynamic", replayGainModeLabel(resources, ReplayGainMode.Dynamic)) } @Test diff --git a/docs/architecture.md b/docs/architecture.md index 7fcf11f0..135f7d80 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -98,7 +98,7 @@ 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. -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: without a peak there is no headroom to check, so a peakless track is only ever attenuated. +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/dynamic/off) and a preamp are DataStore preferences read by `PlaybackService`, and the anti-clip cap uses the peak when the server sends one: without a peak there is no headroom to check, so a peakless track is only ever attenuated. Dynamic mode selects album gain for items whose queue entry references an album and track gain for playlists, singles and other mixed contexts, including shuffled album queues; the choice is carried on each media item because the audio thread cannot query the queue. ## Data model