mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
playback: add dynamic ReplayGain mode
Use album gain for items whose queue entry references an album and track gain for playlists, singles and other mixed contexts, including shuffled album queues. Carry the resolved context on each media item so the audio thread does not need to inspect the queue. Keep a track moved within an album as an album range so it stays in album context; moves out of playlists, artists, genres and other mixed contexts remain pinned song entries.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -164,6 +164,8 @@ data class QueueSnapshot(
|
||||
}
|
||||
|
||||
fun locateContext(contextPlay: Long): Pair<QueueEntry, Long>? = 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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<QueueWindowItem>,
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
<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_replay_gain_mode_album">Album gain</string>
|
||||
<string name="settings_replay_gain_mode_dynamic">Dynamic</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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user