add a separate, editable up-next queue (#61)

Split playback into a shuffled context and a manual up-next list.
`up_next_entries` holds the same reference-plus-range rows as the
context but is never shuffled; `playback_cursor.upNextAnchor` pins the
block between a context track and its successor. `QueueRepository`
resolves a combined position by mapping through the context order and
splicing the block after the anchor, so the controller's window and
next/previous keep working.

PlaybackController gains addToQueue/playNext, consumes a queued track
when playback advances out of it, resumes the context when the block
empties, and clears the block on a new context. The queue view gets an
editable "Up next" section and a read-only context section, and the
context menu gains Play next / Add to queue for songs, albums and
playlists.
This commit is contained in:
austinried
2026-10-04 00:30:00 +00:00
parent 50b99979e1
commit d5a9a11cef
18 changed files with 2038 additions and 145 deletions
File diff suppressed because it is too large Load Diff
@@ -144,6 +144,25 @@ val MIGRATION_14_15 =
} }
} }
val MIGRATION_15_16 =
object : Migration(15, 16) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `up_next_entries` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`position` INTEGER NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, " +
"`refId` TEXT NOT NULL, `rangeStart` INTEGER, `rangeEnd` INTEGER, " +
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
)
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_up_next_entries_sourceId` ON `up_next_entries` (`sourceId`)",
)
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_up_next_entries_position` ON `up_next_entries` (`position`)",
)
connection.execSQL("ALTER TABLE `playback_cursor` ADD COLUMN `upNextAnchor` INTEGER NOT NULL DEFAULT 0")
}
}
private suspend fun rebuildTable( private suspend fun rebuildTable(
connection: SQLiteConnection, connection: SQLiteConnection,
table: String, table: String,
@@ -232,4 +251,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_12_13, MIGRATION_12_13,
MIGRATION_13_14, MIGRATION_13_14,
MIGRATION_14_15, MIGRATION_14_15,
MIGRATION_15_16,
) )
@@ -8,6 +8,7 @@ import com.subtracks.data.model.PlaybackCursor
import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.ShuffleOrder import com.subtracks.data.model.ShuffleOrder
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
import com.subtracks.data.model.UpNextEntry
private const val ALBUM_SONGS_SQL = private const val ALBUM_SONGS_SQL =
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
@@ -31,6 +32,15 @@ interface QueueDao {
@Query("SELECT * FROM queue_entries ORDER BY position, id") @Query("SELECT * FROM queue_entries ORDER BY position, id")
suspend fun entries(): List<QueueEntry> suspend fun entries(): List<QueueEntry>
@Query("DELETE FROM up_next_entries")
suspend fun clearUpNext()
@Insert
suspend fun insertUpNext(entries: List<UpNextEntry>)
@Query("SELECT * FROM up_next_entries ORDER BY position, id")
suspend fun upNextEntries(): List<UpNextEntry>
@Query("SELECT * FROM shuffle_order ORDER BY sequence") @Query("SELECT * FROM shuffle_order ORDER BY sequence")
suspend fun shuffleOrder(): List<ShuffleOrder> suspend fun shuffleOrder(): List<ShuffleOrder>
@@ -16,6 +16,7 @@ import com.subtracks.data.model.ShuffleOrder
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicSource import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.UpNextEntry
@Database( @Database(
entities = [ entities = [
@@ -28,11 +29,12 @@ import com.subtracks.data.model.SubsonicSource
PlaylistSong::class, PlaylistSong::class,
Song::class, Song::class,
QueueEntry::class, QueueEntry::class,
UpNextEntry::class,
PlaybackCursor::class, PlaybackCursor::class,
ShuffleOrder::class, ShuffleOrder::class,
ArtworkSeed::class, ArtworkSeed::class,
], ],
version = 15, version = 16,
exportSchema = true, exportSchema = true,
) )
@ColumnTypeConverters(QueueKindConverter::class) @ColumnTypeConverters(QueueKindConverter::class)
@@ -265,6 +265,39 @@ data class QueueEntry(
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) } val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
} }
@Entity(
tableName = "up_next_entries",
foreignKeys = [
ForeignKey(
entity = Source::class,
parentColumns = ["id"],
childColumns = ["sourceId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("sourceId"), Index("position")],
)
data class UpNextEntry(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val position: Long,
val sourceId: Long,
val kind: QueueKind,
val refId: String,
val rangeStart: Long? = null,
val rangeEnd: Long? = null,
) {
val offset: Long get() = rangeStart ?: 0
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
fun toQueueEntry(): QueueEntry = QueueEntry(id, position, sourceId, kind, refId, rangeStart, rangeEnd)
companion object {
fun from(entry: QueueEntry) =
UpNextEntry(entry.id, entry.position, entry.sourceId, entry.kind, entry.refId, entry.rangeStart, entry.rangeEnd)
}
}
@Entity(tableName = "playback_cursor") @Entity(tableName = "playback_cursor")
data class PlaybackCursor( data class PlaybackCursor(
@PrimaryKey val id: Long = 1, @PrimaryKey val id: Long = 1,
@@ -273,6 +306,7 @@ data class PlaybackCursor(
@ColumnInfo(defaultValue = "0") val repeatMode: Int = 0, @ColumnInfo(defaultValue = "0") val repeatMode: Int = 0,
@ColumnInfo(defaultValue = "0") val shuffleSeed: Long = 0, @ColumnInfo(defaultValue = "0") val shuffleSeed: Long = 0,
@ColumnInfo(defaultValue = "0") val positionMs: Long = 0, @ColumnInfo(defaultValue = "0") val positionMs: Long = 0,
@ColumnInfo(defaultValue = "0") val upNextAnchor: Long = 0,
) )
@Entity(tableName = "shuffle_order") @Entity(tableName = "shuffle_order")
@@ -10,6 +10,7 @@ import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.ShuffleOrder import com.subtracks.data.model.ShuffleOrder
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
import com.subtracks.data.model.UpNextEntry
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -29,6 +30,13 @@ private data class EntryLengthKey(
val count: Long?, val count: Long?,
) )
private data class LoadedSnapshot(
val entries: List<ResolvedQueueEntry>,
val upNext: List<ResolvedQueueEntry>,
val shuffleOrder: LongArray?,
val anchor: Long,
)
data class ResolvedQueueEntry( data class ResolvedQueueEntry(
val entry: QueueEntry, val entry: QueueEntry,
val length: Long, val length: Long,
@@ -36,25 +44,59 @@ data class ResolvedQueueEntry(
data class QueueSnapshot( data class QueueSnapshot(
val entries: List<ResolvedQueueEntry>, val entries: List<ResolvedQueueEntry>,
val upNext: List<ResolvedQueueEntry> = emptyList(),
val shuffleOrder: LongArray? = null, val shuffleOrder: LongArray? = null,
val upNextAnchor: Long = 0,
val version: Long = 0, val version: Long = 0,
) { ) {
val size: Long = entries.sumOf { it.length } val contextSize: Long = entries.sumOf { it.length }
val upNextSize: Long = upNext.sumOf { it.length }
val size: Long = contextSize + upNextSize
val shuffled: Boolean get() = shuffleOrder != null val shuffled: Boolean get() = shuffleOrder != null
fun locate(position: Long): Pair<QueueEntry, Long>? = locateFlat(flatPosition(position) ?: return null) val anchorPlay: Long =
when (val order = shuffleOrder) {
null -> upNextAnchor
else -> order.indexOf(upNextAnchor).takeIf { it >= 0 }?.toLong() ?: 0L
}
fun flatPosition(sequence: Long): Long? = if (shuffleOrder == null) sequence else shuffleOrder.getOrNull(sequence.toInt()) fun isUpNext(position: Long): Boolean = upNextSize > 0L && position > anchorPlay && position <= anchorPlay + upNextSize
private fun locateFlat(position: Long): Pair<QueueEntry, Long>? { fun upNextIndex(position: Long): Long = position - anchorPlay - 1L
var remaining = position
fun contextPlay(position: Long): Long = if (position <= anchorPlay) position else position - upNextSize
fun anchorContextPlay(position: Long): Long = if (isUpNext(position)) anchorPlay else contextPlay(position)
fun combined(contextPlay: Long): Long = if (contextPlay <= anchorPlay) contextPlay else contextPlay + upNextSize
fun flatContext(contextPlay: Long): Long? =
when (val order = shuffleOrder) {
null -> contextPlay.takeIf { it in 0 until contextSize }
else -> order.getOrNull(contextPlay.toInt())
}
fun locate(position: Long): Pair<QueueEntry, Long>? {
if (isUpNext(position)) return locateIn(upNext, upNextIndex(position))
return locateContext(contextPlay(position))
}
fun locateContext(contextPlay: Long): Pair<QueueEntry, Long>? = locateIn(entries, flatContext(contextPlay) ?: return null)
}
private fun locateIn(
entries: List<ResolvedQueueEntry>,
index: Long,
): Pair<QueueEntry, Long>? {
var remaining = index
for (resolved in entries) { for (resolved in entries) {
if (remaining < resolved.length) return resolved.entry to remaining if (remaining < resolved.length) return resolved.entry to remaining
remaining -= resolved.length remaining -= resolved.length
} }
return null return null
}
} }
data class QueueModes( data class QueueModes(
@@ -65,6 +107,7 @@ data class QueueModes(
data class QueueWindowItem( data class QueueWindowItem(
val position: Long, val position: Long,
val item: SongListItem, val item: SongListItem,
val upNext: Boolean = false,
) )
class QueueRepository( class QueueRepository(
@@ -137,10 +180,15 @@ class QueueRepository(
suspend fun replace( suspend fun replace(
entries: List<QueueEntry>, entries: List<QueueEntry>,
shuffleOrder: LongArray? = null, shuffleOrder: LongArray? = null,
upNext: List<QueueEntry> = emptyList(),
upNextAnchor: Long = 0,
) { ) {
write(entries) write(entries)
writeUpNext(upNext)
setShuffle(shuffleOrder != null, shuffleOrder) setShuffle(shuffleOrder != null, shuffleOrder)
cursorMutex.withLock { dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0)) } cursorMutex.withLock {
dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0, upNextAnchor = upNextAnchor))
}
} }
suspend fun snapshot(): QueueSnapshot { suspend fun snapshot(): QueueSnapshot {
@@ -149,15 +197,17 @@ class QueueRepository(
db.useReaderConnection { transactor -> db.useReaderConnection { transactor ->
transactor.deferredTransaction { transactor.deferredTransaction {
val entries = dao.entries().map { ResolvedQueueEntry(it, lengthOf(it, version)) } val entries = dao.entries().map { ResolvedQueueEntry(it, lengthOf(it, version)) }
val upNext =
dao.upNextEntries().map {
val entry = it.toQueueEntry()
ResolvedQueueEntry(entry, lengthOf(entry, version))
}
val row = dao.cursor() val row = dao.cursor()
val shuffled = row?.shuffleEnabled == true val order = if (row?.shuffleEnabled == true) loadShuffleOrder(row.shuffleSeed, entries.sumOf { it.length }) else null
val order = if (shuffled) loadShuffleOrder(row.shuffleSeed, entries.sumOf { it.length }) else null LoadedSnapshot(entries, upNext, order, row?.upNextAnchor ?: 0L)
Triple(entries, order, shuffled)
} }
} }
val (entries, order, shuffled) = loaded return QueueSnapshot(loaded.entries, loaded.upNext, loaded.shuffleOrder, loaded.anchor, version)
val valid = if (shuffled && order == null) null else order
return QueueSnapshot(entries, valid, version)
} }
suspend fun modes(): QueueModes { suspend fun modes(): QueueModes {
@@ -209,29 +259,21 @@ class QueueRepository(
snapshot: QueueSnapshot, snapshot: QueueSnapshot,
position: Long, position: Long,
): SongListItem? { ): SongListItem? {
if (snapshot.shuffled) {
val sourceId =
snapshot.entries
.firstOrNull()
?.entry
?.sourceId ?: return null
val flat = snapshot.flatPosition(position) ?: return null
val id = flatIds(snapshot).getOrNull(flat.toInt()) ?: return null
return dao.songsByIds(sourceId, listOf(id)).firstOrNull()
}
val (entry, offset) = snapshot.locate(position) ?: return null val (entry, offset) = snapshot.locate(position) ?: return null
return rows(entry, entry.offset + offset, 1).firstOrNull() return rows(entry, entry.offset + offset, 1).firstOrNull()
} }
suspend fun flatIndexOf( suspend fun combinedIndexOf(
snapshot: QueueSnapshot, snapshot: QueueSnapshot,
songId: String, songId: String,
): Long? { ): Long? {
val order = snapshot.shuffleOrder ?: return null val upIndex = idsFor(snapshot.upNext).indexOf(songId)
if (upIndex >= 0) return snapshot.anchorPlay + 1 + upIndex
val flat = flatIds(snapshot).indexOf(songId) val flat = flatIds(snapshot).indexOf(songId)
if (flat < 0) return null if (flat < 0) return null
val position = order.indexOf(flat.toLong()) val play = snapshot.shuffleOrder?.indexOf(flat.toLong())?.toLong() ?: flat.toLong()
return if (position >= 0) position.toLong() else null if (play < 0) return null
return snapshot.combined(play)
} }
suspend fun range( suspend fun range(
@@ -239,36 +281,20 @@ class QueueRepository(
first: Long, first: Long,
last: Long, last: Long,
): List<QueueWindowItem> { ): List<QueueWindowItem> {
if (snapshot.shuffled) { val from = first.coerceAtLeast(0)
val sourceId = val to = last.coerceAtMost(snapshot.size - 1)
snapshot.entries if (from > to) return emptyList()
.firstOrNull() val anchor = snapshot.anchorPlay
?.entry val block = snapshot.upNextSize
?.sourceId ?: return emptyList()
val ids = flatIds(snapshot)
val requested = mutableListOf<Pair<Long, String>>()
for (position in first..last) {
val flat = snapshot.flatPosition(position) ?: continue
val id = ids.getOrNull(flat.toInt()) ?: continue
requested += position to id
}
if (requested.isEmpty()) return emptyList()
val byId = dao.songsByIds(sourceId, requested.map { it.second }.distinct()).associateBy { it.song.id }
return requested.mapNotNull { (position, id) -> byId[id]?.let { QueueWindowItem(position, it) } }
}
val items = mutableListOf<QueueWindowItem>() val items = mutableListOf<QueueWindowItem>()
var start = 0L val beforeEnd = minOf(to, anchor)
for (resolved in snapshot.entries) { if (from <= beforeEnd) items += contextPlayRange(snapshot, from, beforeEnd)
val end = start + resolved.length - 1 val blockFirst = maxOf(from, anchor + 1)
val entryStart = start val blockLast = minOf(to, anchor + block)
start += resolved.length if (blockFirst <= blockLast) items += blockPlayRange(snapshot, blockFirst - anchor - 1, blockLast - anchor - 1)
if (resolved.length == 0L || end < first || entryStart > last) continue val afterFirst = maxOf(from, anchor + block + 1)
val from = maxOf(first, entryStart) if (afterFirst <= to) items += contextPlayRange(snapshot, afterFirst - block, to - block)
val to = minOf(last, end) items.sortBy { it.position }
val offset = resolved.entry.offset + (from - entryStart)
rows(resolved.entry, offset, (to - from + 1).toInt())
.forEachIndexed { index, item -> items += QueueWindowItem(from + index, item) }
}
return items return items
} }
@@ -281,41 +307,133 @@ class QueueRepository(
return range(snapshot, (center - radius).coerceAtLeast(0), (center + radius).coerceAtMost(snapshot.size - 1)) return range(snapshot, (center - radius).coerceAtLeast(0), (center + radius).coerceAtMost(snapshot.size - 1))
} }
suspend fun addUpNext(
snapshot: QueueSnapshot,
currentPosition: Long,
entry: QueueEntry,
playNext: Boolean,
): Long {
val list = snapshot.upNext.map { it.entry }.toMutableList()
val active = snapshot.isUpNext(currentPosition)
val insertIndex =
when {
playNext && active -> 1
playNext -> 0
else -> list.size
}.coerceIn(0, list.size)
list.add(insertIndex, entry.copy(id = 0, position = 0))
val anchor =
if (snapshot.upNextSize == 0L) {
val play = snapshot.anchorContextPlay(currentPosition)
snapshot.flatContext(play) ?: 0L
} else {
snapshot.upNextAnchor
}
writeUpNext(list)
cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = anchor)) }
if (snapshot.upNextSize == 0L) return currentPosition
val before = snapshot.upNext.take(insertIndex).sumOf { it.length }
return if (currentPosition >= snapshot.anchorPlay + 1 + before) currentPosition + 1 else currentPosition
}
suspend fun removeUpNextAt(
snapshot: QueueSnapshot,
index: Long,
) {
if (index !in 0 until snapshot.upNextSize) return
writeUpNext(compact(removeEntry(snapshot.upNext, index)).map { it.entry })
}
suspend fun removeAt( suspend fun removeAt(
snapshot: QueueSnapshot, snapshot: QueueSnapshot,
position: Long, position: Long,
): QueueSnapshot { ): QueueSnapshot {
val order = snapshot.shuffleOrder if (snapshot.isUpNext(position)) {
if (order == null) { removeUpNextAt(snapshot, snapshot.upNextIndex(position))
write(compact(removeEntry(snapshot.entries, position)).map { it.entry })
return snapshot() return snapshot()
} }
val flat = order.getOrNull(position.toInt()) ?: return snapshot val play = snapshot.contextPlay(position)
write(compact(removeEntry(snapshot.entries, flat)).map { it.entry }) val order = snapshot.shuffleOrder
val reordered = order.toMutableList().apply { removeAt(position.toInt()) } if (order == null) {
val removed = compact(removeEntry(snapshot.entries, play))
write(removed.map { it.entry })
adjustAnchorOnRemove(play, removed.sumOf { it.length })
} else {
val flat = order.getOrNull(play.toInt()) ?: return snapshot
val removed = compact(removeEntry(snapshot.entries, flat))
write(removed.map { it.entry })
val reordered = order.toMutableList().apply { removeAt(play.toInt()) }
for (index in reordered.indices) if (reordered[index] > flat) reordered[index] -= 1 for (index in reordered.indices) if (reordered[index] > flat) reordered[index] -= 1
setShuffle(true, reordered.toLongArray()) setShuffle(true, reordered.toLongArray())
adjustAnchorOnRemove(flat, removed.sumOf { it.length })
}
return snapshot() return snapshot()
} }
private suspend fun adjustAnchorOnRemove(
removedFlat: Long,
newSize: Long,
) = cursorMutex.withLock {
val row = cursorRow()
val anchor = row.upNextAnchor
val adjusted =
when {
anchor > removedFlat -> anchor - 1
anchor == removedFlat -> removedFlat.coerceIn(0, (newSize - 1).coerceAtLeast(0))
else -> anchor
}
if (adjusted != anchor) dao.setCursor(row.copy(upNextAnchor = adjusted))
}
private suspend fun adjustAnchorOnMove(
flatFrom: Long,
flatTo: Long,
) = cursorMutex.withLock {
val row = cursorRow()
val anchor = row.upNextAnchor
val moved =
when {
flatFrom == anchor -> flatTo
flatFrom < anchor && flatTo >= anchor -> anchor - 1
flatFrom > anchor && flatTo <= anchor -> anchor + 1
else -> anchor
}
if (moved != anchor) dao.setCursor(row.copy(upNextAnchor = moved))
}
suspend fun move( suspend fun move(
snapshot: QueueSnapshot, snapshot: QueueSnapshot,
from: Long, from: Long,
to: Long, to: Long,
): Boolean { ): Boolean {
val fromUpNext = snapshot.isUpNext(from)
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 removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from))
val moved = ResolvedQueueEntry(songEntry(sourceId, song.song.id), 1L)
writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
return true
}
if (fromUpNext != toUpNext) return false
val playFrom = snapshot.contextPlay(from)
val playTo = snapshot.contextPlay(to)
val order = snapshot.shuffleOrder val order = snapshot.shuffleOrder
if (order != null) { if (order != null) {
val reordered = order.toMutableList() val reordered = order.toMutableList()
val moved = reordered.removeAt(from.toInt()) val moved = reordered.removeAt(playFrom.toInt())
reordered.add(to.coerceIn(0L, reordered.size.toLong()).toInt(), moved) reordered.add(playTo.coerceIn(0L, reordered.size.toLong()).toInt(), moved)
setShuffle(true, reordered.toLongArray()) setShuffle(true, reordered.toLongArray())
return true return true
} }
val located = snapshot.locate(from) ?: return false val located = snapshot.locateContext(playFrom) ?: return false
val song = itemAt(snapshot, from) ?: return false val song = itemAt(snapshot, from) ?: return false
val removed = removeEntry(snapshot.entries, from) val removed = removeEntry(snapshot.entries, playFrom)
val moved = ResolvedQueueEntry(songEntry(located.first.sourceId, song.song.id), 1L) val moved = ResolvedQueueEntry(songEntry(located.first.sourceId, song.song.id), 1L)
write(compact(insertEntry(removed, to.coerceIn(0, removed.sumOf { it.length }), moved)).map { it.entry }) val insertAt = playTo.coerceIn(0, removed.sumOf { it.length })
write(compact(insertEntry(removed, insertAt, moved)).map { it.entry })
adjustAnchorOnMove(playFrom, insertAt)
return true return true
} }
@@ -347,27 +465,109 @@ class QueueRepository(
cachedFlatIds = null cachedFlatIds = null
} }
private suspend fun writeUpNext(entries: List<QueueEntry>) {
db.useWriterConnection { transactor ->
transactor.immediateTransaction {
dao.clearUpNext()
dao.insertUpNext(
entries.mapIndexed { index, entry ->
UpNextEntry.from(entry).copy(id = 0, position = index.toLong())
},
)
}
}
queueVersion.incrementAndGet()
}
private suspend fun flatIds(snapshot: QueueSnapshot): List<String> { private suspend fun flatIds(snapshot: QueueSnapshot): List<String> {
val cached = cachedFlatIds val cached = cachedFlatIds
if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.size) return cached if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.contextSize) return cached
val ids = ArrayList<String>(snapshot.size.toInt()) val ids = idsFor(snapshot.entries)
for (resolved in snapshot.entries) {
val entry = resolved.entry
val entryIds =
when (entry.kind) {
QueueKind.Song -> listOf(entry.refId)
QueueKind.Album -> dao.albumSongIds(entry.sourceId, entry.refId)
QueueKind.Playlist -> dao.playlistSongIds(entry.sourceId, entry.refId)
}
val from = entry.offset.toInt().coerceIn(0, entryIds.size)
val to = (entry.offset + resolved.length).toInt().coerceIn(from, entryIds.size)
ids.addAll(entryIds.subList(from, to))
}
cachedFlatIds = ids cachedFlatIds = ids
cachedFlatVersion = snapshot.version cachedFlatVersion = snapshot.version
return ids return ids
} }
private suspend fun idsFor(resolved: List<ResolvedQueueEntry>): List<String> {
val ids = ArrayList<String>()
for (entry in resolved) {
val ref = entry.entry
val entryIds =
when (ref.kind) {
QueueKind.Song -> listOf(ref.refId)
QueueKind.Album -> dao.albumSongIds(ref.sourceId, ref.refId)
QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId)
}
val from = ref.offset.toInt().coerceIn(0, entryIds.size)
val to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size)
ids.addAll(entryIds.subList(from, to))
}
return ids
}
private suspend fun contextPlayRange(
snapshot: QueueSnapshot,
first: Long,
last: Long,
): List<QueueWindowItem> {
if (first > last) return emptyList()
val items = mutableListOf<QueueWindowItem>()
if (snapshot.shuffled) {
val sourceId =
snapshot.entries
.firstOrNull()
?.entry
?.sourceId ?: return emptyList()
val ids = flatIds(snapshot)
val requested = mutableListOf<Pair<Long, String>>()
for (play in first..last) {
val flat = snapshot.flatContext(play) ?: continue
val id = ids.getOrNull(flat.toInt()) ?: continue
requested += play to id
}
if (requested.isEmpty()) return emptyList()
val byId = dao.songsByIds(sourceId, requested.map { it.second }.distinct()).associateBy { it.song.id }
requested.forEach { (play, id) -> byId[id]?.let { items += QueueWindowItem(snapshot.combined(play), it) } }
return items
}
var start = 0L
for (resolved in snapshot.entries) {
val end = start + resolved.length - 1
val entryStart = start
start += resolved.length
if (resolved.length == 0L || end < first || entryStart > last) continue
val from = maxOf(first, entryStart)
val to = minOf(last, end)
val offset = resolved.entry.offset + (from - entryStart)
rows(resolved.entry, offset, (to - from + 1).toInt())
.forEachIndexed { index, item -> items += QueueWindowItem(snapshot.combined(from + index), item) }
}
return items
}
private suspend fun blockPlayRange(
snapshot: QueueSnapshot,
first: Long,
last: Long,
): List<QueueWindowItem> {
if (first > last) return emptyList()
val items = mutableListOf<QueueWindowItem>()
var start = 0L
for (resolved in snapshot.upNext) {
val end = start + resolved.length - 1
val entryStart = start
start += resolved.length
if (resolved.length == 0L || end < first || entryStart > last) continue
val from = maxOf(first, entryStart)
val to = minOf(last, end)
val offset = resolved.entry.offset + (from - entryStart)
rows(resolved.entry, offset, (to - from + 1).toInt()).forEachIndexed { index, item ->
items += QueueWindowItem(snapshot.anchorPlay + 1 + from + index, item, upNext = true)
}
}
return items
}
private suspend fun rows( private suspend fun rows(
entry: QueueEntry, entry: QueueEntry,
offset: Long, offset: Long,
@@ -59,6 +59,8 @@ private sealed interface QueueUndo {
val cursor: Long val cursor: Long
val current: Long? val current: Long?
val shuffleOrder: LongArray? val shuffleOrder: LongArray?
val upNext: List<QueueEntry>
val upNextAnchor: Long
} }
private data class RemovedUndo( private data class RemovedUndo(
@@ -68,6 +70,8 @@ private data class RemovedUndo(
val position: Long, val position: Long,
val item: QueueItem?, val item: QueueItem?,
override val shuffleOrder: LongArray?, override val shuffleOrder: LongArray?,
override val upNext: List<QueueEntry>,
override val upNextAnchor: Long,
) : QueueUndo ) : QueueUndo
private data class MovedUndo( private data class MovedUndo(
@@ -77,12 +81,16 @@ private data class MovedUndo(
val from: Long, val from: Long,
val to: Long, val to: Long,
override val shuffleOrder: LongArray?, override val shuffleOrder: LongArray?,
override val upNext: List<QueueEntry>,
override val upNextAnchor: Long,
) : QueueUndo ) : QueueUndo
private data class ReloadUndo( private data class ReloadUndo(
override val entries: List<QueueEntry>, override val entries: List<QueueEntry>,
override val cursor: Long, override val cursor: Long,
override val shuffleOrder: LongArray?, override val shuffleOrder: LongArray?,
override val upNext: List<QueueEntry>,
override val upNextAnchor: Long,
) : QueueUndo { ) : QueueUndo {
override val current: Long? = null override val current: Long? = null
} }
@@ -121,6 +129,7 @@ class PlaybackController(
private var queueSourceId: Long? = null private var queueSourceId: Long? = null
private var windowStart = 0L private var windowStart = 0L
private var windowEnd = -1L private var windowEnd = -1L
private var lastPosition: Long? = null
private var updating = false private var updating = false
private var lastEdit: QueueUndo? = null private var lastEdit: QueueUndo? = null
private var shuffleEnabled = false private var shuffleEnabled = false
@@ -234,15 +243,15 @@ class PlaybackController(
val previous = snapshot val previous = snapshot
val previousEntry = previous?.entries?.firstOrNull()?.entry val previousEntry = previous?.entries?.firstOrNull()?.entry
val same = previousEntry != null && previousEntry.kind == entry.kind && previousEntry.refId == entry.refId val same = previousEntry != null && previousEntry.kind == entry.kind && previousEntry.refId == entry.refId
val avoid = if (same) currentPosition()?.let { previous.flatPosition(it) } else null val avoid = if (same) currentPosition()?.let { previous.flatContext(previous.anchorContextPlay(it)) } else null
queueRepository.replace(listOf(entry)) queueRepository.replace(listOf(entry))
var snap = queueRepository.snapshot() var snap = queueRepository.snapshot()
if (snap.size == 0L) { if (snap.contextSize == 0L) {
stopLocked() stopLocked()
return@withLock return@withLock
} }
val start = randomIndex(snap.size, avoid) val start = randomIndex(snap.contextSize, avoid)
val order = withContext(Dispatchers.Default) { shuffledOrder(snap.size, start) } val order = withContext(Dispatchers.Default) { shuffledOrder(snap.contextSize, start) }
queueRepository.setShuffle(true, order) queueRepository.setShuffle(true, order)
shuffleEnabled = true shuffleEnabled = true
queueSourceId = entry.sourceId queueSourceId = entry.sourceId
@@ -272,6 +281,7 @@ class PlaybackController(
queueRepository.setCursor(target) queueRepository.setCursor(target)
if (target in windowStart..windowEnd) { if (target in windowStart..windowEnd) {
player.ensurePrepared() player.ensurePrepared()
lastPosition = target
player.seekToIndex((target - windowStart).toInt()) player.seekToIndex((target - windowStart).toInt())
player.play() player.play()
shiftWindowLocked(target) shiftWindowLocked(target)
@@ -290,10 +300,11 @@ class PlaybackController(
val player = player ?: return@withLock val player = player ?: return@withLock
val cursor = queueRepository.cursor() val cursor = queueRepository.cursor()
val entries = snapshot.entries.map { it.entry } val entries = snapshot.entries.map { it.entry }
val upNext = snapshot.upNext.map { it.entry }
if (snapshot.size == 1L) { if (snapshot.size == 1L) {
queueRepository.removeAt(snapshot, position) queueRepository.removeAt(snapshot, position)
stopLocked() stopLocked()
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleOrder) lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
return@withLock return@withLock
} }
val current = currentPosition() ?: return@withLock val current = currentPosition() ?: return@withLock
@@ -305,9 +316,9 @@ class PlaybackController(
windowJob?.cancel() windowJob?.cancel()
lastEdit = lastEdit =
if (removed == null) { if (removed == null) {
ReloadUndo(entries, cursor, snapshot.shuffleOrder) ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
} else { } else {
RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleOrder) RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
} }
when { when {
position == current -> { position == current -> {
@@ -347,6 +358,7 @@ class PlaybackController(
val current = currentPosition() ?: return@withLock val current = currentPosition() ?: return@withLock
val cursor = queueRepository.cursor() val cursor = queueRepository.cursor()
val entries = snapshot.entries.map { it.entry } val entries = snapshot.entries.map { it.entry }
val upNext = snapshot.upNext.map { it.entry }
if (!queueRepository.move(snapshot, from, to)) return@withLock if (!queueRepository.move(snapshot, from, to)) return@withLock
this.snapshot = readSnapshot() this.snapshot = readSnapshot()
val target = movedCursor(current, from, to) val target = movedCursor(current, from, to)
@@ -355,9 +367,9 @@ class PlaybackController(
val toInWindow = to in windowStart..windowEnd val toInWindow = to in windowStart..windowEnd
lastEdit = lastEdit =
if (fromInWindow == toInWindow) { if (fromInWindow == toInWindow) {
MovedUndo(entries, cursor, target, from, to, snapshot.shuffleOrder) MovedUndo(entries, cursor, target, from, to, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
} else { } else {
ReloadUndo(entries, cursor, snapshot.shuffleOrder) ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
} }
when { when {
fromInWindow && toInWindow -> { fromInWindow && toInWindow -> {
@@ -397,7 +409,7 @@ class PlaybackController(
val player = player ?: return@withLock val player = player ?: return@withLock
val playing = player.playWhenReady val playing = player.playWhenReady
val positionMs = player.currentPositionMs val positionMs = player.currentPositionMs
queueRepository.replace(undo.entries, undo.shuffleOrder) queueRepository.replace(undo.entries, undo.shuffleOrder, undo.upNext, undo.upNextAnchor)
shuffleEnabled = undo.shuffleOrder != null shuffleEnabled = undo.shuffleOrder != null
val restored = readSnapshot() val restored = readSnapshot()
this.snapshot = restored this.snapshot = restored
@@ -488,12 +500,33 @@ class PlaybackController(
val snapshot = snapshot ?: return val snapshot = snapshot ?: return
if (snapshot.size == 0L) return if (snapshot.size == 0L) return
val current = currentPosition() ?: return val current = currentPosition() ?: return
if (snapshot.isUpNext(current)) {
scope.launch { consumeUpNext(current) }
return
}
when { when {
current < snapshot.size - 1 -> jumpTo(current + 1) current < snapshot.size - 1 -> jumpTo(current + 1)
repeatMode != RepeatMode.Off -> jumpTo(0) repeatMode != RepeatMode.Off -> jumpTo(0)
} }
} }
private suspend fun consumeUpNext(position: Long) =
startLock.withLock {
val snapshot = snapshot ?: return@withLock
if (!snapshot.isUpNext(position)) return@withLock
queueRepository.removeUpNextAt(snapshot, snapshot.upNextIndex(position))
val updated = readSnapshot()
this.snapshot = updated
windowJob?.cancel()
endedHandled = false
queueRepository.setCursor(position)
if (position < updated.size) {
loadWindow(position, autoplay = true)
} else {
refresh(position)
}
}
fun previous() { fun previous() {
val player = player ?: return val player = player ?: return
if (player.currentPositionMs > RESTART_THRESHOLD_MS) { if (player.currentPositionMs > RESTART_THRESHOLD_MS) {
@@ -522,6 +555,45 @@ class PlaybackController(
} }
} }
fun addToQueue(
sourceId: Long,
kind: QueueKind,
refId: String,
) = enqueue(sourceId, kind, refId, playNext = false)
fun playNext(
sourceId: Long,
kind: QueueKind,
refId: String,
) = enqueue(sourceId, kind, refId, playNext = true)
private fun enqueue(
sourceId: Long,
kind: QueueKind,
refId: String,
playNext: Boolean,
) {
val entry = QueueEntry(position = 0, sourceId = sourceId, kind = kind, refId = refId)
scope.launch { enqueue(entry, playNext) }
}
private suspend fun enqueue(
entry: QueueEntry,
playNext: Boolean,
) = startLock.withLock {
val snapshot = snapshot ?: return@withLock
val player = player ?: return@withLock
if (snapshot.size == 0L) return@withLock
val current = currentPosition() ?: return@withLock
val position = queueRepository.addUpNext(snapshot, current, entry, playNext)
val updated = readSnapshot()
this.snapshot = updated
queueRepository.setCursor(position)
windowJob?.cancel()
rebuildWindow(queueRepository.window(updated, position, QUEUE_WINDOW_RADIUS), position)
refresh(position)
}
fun cycleRepeat() { fun cycleRepeat() {
val next = val next =
when (repeatMode) { when (repeatMode) {
@@ -543,22 +615,27 @@ class PlaybackController(
if (snapshot.size == 0L) return@withLock if (snapshot.size == 0L) return@withLock
player ?: return@withLock player ?: return@withLock
val current = currentPosition() ?: return@withLock val current = currentPosition() ?: return@withLock
val flat = snapshot.flatPosition(current) ?: return@withLock val wasUpNext = snapshot.isUpNext(current)
val blockIndex = snapshot.upNextIndex(current)
val flat = snapshot.flatContext(snapshot.anchorContextPlay(current)) ?: return@withLock
windowJob?.cancel() windowJob?.cancel()
val position = val enabling = !shuffleEnabled
if (shuffleEnabled) { if (enabling) {
queueRepository.setShuffle(false, null) val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, flat) }
shuffleEnabled = false
flat
} else {
val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.size, flat) }
queueRepository.setShuffle(true, order) queueRepository.setShuffle(true, order)
shuffleEnabled = true } else {
0L queueRepository.setShuffle(false, null)
} }
queueRepository.setCursor(position) shuffleEnabled = enabling
val reordered = readSnapshot() val reordered = readSnapshot()
this@PlaybackController.snapshot = reordered this@PlaybackController.snapshot = reordered
val position =
when {
wasUpNext -> reordered.anchorPlay + 1 + blockIndex
enabling -> reordered.combined(0)
else -> reordered.combined(flat)
}
queueRepository.setCursor(position)
rebuildWindow(queueRepository.window(reordered, position, QUEUE_WINDOW_RADIUS), position) rebuildWindow(queueRepository.window(reordered, position, QUEUE_WINDOW_RADIUS), position)
endedHandled = false endedHandled = false
refresh(position) refresh(position)
@@ -618,10 +695,10 @@ class PlaybackController(
stopLocked() stopLocked()
return@withLock return@withLock
} }
val start = startPosition.coerceIn(0, snapshot.size - 1) val start = startPosition.coerceIn(0, snapshot.contextSize - 1)
val position = val position =
if (shuffleEnabled) { if (shuffleEnabled) {
val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.size, start) } val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, start) }
queueRepository.setShuffle(true, order) queueRepository.setShuffle(true, order)
0L 0L
} else { } else {
@@ -643,9 +720,9 @@ class PlaybackController(
repeatMode = modes.repeat.toRepeatMode() repeatMode = modes.repeat.toRepeatMode()
player?.setRepeatOne(repeatMode == RepeatMode.One) player?.setRepeatOne(repeatMode == RepeatMode.One)
var snapshot = queueRepository.snapshot() var snapshot = queueRepository.snapshot()
if (shuffleEnabled && !snapshot.shuffled && snapshot.size > 0L) { if (shuffleEnabled && !snapshot.shuffled && snapshot.contextSize > 0L) {
val start = queueRepository.cursor().coerceIn(0, snapshot.size - 1) val start = snapshot.anchorContextPlay(queueRepository.cursor()).coerceIn(0, snapshot.contextSize - 1)
val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.size, start) } val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, start) }
queueRepository.setShuffle(true, order) queueRepository.setShuffle(true, order)
queueRepository.setCursor(0) queueRepository.setCursor(0)
snapshot = queueRepository.snapshot() snapshot = queueRepository.snapshot()
@@ -680,6 +757,7 @@ class PlaybackController(
queueSourceId = null queueSourceId = null
windowStart = 0 windowStart = 0
windowEnd = -1 windowEnd = -1
lastPosition = null
lastEdit = null lastEdit = null
player?.run { player?.run {
stop() stop()
@@ -702,6 +780,7 @@ class PlaybackController(
if (startIndex < 0) return if (startIndex < 0) return
windowStart = window.first().position windowStart = window.first().position
windowEnd = window.last().position windowEnd = window.last().position
lastPosition = position
updating = true updating = true
player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs) player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs)
updating = false updating = false
@@ -745,6 +824,7 @@ class PlaybackController(
player.seekToIndex((dest - windowStart).toInt()) player.seekToIndex((dest - windowStart).toInt())
if (wasEnded) player.play() if (wasEnded) player.play()
endedHandled = false endedHandled = false
lastPosition = dest
refresh(dest) refresh(dest)
} }
} }
@@ -755,6 +835,12 @@ class PlaybackController(
val snapshot = snapshot ?: return val snapshot = snapshot ?: return
if (snapshot.size == 0L) return if (snapshot.size == 0L) return
val position = (windowStart + player.currentIndex).coerceIn(0, snapshot.size - 1) val position = (windowStart + player.currentIndex).coerceIn(0, snapshot.size - 1)
val previous = lastPosition
lastPosition = position
if (previous != null && previous != position && snapshot.isUpNext(previous)) {
consumeUpNext(previous)
return
}
lastSavedPositionMs = 0 lastSavedPositionMs = 0
queueRepository.setCursor(position) queueRepository.setCursor(position)
refresh(position) refresh(position)
@@ -782,7 +868,7 @@ class PlaybackController(
if (previous != null && previous.shuffled != snapshot.shuffled) { if (previous != null && previous.shuffled != snapshot.shuffled) {
val currentId = player.currentItem?.id val currentId = player.currentItem?.id
val position = val position =
(currentId?.let { queueRepository.flatIndexOf(snapshot, it) } ?: center) (currentId?.let { queueRepository.combinedIndexOf(snapshot, it) } ?: center)
.coerceIn(0, snapshot.size - 1) .coerceIn(0, snapshot.size - 1)
queueRepository.setCursor(position) queueRepository.setCursor(position)
rebuildWindow(queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS), position) rebuildWindow(queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS), position)
@@ -835,6 +921,7 @@ class PlaybackController(
} }
windowStart = window.first().position windowStart = window.first().position
windowEnd = window.last().position windowEnd = window.last().position
lastPosition = position
} }
private fun currentPosition(): Long? { private fun currentPosition(): Long? {
@@ -900,7 +987,9 @@ class PlaybackController(
} }
private fun contextAt(position: Long?): QueueContext? { private fun contextAt(position: Long?): QueueContext? {
val entry = position?.let { snapshot?.locate(it)?.first } ?: return null val snapshot = snapshot ?: return null
val play = position?.let { snapshot.anchorContextPlay(it) } ?: return null
val entry = snapshot.locateContext(play)?.first ?: return null
return QueueContext(entry.kind, entry.sourceId, entry.refId) return QueueContext(entry.kind, entry.sourceId, entry.refId)
} }
@@ -12,10 +12,12 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
import androidx.compose.material.icons.rounded.Album import androidx.compose.material.icons.rounded.Album
import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material.icons.rounded.Shuffle
import androidx.compose.material.icons.rounded.SkipNext
import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarBorder import androidx.compose.material.icons.rounded.StarBorder
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -41,6 +43,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
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 com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.QueueKind
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.subtracks.data.model.Album as AlbumModel import com.subtracks.data.model.Album as AlbumModel
@@ -90,12 +93,28 @@ sealed interface MenuTarget {
} }
} }
data class QueueRef(
val sourceId: Long,
val kind: QueueKind,
val refId: String,
)
fun MenuTarget.queueRef(): QueueRef? =
when (this) {
is MenuTarget.Song -> QueueRef(song.sourceId, QueueKind.Song, song.id)
is MenuTarget.Album -> QueueRef(album.sourceId, QueueKind.Album, album.id)
is MenuTarget.Playlist -> QueueRef(playlist.sourceId, QueueKind.Playlist, playlist.id)
is MenuTarget.Artist -> null
}
class ItemActions( class ItemActions(
val playSong: ((SongModel) -> Unit)? = null, val playSong: ((SongModel) -> Unit)? = null,
val playAlbum: (AlbumModel) -> Unit = {}, val playAlbum: (AlbumModel) -> Unit = {},
val shuffleAlbum: (AlbumModel) -> Unit = {}, val shuffleAlbum: (AlbumModel) -> Unit = {},
val playPlaylist: (PlaylistModel) -> Unit = {}, val playPlaylist: (PlaylistModel) -> Unit = {},
val shufflePlaylist: (PlaylistModel) -> Unit = {}, val shufflePlaylist: (PlaylistModel) -> Unit = {},
val playNext: ((QueueRef) -> Unit)? = null,
val addToQueue: ((QueueRef) -> Unit)? = null,
val setStar: suspend (StarType, String, Boolean) -> Result<Unit> = { _, _, _ -> Result.success(Unit) }, val setStar: suspend (StarType, String, Boolean) -> Result<Unit> = { _, _, _ -> Result.success(Unit) },
val viewAlbum: ((String) -> Unit)? = null, val viewAlbum: ((String) -> Unit)? = null,
val viewArtist: ((String) -> Unit)? = null, val viewArtist: ((String) -> Unit)? = null,
@@ -139,6 +158,19 @@ fun ItemContextMenu(
} }
} }
val queueRef = target.queueRef()
@Composable
fun queueItems() {
if (queueRef == null) return
actions.playNext?.let { next ->
MenuItem(Icons.Rounded.SkipNext, "Play next") { dismiss { next(queueRef) } }
}
actions.addToQueue?.let { add ->
MenuItem(Icons.AutoMirrored.Rounded.QueueMusic, "Add to queue") { dismiss { add(queueRef) } }
}
}
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { dismiss() }, onDismissRequest = { dismiss() },
sheetState = sheetState, sheetState = sheetState,
@@ -151,6 +183,7 @@ fun ItemContextMenu(
actions.playSong?.let { play -> actions.playSong?.let { play ->
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { play(target.song) } } MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { play(target.song) } }
} }
queueItems()
StarItem(target.song.starred) { starring -> actions.setStar(StarType.Song, target.song.id, starring) } StarItem(target.song.starred) { starring -> actions.setStar(StarType.Song, target.song.id, starring) }
target.song.albumId?.let { albumId -> target.song.albumId?.let { albumId ->
actions.viewAlbum?.let { view -> actions.viewAlbum?.let { view ->
@@ -171,6 +204,7 @@ fun ItemContextMenu(
is MenuTarget.Album -> { is MenuTarget.Album -> {
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { actions.playAlbum(target.album) } } MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { actions.playAlbum(target.album) } }
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shuffleAlbum(target.album) } } MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shuffleAlbum(target.album) } }
queueItems()
StarItem(target.album.starred) { starring -> actions.setStar(StarType.Album, target.album.id, starring) } StarItem(target.album.starred) { starring -> actions.setStar(StarType.Album, target.album.id, starring) }
target.album.artistId?.let { artistId -> target.album.artistId?.let { artistId ->
actions.viewArtist?.let { view -> actions.viewArtist?.let { view ->
@@ -188,6 +222,7 @@ fun ItemContextMenu(
is MenuTarget.Playlist -> { is MenuTarget.Playlist -> {
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { actions.playPlaylist(target.playlist) } } MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { actions.playPlaylist(target.playlist) } }
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shufflePlaylist(target.playlist) } } MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shufflePlaylist(target.playlist) } }
queueItems()
} }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -63,6 +63,8 @@ fun AlbumDetailRoute(
playSong = { song -> songs.indexOfFirst { it.id == song.id }.takeIf { it >= 0 }?.let(viewModel::play) }, playSong = { song -> songs.indexOfFirst { it.id == song.id }.takeIf { it >= 0 }?.let(viewModel::play) },
playAlbum = { viewModel.playAll() }, playAlbum = { viewModel.playAll() },
shuffleAlbum = { viewModel.shuffle() }, shuffleAlbum = { viewModel.shuffle() },
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
setStar = setStar, setStar = setStar,
viewArtist = onViewArtist, viewArtist = onViewArtist,
) )
@@ -214,6 +214,8 @@ fun LibraryRoute(
shuffleAlbum = { viewModel.shuffleAlbum(it.id) }, shuffleAlbum = { viewModel.shuffleAlbum(it.id) },
playPlaylist = { viewModel.playPlaylist(it.id) }, playPlaylist = { viewModel.playPlaylist(it.id) },
shufflePlaylist = { viewModel.shufflePlaylist(it.id) }, shufflePlaylist = { viewModel.shufflePlaylist(it.id) },
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
setStar = setStar, setStar = setStar,
viewAlbum = onViewAlbum, viewAlbum = onViewAlbum,
viewArtist = onViewArtist, viewArtist = onViewArtist,
@@ -49,6 +49,8 @@ fun PlaylistDetailRoute(
ItemActions( ItemActions(
playPlaylist = { viewModel.playAll() }, playPlaylist = { viewModel.playAll() },
shufflePlaylist = { viewModel.shuffle() }, shufflePlaylist = { viewModel.shuffle() },
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
setStar = setStar, setStar = setStar,
viewAlbum = onViewAlbum, viewAlbum = onViewAlbum,
viewArtist = onViewArtist, viewArtist = onViewArtist,
@@ -105,6 +105,8 @@ fun NowPlayingRoute(
val context = LocalPlatformContext.current val context = LocalPlatformContext.current
val actions = val actions =
ItemActions( ItemActions(
playNext = { controller.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { controller.addToQueue(it.sourceId, it.kind, it.refId) },
setStar = setStar, setStar = setStar,
viewAlbum = onViewAlbum, viewAlbum = onViewAlbum,
viewArtist = onViewArtist, viewArtist = onViewArtist,
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -75,6 +76,7 @@ data class QueueRow(
val id: Long, val id: Long,
val position: Long, val position: Long,
val song: SongListItem, val song: SongListItem,
val upNext: Boolean = false,
) )
class QueueViewModel( class QueueViewModel(
@@ -95,7 +97,7 @@ class QueueViewModel(
private var nextId = 0L private var nextId = 0L
private val mutex = Mutex() private val mutex = Mutex()
private fun newRow(item: QueueWindowItem) = QueueRow(nextId++, item.position, item.item) private fun newRow(item: QueueWindowItem) = QueueRow(nextId++, item.position, item.item, item.upNext)
private fun reusedIds(): Map<String, MutableList<Long>> = private fun reusedIds(): Map<String, MutableList<Long>> =
rows.groupBy { it.song.song.id }.mapValues { entry -> entry.value.map { it.id }.toMutableList() } rows.groupBy { it.song.song.id }.mapValues { entry -> entry.value.map { it.id }.toMutableList() }
@@ -106,7 +108,7 @@ class QueueViewModel(
): QueueRow { ): QueueRow {
val ids = reused[item.item.song.id] val ids = reused[item.item.song.id]
val id = if (!ids.isNullOrEmpty()) ids.removeAt(0) else nextId++ val id = if (!ids.isNullOrEmpty()) ids.removeAt(0) else nextId++
return QueueRow(id, item.position, item.item) return QueueRow(id, item.position, item.item, item.upNext)
} }
fun open() { fun open() {
@@ -169,6 +171,7 @@ class QueueViewModel(
) { ) {
if (fromIndex == toIndex) return if (fromIndex == toIndex) return
if (fromIndex !in rows.indices || toIndex !in rows.indices) return if (fromIndex !in rows.indices || toIndex !in rows.indices) return
if (rows[fromIndex].upNext != rows[toIndex].upNext) return
rows.add(toIndex, rows.removeAt(fromIndex)) rows.add(toIndex, rows.removeAt(fromIndex))
} }
@@ -256,6 +259,12 @@ fun QueueRoute(
sourceRepository: SourceRepository = koinInject(), sourceRepository: SourceRepository = koinInject(),
) { ) {
val playback by controller.state.collectAsStateWithLifecycle() val playback by controller.state.collectAsStateWithLifecycle()
val queueContext = playback.context
val fallbackTitle = "Next from here"
val sourceTitle = remember(queueContext) { mutableStateOf(fallbackTitle) }
LaunchedEffect(queueContext) {
sourceTitle.value = controller.sourceTitle(queueContext)?.takeIf { it.isNotBlank() } ?: fallbackTitle
}
LaunchedEffect(playback.shuffle, open) { if (open) viewModel.open() } LaunchedEffect(playback.shuffle, open) { if (open) viewModel.open() }
QueueScreen( QueueScreen(
rows = viewModel.rows, rows = viewModel.rows,
@@ -263,6 +272,8 @@ fun QueueRoute(
initialIndex = viewModel.initialIndex, initialIndex = viewModel.initialIndex,
generation = viewModel.generation, generation = viewModel.generation,
currentSongId = playback.item?.id, currentSongId = playback.item?.id,
shuffle = playback.shuffle,
contextTitle = sourceTitle.value,
coverArt = sourceRepository::coverArt, coverArt = sourceRepository::coverArt,
onBack = onBack, onBack = onBack,
onPlay = viewModel::play, onPlay = viewModel::play,
@@ -294,6 +305,8 @@ fun QueueScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
initialIndex: Int = 0, initialIndex: Int = 0,
generation: Int = 0, generation: Int = 0,
shuffle: Boolean = false,
contextTitle: String? = null,
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val fill = rememberViewportFill(listState) val fill = rememberViewportFill(listState)
@@ -360,6 +373,10 @@ fun QueueScreen(
) { ) {
items(count = rows.size, key = { rows[it].id }) { index -> items(count = rows.size, key = { rows[it].id }) { index ->
val row = rows[index] val row = rows[index]
if (index == 0 || rows[index - 1].upNext != row.upNext) {
QueueSectionHeader(if (row.upNext) "Up next" else contextTitle ?: "Next up")
}
val draggable = row.upNext || !shuffle
ReorderableItem( ReorderableItem(
state = reorderState, state = reorderState,
key = row.id, key = row.id,
@@ -376,6 +393,9 @@ fun QueueScreen(
floating = isDragging, floating = isDragging,
coverArt = coverArt, coverArt = coverArt,
dragHandle = dragHandle =
if (!draggable) {
null
} else {
Modifier.draggableHandle( Modifier.draggableHandle(
onDragStarted = { onDragStarted = {
dragId = row.id dragId = row.id
@@ -387,7 +407,12 @@ fun QueueScreen(
if (id != null && from != null) { if (id != null && from != null) {
val index = rows.indexOfFirst { it.id == id } val index = rows.indexOfFirst { it.id == id }
val to = if (index < 0) from else dropTarget(rows, index, from) val to = if (index < 0) from else dropTarget(rows, index, from)
if (to != from) { val left = rows.getOrNull(index - 1)?.upNext
val right = rows.getOrNull(index + 1)?.upNext
val sameSection =
(left == null || left == row.upNext) &&
(right == null || right == row.upNext)
if (to != from && sameSection) {
onMove(from, to) onMove(from, to)
showUndo("Queue reordered") showUndo("Queue reordered")
} }
@@ -395,7 +420,8 @@ fun QueueScreen(
dragId = null dragId = null
dragFrom = null dragFrom = null
}, },
), )
},
onClick = { onPlay(row.position) }, onClick = { onPlay(row.position) },
onRemove = { onRemove = {
onRemove(row.position) onRemove(row.position)
@@ -411,6 +437,16 @@ fun QueueScreen(
} }
} }
@Composable
private fun QueueSectionHeader(label: String) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
)
}
internal fun dropTarget( internal fun dropTarget(
rows: List<QueueRow>, rows: List<QueueRow>,
index: Int, index: Int,
@@ -428,7 +464,7 @@ private fun QueueRowItem(
isPlaying: Boolean, isPlaying: Boolean,
floating: Boolean, floating: Boolean,
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
dragHandle: Modifier, dragHandle: Modifier?,
onClick: () -> Unit, onClick: () -> Unit,
onRemove: () -> Unit, onRemove: () -> Unit,
) { ) {
@@ -446,10 +482,12 @@ private fun QueueRowItem(
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
) )
} }
if (dragHandle != null) {
Box(modifier = Modifier.size(40.dp).then(dragHandle), contentAlignment = Alignment.Center) { Box(modifier = Modifier.size(40.dp).then(dragHandle), contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = "Reorder") Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = "Reorder")
} }
} }
}
}, },
modifier = modifier =
Modifier Modifier
@@ -372,6 +372,142 @@ class QueueRepositoryTest {
assertEquals(4, repository.snapshot().size) assertEquals(4, repository.snapshot().size)
} }
@Test
fun upNextResolvesAfterTheCurrentTrack() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
val updated = repository.snapshot()
assertEquals(4, updated.size)
assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(updated))
}
@Test
fun playNextGoesToTheFrontOfTheUpNextQueue() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(
repository.snapshot(),
currentPosition = 0,
entry = repository.songEntry(1, "s5"),
playNext = true,
)
assertEquals(listOf("s1", "s5", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
}
@Test
fun removingAQueuedTrackDropsItFromTheCombinedQueue() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.removeAt(repository.snapshot(), 1)
val updated = repository.snapshot()
assertEquals(3, updated.size)
assertEquals(listOf("s1", "s2", "s3"), resolveAll(updated))
}
@Test
fun movingAQueuedTrackReordersTheUpNextQueue() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(
repository.snapshot(),
currentPosition = 0,
entry = repository.songEntry(1, "s5"),
playNext = false,
)
assertEquals(listOf("s1", "s4", "s5", "s2", "s3"), resolveAll(repository.snapshot()))
repository.move(repository.snapshot(), from = 2, to = 1)
assertEquals(listOf("s1", "s5", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
}
@Test
fun queuedTracksNeverJoinAShuffledContext() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.setShuffle(true, longArrayOf(2, 0, 1))
val shuffled = repository.snapshot()
repository.addUpNext(shuffled, currentPosition = 0, entry = repository.songEntry(1, "s5"), playNext = false)
val updated = repository.snapshot()
assertEquals(4, updated.size)
assertEquals("s5", repository.itemAt(updated, 1)?.song?.id)
assertEquals(listOf("s3", "s5", "s1", "s2"), resolveAll(updated))
assertTrue(updated.shuffled)
}
@Test
fun consumingASliceOfAQueuedAlbumRemovesTheRightTrack() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
assertEquals(listOf("s1", "s4", "s5", "s2", "s3"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 2)
assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
}
@Test
fun removingTheLastAnchorTrackKeepsTheQueueResolvable() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 2, entry = repository.songEntry(1, "s4"), playNext = false)
assertEquals(listOf("s1", "s2", "s3", "s4"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 2)
val updated = repository.snapshot()
assertEquals(3, updated.size)
assertEquals(listOf("s1", "s2", "s4"), resolveAll(updated))
}
@Test
fun removingAContextTrackBeforeTheAnchorKeepsTheQueueAttached() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false)
assertEquals(listOf("s1", "s2", "s4", "s3"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 0)
assertEquals(listOf("s2", "s4", "s3"), resolveAll(repository.snapshot()))
}
@Test
fun replacingTheContextClearsTheUpNextQueue() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.replace(listOf(repository.songEntry(1, "s5")))
val updated = repository.snapshot()
assertTrue(updated.upNext.isEmpty())
assertEquals(1, updated.size)
assertEquals(listOf("s5"), resolveAll(updated))
}
private fun albumRange( private fun albumRange(
start: Long, start: Long,
end: Long, end: Long,
@@ -183,8 +183,10 @@ class FakePlayerHandle : PlayerHandle {
override fun seekToIndex(index: Int) { override fun seekToIndex(index: Int) {
operations += "seekToIndex($index)" operations += "seekToIndex($index)"
val changed = index != this.index
this.index = index this.index = index
ended = false ended = false
if (changed) listeners.forEach { it.onTransition() }
notifyEvents() notifyEvents()
} }
@@ -192,6 +192,167 @@ class PlaybackControllerTest {
assertTrue(controller.state.value.hasPrevious) assertTrue(controller.state.value.hasPrevious)
} }
@Test
fun aQueuedTrackPlaysNextAndIsConsumed() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
controller.next()
await {
controller.state.value.item
?.id == "s2"
}
assertEquals("s3", runBlocking { controller.upcomingItem()?.id })
}
@Test
fun addingToTheQueueFromAMiddleTrackPlaysTheQueuedTrackNext() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 1)
await {
controller.state.value.item
?.id == "s2"
}
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
controller.next()
await {
controller.state.value.item
?.id == "s3"
}
}
@Test
fun playNextGoesInFrontOfTheQueuedTracks() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
seedSong("x2", "al2")
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Song, "x1")
controller.playNext(1, QueueKind.Song, "x2")
await { runBlocking { controller.upcomingItem()?.id } == "x2" }
controller.next()
await {
controller.state.value.item
?.id == "x2"
}
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
}
@Test
fun tappingAContextTrackWhileAQueuedTrackPlaysKeepsTheQueue() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Song, "x1")
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
controller.playAt(3)
await {
controller.state.value.item
?.id == "s3"
}
assertFalse(runBlocking { queues.snapshot().upNext }.isEmpty())
}
@Test
fun previousFromAQueuedTrackReturnsToTheContextWithoutConsuming() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Song, "x1")
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
controller.previous()
await {
controller.state.value.item
?.id == "s1"
}
assertEquals("x1", runBlocking { controller.upcomingItem()?.id })
}
@Test
fun startingANewContextClearsTheQueue() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
assertTrue(runBlocking { queues.snapshot().upNext }.isEmpty())
}
@Test @Test
fun theDurationUsesTheNextTracksMetadataWhileItLoads() { fun theDurationUsesTheNextTracksMetadataWhileItLoads() {
seedAlbum(3, sourceId = 1) seedAlbum(3, sourceId = 1)
@@ -1152,6 +1313,32 @@ class PlaybackControllerTest {
} }
} }
private fun seedSong(
id: String,
albumId: String,
) {
runBlocking {
db.libraryDao().upsertSongs(
listOf(
Song(
sourceId = 1,
id = id,
albumId = albumId,
artistId = "ar1",
title = "Song $id",
album = "Other Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
genre = null,
),
),
)
}
}
private fun await(predicate: () -> Boolean) { private fun await(predicate: () -> Boolean) {
val deadline = System.nanoTime() + 5_000_000_000L val deadline = System.nanoTime() + 5_000_000_000L
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10) while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
+3 -1
View File
@@ -84,11 +84,13 @@ The queue lives in SQL, not memory. `queue_entries` is an ordered list of *refer
`QueueRepository` resolves a queue position to a song lazily: it walks the entries summing their lengths (a `COUNT(*)` per entry) and then fetches one row with `LIMIT 1 OFFSET`. `PlaybackController` keeps only the resolved entry lengths and the cursor in memory — never the songs — and hands the player a bounded window (currently `2 * 25 + 1` items) centred on the cursor. A player index maps back to a queue position as `windowStart + index`. The window is loaded once with `setMediaItems` and then shifted incrementally with `addMediaItems`/`removeMediaItem` as playback advances; a shift never replaces the playlist, so the current item and its prepared next source survive and transitions stay gapless. `hasNext`/`hasPrevious` are intentionally always true rather than derived from the SQL queue size: the previous/next buttons stay usable at both ends of the queue so repeat mode and restart-the-track keep working, and `next()`/`previous()` decide the actual end-of-queue action (advance, wrap when repeating, restart, or no-op). The snapshot is re-read whenever the window shifts and on skip, so a sync that shrinks the referenced album or playlist cannot strand the window on stale lengths; if the active source changes under a loaded queue, playback stops rather than resolving the old queue's stream URLs against the new server. `QueueRepository` resolves a queue position to a song lazily: it walks the entries summing their lengths (a `COUNT(*)` per entry) and then fetches one row with `LIMIT 1 OFFSET`. `PlaybackController` keeps only the resolved entry lengths and the cursor in memory — never the songs — and hands the player a bounded window (currently `2 * 25 + 1` items) centred on the cursor. A player index maps back to a queue position as `windowStart + index`. The window is loaded once with `setMediaItems` and then shifted incrementally with `addMediaItems`/`removeMediaItem` as playback advances; a shift never replaces the playlist, so the current item and its prepared next source survive and transitions stay gapless. `hasNext`/`hasPrevious` are intentionally always true rather than derived from the SQL queue size: the previous/next buttons stay usable at both ends of the queue so repeat mode and restart-the-track keep working, and `next()`/`previous()` decide the actual end-of-queue action (advance, wrap when repeating, restart, or no-op). The snapshot is re-read whenever the window shifts and on skip, so a sync that shrinks the referenced album or playlist cannot strand the window on stale lengths; if the active source changes under a loaded queue, playback stops rather than resolving the old queue's stream URLs against the new server.
Playback is a *context* plus a manual *up next* list. `queue_entries` is the context — the album, playlist or song you started, and the only thing shuffle permutes. `up_next_entries` is the manual queue: the same reference-plus-range rows, ordered, never shuffled. A `playback_cursor.upNextAnchor` (a flat index into the context) pins the block between a context track and its successor, and `QueueRepository` resolves a combined position by mapping it through the context order and splicing the block after the anchor, so the controller's window and next/previous logic keep working. Add to queue appends to the block and play next inserts at its front; both reuse the context edit machinery (range split, insert, compact). Advancing out of a block track removes it — consume as played, with no history — and a new context clears the block, so a manual edit never disturbs the shuffle permutation.
Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from. Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from.
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances.
The window is also the queue the media notification and Android Auto can browse — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. Timed lyrics/gapless format preferences, scrobbling, downloads and adding to the queue from the library are not wired yet. The window is also the queue the media notification and Android Auto can browse — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics/gapless format preferences, scrobbling and downloads are not wired yet.
## Data model ## Data model
+2 -1
View File
@@ -35,7 +35,8 @@ Not done yet in this slice (deliberately): cover-art tonal colour extraction, lo
- (done) A SQL-backed queue of references (whole playlist / album / song, each with an optional ordinal range) resolved lazily, with only a bounded window in memory and in the player. - (done) A SQL-backed queue of references (whole playlist / album / song, each with an optional ordinal range) resolved lazily, with only a bounded window in memory and in the player.
- (done) Streaming bitrate and preferred format from settings (`maxBitRate`/`format`, so the server transcodes as configured). - (done) Streaming bitrate and preferred format from settings (`maxBitRate`/`format`, so the server transcodes as configured).
- (done) A queue view that lists the resolved queue, jumps to a track on tap, removes tracks and reorders them by dragging, with a one-step undo. - (done) A queue view that lists the resolved queue, jumps to a track on tap, removes tracks and reorders them by dragging, with a one-step undo.
- Adding to the queue from the library (play next / add to queue), and gapless format preferences. - (done) Adding to the queue from the library (play next / add to queue), backed by a separate, editable up-next list that plays between the current track and the rest of the context.
- Gapless format preferences.
- Scrobbling and "now playing" back to the server. - Scrobbling and "now playing" back to the server.
- Optional download/caching. - Optional download/caching.