mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +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(
|
||||
|
||||
Reference in New Issue
Block a user