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(
connection: SQLiteConnection,
table: String,
@@ -232,4 +251,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_12_13,
MIGRATION_13_14,
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.ShuffleOrder
import com.subtracks.data.model.SongListItem
import com.subtracks.data.model.UpNextEntry
private const val ALBUM_SONGS_SQL =
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
@@ -31,6 +32,15 @@ interface QueueDao {
@Query("SELECT * FROM queue_entries ORDER BY position, id")
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")
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.Source
import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.UpNextEntry
@Database(
entities = [
@@ -28,11 +29,12 @@ import com.subtracks.data.model.SubsonicSource
PlaylistSong::class,
Song::class,
QueueEntry::class,
UpNextEntry::class,
PlaybackCursor::class,
ShuffleOrder::class,
ArtworkSeed::class,
],
version = 15,
version = 16,
exportSchema = true,
)
@ColumnTypeConverters(QueueKindConverter::class)
@@ -265,6 +265,39 @@ data class QueueEntry(
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")
data class PlaybackCursor(
@PrimaryKey val id: Long = 1,
@@ -273,6 +306,7 @@ data class PlaybackCursor(
@ColumnInfo(defaultValue = "0") val repeatMode: Int = 0,
@ColumnInfo(defaultValue = "0") val shuffleSeed: Long = 0,
@ColumnInfo(defaultValue = "0") val positionMs: Long = 0,
@ColumnInfo(defaultValue = "0") val upNextAnchor: Long = 0,
)
@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.ShuffleOrder
import com.subtracks.data.model.SongListItem
import com.subtracks.data.model.UpNextEntry
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -29,6 +30,13 @@ private data class EntryLengthKey(
val count: Long?,
)
private data class LoadedSnapshot(
val entries: List<ResolvedQueueEntry>,
val upNext: List<ResolvedQueueEntry>,
val shuffleOrder: LongArray?,
val anchor: Long,
)
data class ResolvedQueueEntry(
val entry: QueueEntry,
val length: Long,
@@ -36,25 +44,59 @@ data class ResolvedQueueEntry(
data class QueueSnapshot(
val entries: List<ResolvedQueueEntry>,
val upNext: List<ResolvedQueueEntry> = emptyList(),
val shuffleOrder: LongArray? = null,
val upNextAnchor: 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
fun locate(position: Long): Pair<QueueEntry, Long>? = locateFlat(flatPosition(position) ?: return null)
fun flatPosition(sequence: Long): Long? = if (shuffleOrder == null) sequence else shuffleOrder.getOrNull(sequence.toInt())
private fun locateFlat(position: Long): Pair<QueueEntry, Long>? {
var remaining = position
for (resolved in entries) {
if (remaining < resolved.length) return resolved.entry to remaining
remaining -= resolved.length
val anchorPlay: Long =
when (val order = shuffleOrder) {
null -> upNextAnchor
else -> order.indexOf(upNextAnchor).takeIf { it >= 0 }?.toLong() ?: 0L
}
return null
fun isUpNext(position: Long): Boolean = upNextSize > 0L && position > anchorPlay && position <= anchorPlay + upNextSize
fun upNextIndex(position: Long): Long = position - anchorPlay - 1L
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) {
if (remaining < resolved.length) return resolved.entry to remaining
remaining -= resolved.length
}
return null
}
data class QueueModes(
@@ -65,6 +107,7 @@ data class QueueModes(
data class QueueWindowItem(
val position: Long,
val item: SongListItem,
val upNext: Boolean = false,
)
class QueueRepository(
@@ -137,10 +180,15 @@ class QueueRepository(
suspend fun replace(
entries: List<QueueEntry>,
shuffleOrder: LongArray? = null,
upNext: List<QueueEntry> = emptyList(),
upNextAnchor: Long = 0,
) {
write(entries)
writeUpNext(upNext)
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 {
@@ -149,15 +197,17 @@ class QueueRepository(
db.useReaderConnection { transactor ->
transactor.deferredTransaction {
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 shuffled = row?.shuffleEnabled == true
val order = if (shuffled) loadShuffleOrder(row.shuffleSeed, entries.sumOf { it.length }) else null
Triple(entries, order, shuffled)
val order = if (row?.shuffleEnabled == true) loadShuffleOrder(row.shuffleSeed, entries.sumOf { it.length }) else null
LoadedSnapshot(entries, upNext, order, row?.upNextAnchor ?: 0L)
}
}
val (entries, order, shuffled) = loaded
val valid = if (shuffled && order == null) null else order
return QueueSnapshot(entries, valid, version)
return QueueSnapshot(loaded.entries, loaded.upNext, loaded.shuffleOrder, loaded.anchor, version)
}
suspend fun modes(): QueueModes {
@@ -209,29 +259,21 @@ class QueueRepository(
snapshot: QueueSnapshot,
position: Long,
): 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
return rows(entry, entry.offset + offset, 1).firstOrNull()
}
suspend fun flatIndexOf(
suspend fun combinedIndexOf(
snapshot: QueueSnapshot,
songId: String,
): 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)
if (flat < 0) return null
val position = order.indexOf(flat.toLong())
return if (position >= 0) position.toLong() else null
val play = snapshot.shuffleOrder?.indexOf(flat.toLong())?.toLong() ?: flat.toLong()
if (play < 0) return null
return snapshot.combined(play)
}
suspend fun range(
@@ -239,36 +281,20 @@ class QueueRepository(
first: Long,
last: Long,
): List<QueueWindowItem> {
if (snapshot.shuffled) {
val sourceId =
snapshot.entries
.firstOrNull()
?.entry
?.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 from = first.coerceAtLeast(0)
val to = last.coerceAtMost(snapshot.size - 1)
if (from > to) return emptyList()
val anchor = snapshot.anchorPlay
val block = snapshot.upNextSize
val items = mutableListOf<QueueWindowItem>()
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(from + index, item) }
}
val beforeEnd = minOf(to, anchor)
if (from <= beforeEnd) items += contextPlayRange(snapshot, from, beforeEnd)
val blockFirst = maxOf(from, anchor + 1)
val blockLast = minOf(to, anchor + block)
if (blockFirst <= blockLast) items += blockPlayRange(snapshot, blockFirst - anchor - 1, blockLast - anchor - 1)
val afterFirst = maxOf(from, anchor + block + 1)
if (afterFirst <= to) items += contextPlayRange(snapshot, afterFirst - block, to - block)
items.sortBy { it.position }
return items
}
@@ -281,41 +307,133 @@ class QueueRepository(
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(
snapshot: QueueSnapshot,
position: Long,
): QueueSnapshot {
val order = snapshot.shuffleOrder
if (order == null) {
write(compact(removeEntry(snapshot.entries, position)).map { it.entry })
if (snapshot.isUpNext(position)) {
removeUpNextAt(snapshot, snapshot.upNextIndex(position))
return snapshot()
}
val flat = order.getOrNull(position.toInt()) ?: return snapshot
write(compact(removeEntry(snapshot.entries, flat)).map { it.entry })
val reordered = order.toMutableList().apply { removeAt(position.toInt()) }
for (index in reordered.indices) if (reordered[index] > flat) reordered[index] -= 1
setShuffle(true, reordered.toLongArray())
val play = snapshot.contextPlay(position)
val order = snapshot.shuffleOrder
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
setShuffle(true, reordered.toLongArray())
adjustAnchorOnRemove(flat, removed.sumOf { it.length })
}
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(
snapshot: QueueSnapshot,
from: Long,
to: Long,
): 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
if (order != null) {
val reordered = order.toMutableList()
val moved = reordered.removeAt(from.toInt())
reordered.add(to.coerceIn(0L, reordered.size.toLong()).toInt(), moved)
val moved = reordered.removeAt(playFrom.toInt())
reordered.add(playTo.coerceIn(0L, reordered.size.toLong()).toInt(), moved)
setShuffle(true, reordered.toLongArray())
return true
}
val located = snapshot.locate(from) ?: return false
val located = snapshot.locateContext(playFrom) ?: 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)
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
}
@@ -347,27 +465,109 @@ class QueueRepository(
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> {
val cached = cachedFlatIds
if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.size) return cached
val ids = ArrayList<String>(snapshot.size.toInt())
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))
}
if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.contextSize) return cached
val ids = idsFor(snapshot.entries)
cachedFlatIds = ids
cachedFlatVersion = snapshot.version
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(
entry: QueueEntry,
offset: Long,
@@ -59,6 +59,8 @@ private sealed interface QueueUndo {
val cursor: Long
val current: Long?
val shuffleOrder: LongArray?
val upNext: List<QueueEntry>
val upNextAnchor: Long
}
private data class RemovedUndo(
@@ -68,6 +70,8 @@ private data class RemovedUndo(
val position: Long,
val item: QueueItem?,
override val shuffleOrder: LongArray?,
override val upNext: List<QueueEntry>,
override val upNextAnchor: Long,
) : QueueUndo
private data class MovedUndo(
@@ -77,12 +81,16 @@ private data class MovedUndo(
val from: Long,
val to: Long,
override val shuffleOrder: LongArray?,
override val upNext: List<QueueEntry>,
override val upNextAnchor: Long,
) : QueueUndo
private data class ReloadUndo(
override val entries: List<QueueEntry>,
override val cursor: Long,
override val shuffleOrder: LongArray?,
override val upNext: List<QueueEntry>,
override val upNextAnchor: Long,
) : QueueUndo {
override val current: Long? = null
}
@@ -121,6 +129,7 @@ class PlaybackController(
private var queueSourceId: Long? = null
private var windowStart = 0L
private var windowEnd = -1L
private var lastPosition: Long? = null
private var updating = false
private var lastEdit: QueueUndo? = null
private var shuffleEnabled = false
@@ -234,15 +243,15 @@ class PlaybackController(
val previous = snapshot
val previousEntry = previous?.entries?.firstOrNull()?.entry
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))
var snap = queueRepository.snapshot()
if (snap.size == 0L) {
if (snap.contextSize == 0L) {
stopLocked()
return@withLock
}
val start = randomIndex(snap.size, avoid)
val order = withContext(Dispatchers.Default) { shuffledOrder(snap.size, start) }
val start = randomIndex(snap.contextSize, avoid)
val order = withContext(Dispatchers.Default) { shuffledOrder(snap.contextSize, start) }
queueRepository.setShuffle(true, order)
shuffleEnabled = true
queueSourceId = entry.sourceId
@@ -272,6 +281,7 @@ class PlaybackController(
queueRepository.setCursor(target)
if (target in windowStart..windowEnd) {
player.ensurePrepared()
lastPosition = target
player.seekToIndex((target - windowStart).toInt())
player.play()
shiftWindowLocked(target)
@@ -290,10 +300,11 @@ class PlaybackController(
val player = player ?: return@withLock
val cursor = queueRepository.cursor()
val entries = snapshot.entries.map { it.entry }
val upNext = snapshot.upNext.map { it.entry }
if (snapshot.size == 1L) {
queueRepository.removeAt(snapshot, position)
stopLocked()
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleOrder)
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
return@withLock
}
val current = currentPosition() ?: return@withLock
@@ -305,9 +316,9 @@ class PlaybackController(
windowJob?.cancel()
lastEdit =
if (removed == null) {
ReloadUndo(entries, cursor, snapshot.shuffleOrder)
ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
} else {
RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleOrder)
RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
}
when {
position == current -> {
@@ -347,6 +358,7 @@ class PlaybackController(
val current = currentPosition() ?: return@withLock
val cursor = queueRepository.cursor()
val entries = snapshot.entries.map { it.entry }
val upNext = snapshot.upNext.map { it.entry }
if (!queueRepository.move(snapshot, from, to)) return@withLock
this.snapshot = readSnapshot()
val target = movedCursor(current, from, to)
@@ -355,9 +367,9 @@ class PlaybackController(
val toInWindow = to in windowStart..windowEnd
lastEdit =
if (fromInWindow == toInWindow) {
MovedUndo(entries, cursor, target, from, to, snapshot.shuffleOrder)
MovedUndo(entries, cursor, target, from, to, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
} else {
ReloadUndo(entries, cursor, snapshot.shuffleOrder)
ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
}
when {
fromInWindow && toInWindow -> {
@@ -397,7 +409,7 @@ class PlaybackController(
val player = player ?: return@withLock
val playing = player.playWhenReady
val positionMs = player.currentPositionMs
queueRepository.replace(undo.entries, undo.shuffleOrder)
queueRepository.replace(undo.entries, undo.shuffleOrder, undo.upNext, undo.upNextAnchor)
shuffleEnabled = undo.shuffleOrder != null
val restored = readSnapshot()
this.snapshot = restored
@@ -488,12 +500,33 @@ class PlaybackController(
val snapshot = snapshot ?: return
if (snapshot.size == 0L) return
val current = currentPosition() ?: return
if (snapshot.isUpNext(current)) {
scope.launch { consumeUpNext(current) }
return
}
when {
current < snapshot.size - 1 -> jumpTo(current + 1)
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() {
val player = player ?: return
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() {
val next =
when (repeatMode) {
@@ -543,22 +615,27 @@ class PlaybackController(
if (snapshot.size == 0L) return@withLock
player ?: 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()
val position =
if (shuffleEnabled) {
queueRepository.setShuffle(false, null)
shuffleEnabled = false
flat
} else {
val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.size, flat) }
queueRepository.setShuffle(true, order)
shuffleEnabled = true
0L
}
queueRepository.setCursor(position)
val enabling = !shuffleEnabled
if (enabling) {
val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, flat) }
queueRepository.setShuffle(true, order)
} else {
queueRepository.setShuffle(false, null)
}
shuffleEnabled = enabling
val reordered = readSnapshot()
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)
endedHandled = false
refresh(position)
@@ -618,10 +695,10 @@ class PlaybackController(
stopLocked()
return@withLock
}
val start = startPosition.coerceIn(0, snapshot.size - 1)
val start = startPosition.coerceIn(0, snapshot.contextSize - 1)
val position =
if (shuffleEnabled) {
val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.size, start) }
val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, start) }
queueRepository.setShuffle(true, order)
0L
} else {
@@ -643,9 +720,9 @@ class PlaybackController(
repeatMode = modes.repeat.toRepeatMode()
player?.setRepeatOne(repeatMode == RepeatMode.One)
var snapshot = queueRepository.snapshot()
if (shuffleEnabled && !snapshot.shuffled && snapshot.size > 0L) {
val start = queueRepository.cursor().coerceIn(0, snapshot.size - 1)
val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.size, start) }
if (shuffleEnabled && !snapshot.shuffled && snapshot.contextSize > 0L) {
val start = snapshot.anchorContextPlay(queueRepository.cursor()).coerceIn(0, snapshot.contextSize - 1)
val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, start) }
queueRepository.setShuffle(true, order)
queueRepository.setCursor(0)
snapshot = queueRepository.snapshot()
@@ -680,6 +757,7 @@ class PlaybackController(
queueSourceId = null
windowStart = 0
windowEnd = -1
lastPosition = null
lastEdit = null
player?.run {
stop()
@@ -702,6 +780,7 @@ class PlaybackController(
if (startIndex < 0) return
windowStart = window.first().position
windowEnd = window.last().position
lastPosition = position
updating = true
player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs)
updating = false
@@ -745,6 +824,7 @@ class PlaybackController(
player.seekToIndex((dest - windowStart).toInt())
if (wasEnded) player.play()
endedHandled = false
lastPosition = dest
refresh(dest)
}
}
@@ -755,6 +835,12 @@ class PlaybackController(
val snapshot = snapshot ?: return
if (snapshot.size == 0L) return
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
queueRepository.setCursor(position)
refresh(position)
@@ -782,7 +868,7 @@ class PlaybackController(
if (previous != null && previous.shuffled != snapshot.shuffled) {
val currentId = player.currentItem?.id
val position =
(currentId?.let { queueRepository.flatIndexOf(snapshot, it) } ?: center)
(currentId?.let { queueRepository.combinedIndexOf(snapshot, it) } ?: center)
.coerceIn(0, snapshot.size - 1)
queueRepository.setCursor(position)
rebuildWindow(queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS), position)
@@ -835,6 +921,7 @@ class PlaybackController(
}
windowStart = window.first().position
windowEnd = window.last().position
lastPosition = position
}
private fun currentPosition(): Long? {
@@ -900,7 +987,9 @@ class PlaybackController(
}
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)
}
@@ -12,10 +12,12 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.Person
import androidx.compose.material.icons.rounded.PlayArrow
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.StarBorder
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.unit.dp
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.QueueKind
import com.subtracks.data.source.StarType
import kotlinx.coroutines.launch
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(
val playSong: ((SongModel) -> Unit)? = null,
val playAlbum: (AlbumModel) -> Unit = {},
val shuffleAlbum: (AlbumModel) -> Unit = {},
val playPlaylist: (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 viewAlbum: ((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(
onDismissRequest = { dismiss() },
sheetState = sheetState,
@@ -151,6 +183,7 @@ fun ItemContextMenu(
actions.playSong?.let { play ->
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { play(target.song) } }
}
queueItems()
StarItem(target.song.starred) { starring -> actions.setStar(StarType.Song, target.song.id, starring) }
target.song.albumId?.let { albumId ->
actions.viewAlbum?.let { view ->
@@ -171,6 +204,7 @@ fun ItemContextMenu(
is MenuTarget.Album -> {
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { actions.playAlbum(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) }
target.album.artistId?.let { artistId ->
actions.viewArtist?.let { view ->
@@ -188,6 +222,7 @@ fun ItemContextMenu(
is MenuTarget.Playlist -> {
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { actions.playPlaylist(target.playlist) } }
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shufflePlaylist(target.playlist) } }
queueItems()
}
}
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) },
playAlbum = { viewModel.playAll() },
shuffleAlbum = { viewModel.shuffle() },
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
setStar = setStar,
viewArtist = onViewArtist,
)
@@ -214,6 +214,8 @@ fun LibraryRoute(
shuffleAlbum = { viewModel.shuffleAlbum(it.id) },
playPlaylist = { viewModel.playPlaylist(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,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
@@ -49,6 +49,8 @@ fun PlaylistDetailRoute(
ItemActions(
playPlaylist = { viewModel.playAll() },
shufflePlaylist = { viewModel.shuffle() },
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
setStar = setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
@@ -105,6 +105,8 @@ fun NowPlayingRoute(
val context = LocalPlatformContext.current
val actions =
ItemActions(
playNext = { controller.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { controller.addToQueue(it.sourceId, it.kind, it.refId) },
setStar = setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -75,6 +76,7 @@ data class QueueRow(
val id: Long,
val position: Long,
val song: SongListItem,
val upNext: Boolean = false,
)
class QueueViewModel(
@@ -95,7 +97,7 @@ class QueueViewModel(
private var nextId = 0L
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>> =
rows.groupBy { it.song.song.id }.mapValues { entry -> entry.value.map { it.id }.toMutableList() }
@@ -106,7 +108,7 @@ class QueueViewModel(
): QueueRow {
val ids = reused[item.item.song.id]
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() {
@@ -169,6 +171,7 @@ class QueueViewModel(
) {
if (fromIndex == toIndex) 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))
}
@@ -256,6 +259,12 @@ fun QueueRoute(
sourceRepository: SourceRepository = koinInject(),
) {
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() }
QueueScreen(
rows = viewModel.rows,
@@ -263,6 +272,8 @@ fun QueueRoute(
initialIndex = viewModel.initialIndex,
generation = viewModel.generation,
currentSongId = playback.item?.id,
shuffle = playback.shuffle,
contextTitle = sourceTitle.value,
coverArt = sourceRepository::coverArt,
onBack = onBack,
onPlay = viewModel::play,
@@ -294,6 +305,8 @@ fun QueueScreen(
modifier: Modifier = Modifier,
initialIndex: Int = 0,
generation: Int = 0,
shuffle: Boolean = false,
contextTitle: String? = null,
) {
val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
@@ -360,6 +373,10 @@ fun QueueScreen(
) {
items(count = rows.size, key = { rows[it].id }) { 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(
state = reorderState,
key = row.id,
@@ -376,26 +393,35 @@ fun QueueScreen(
floating = isDragging,
coverArt = coverArt,
dragHandle =
Modifier.draggableHandle(
onDragStarted = {
dragId = row.id
dragFrom = row.position
},
onDragStopped = {
val id = dragId
val from = dragFrom
if (id != null && from != null) {
val index = rows.indexOfFirst { it.id == id }
val to = if (index < 0) from else dropTarget(rows, index, from)
if (to != from) {
onMove(from, to)
showUndo("Queue reordered")
if (!draggable) {
null
} else {
Modifier.draggableHandle(
onDragStarted = {
dragId = row.id
dragFrom = row.position
},
onDragStopped = {
val id = dragId
val from = dragFrom
if (id != null && from != null) {
val index = rows.indexOfFirst { it.id == id }
val to = if (index < 0) from else dropTarget(rows, index, 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)
showUndo("Queue reordered")
}
}
}
dragId = null
dragFrom = null
},
),
dragId = null
dragFrom = null
},
)
},
onClick = { onPlay(row.position) },
onRemove = {
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(
rows: List<QueueRow>,
index: Int,
@@ -428,7 +464,7 @@ private fun QueueRowItem(
isPlaying: Boolean,
floating: Boolean,
coverArt: (String?, Boolean) -> CoverArtRef?,
dragHandle: Modifier,
dragHandle: Modifier?,
onClick: () -> Unit,
onRemove: () -> Unit,
) {
@@ -446,8 +482,10 @@ private fun QueueRowItem(
modifier = Modifier.size(20.dp),
)
}
Box(modifier = Modifier.size(40.dp).then(dragHandle), contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = "Reorder")
if (dragHandle != null) {
Box(modifier = Modifier.size(40.dp).then(dragHandle), contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
},
@@ -372,6 +372,142 @@ class QueueRepositoryTest {
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(
start: Long,
end: Long,
@@ -183,8 +183,10 @@ class FakePlayerHandle : PlayerHandle {
override fun seekToIndex(index: Int) {
operations += "seekToIndex($index)"
val changed = index != this.index
this.index = index
ended = false
if (changed) listeners.forEach { it.onTransition() }
notifyEvents()
}
@@ -192,6 +192,167 @@ class PlaybackControllerTest {
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
fun theDurationUsesTheNextTracksMetadataWhileItLoads() {
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) {
val deadline = System.nanoTime() + 5_000_000_000L
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.
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.
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
+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) 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.
- 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.
- Optional download/caching.