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