mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
+1129
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user