add repeat and shuffle modes and fix duplicate track ordering

This commit is contained in:
austinried
2026-10-04 00:29:55 +00:00
parent 5a1915d06b
commit a755de5356
13 changed files with 1227 additions and 46 deletions
@@ -140,7 +140,7 @@ interface LibraryDao {
playlistId: String,
): Flow<Playlist?>
@Query("SELECT * FROM songs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc, track")
@Query("SELECT * FROM songs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc, track, id")
fun songsByAlbum(
sourceId: Long,
albumId: String,
@@ -52,4 +52,13 @@ val MIGRATION_4_5 =
}
}
val MIGRATIONS: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
val MIGRATION_5_6 =
object : Migration(5, 6) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `playback_cursor` ADD COLUMN `shuffleEnabled` INTEGER NOT NULL DEFAULT 0")
connection.execSQL("ALTER TABLE `playback_cursor` ADD COLUMN `repeatMode` INTEGER NOT NULL DEFAULT 0")
connection.execSQL("ALTER TABLE `playback_cursor` ADD COLUMN `shuffleOrder` TEXT")
}
}
val MIGRATIONS: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
@@ -30,7 +30,7 @@ import com.subtracks.data.model.SubsonicSource
PlaybackCursor::class,
ArtworkSeed::class,
],
version = 5,
version = 6,
exportSchema = true,
)
@ColumnTypeConverters(QueueKindConverter::class)
@@ -208,6 +208,9 @@ data class QueueEntry(
data class PlaybackCursor(
@PrimaryKey val id: Long = 1,
val queuePosition: Long,
@ColumnInfo(defaultValue = "0") val shuffleEnabled: Boolean = false,
@ColumnInfo(defaultValue = "0") val repeatMode: Int = 0,
val shuffleOrder: String? = null,
)
@Entity(tableName = "artwork_seeds")
@@ -19,10 +19,23 @@ data class ResolvedQueueEntry(
data class QueueSnapshot(
val entries: List<ResolvedQueueEntry>,
val shuffleOrder: LongArray? = null,
) {
val size: Long = entries.sumOf { it.length }
fun locate(position: Long): Pair<QueueEntry, Long>? {
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())
fun sequencePosition(flat: Long): Long {
val order = shuffleOrder ?: return flat
val index = order.indexOfFirst { it == flat }
return if (index >= 0) index.toLong() else flat
}
private fun locateFlat(position: Long): Pair<QueueEntry, Long>? {
var remaining = position
for (resolved in entries) {
if (remaining < resolved.length) return resolved.entry to remaining
@@ -32,6 +45,11 @@ data class QueueSnapshot(
}
}
data class QueueModes(
val shuffle: Boolean,
val repeat: Int,
)
data class QueueWindowItem(
val position: Long,
val item: SongListItem,
@@ -59,18 +77,46 @@ class QueueRepository(
fun songsEntry(sourceId: Long) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Songs, refId = "")
suspend fun replace(entries: List<QueueEntry>) {
suspend fun replace(
entries: List<QueueEntry>,
shuffleOrder: LongArray? = null,
) {
write(entries)
dao.setCursor(PlaybackCursor(queuePosition = 0))
dao.setCursor(
cursorRow().copy(
queuePosition = 0,
shuffleEnabled = shuffleOrder != null,
shuffleOrder = shuffleOrder?.joinToString(","),
),
)
}
suspend fun snapshot(): QueueSnapshot =
db.useReaderConnection { transactor ->
transactor.deferredTransaction {
QueueSnapshot(dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) })
val entries = dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) }
val row = dao.cursor()
val order =
row?.shuffleOrder?.parseOrder()?.takeIf {
row.shuffleEnabled &&
it.size.toLong() == entries.sumOf { e -> e.length }
}
QueueSnapshot(entries, order)
}
}
suspend fun modes(): QueueModes {
val row = dao.cursor() ?: return QueueModes(shuffle = false, repeat = 0)
return QueueModes(row.shuffleEnabled, row.repeatMode)
}
suspend fun setShuffle(
enabled: Boolean,
order: LongArray?,
) = dao.setCursor(cursorRow().copy(shuffleEnabled = enabled, shuffleOrder = order?.joinToString(",")))
suspend fun setRepeat(mode: Int) = dao.setCursor(cursorRow().copy(repeatMode = mode))
suspend fun itemAt(
snapshot: QueueSnapshot,
position: Long,
@@ -84,6 +130,14 @@ class QueueRepository(
first: Long,
last: Long,
): List<QueueWindowItem> {
if (snapshot.shuffled) {
val items = mutableListOf<QueueWindowItem>()
for (position in first..last) {
val (entry, offset) = snapshot.locate(position) ?: continue
rows(entry, entry.offset + offset, 1).firstOrNull()?.let { items += QueueWindowItem(position, it) }
}
return items
}
val items = mutableListOf<QueueWindowItem>()
var start = 0L
for (resolved in snapshot.entries) {
@@ -112,16 +166,36 @@ class QueueRepository(
suspend fun removeAt(
snapshot: QueueSnapshot,
position: Long,
) = write(removeEntry(snapshot.entries, position).map { it.entry })
): QueueSnapshot {
val order = snapshot.shuffleOrder
if (order == null) {
write(removeEntry(snapshot.entries, position).map { it.entry })
return snapshot()
}
val flat = order.getOrNull(position.toInt()) ?: return snapshot
write(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())
return snapshot()
}
suspend fun move(
snapshot: QueueSnapshot,
from: Long,
to: Long,
): Boolean {
val before = snapshot()
val located = before.locate(from) ?: return false
val song = itemAt(before, from) ?: return false
val removed = removeEntry(before.entries, from)
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)
setShuffle(true, reordered.toLongArray())
return true
}
val located = snapshot.locate(from) ?: return false
val song = itemAt(snapshot, from) ?: return false
val removed = removeEntry(snapshot.entries, from)
val moved = songEntry(located.first.sourceId, song.song.id)
write(insertEntry(removed, to.coerceIn(0, removed.sumOf { it.length }), moved))
return true
@@ -129,7 +203,9 @@ class QueueRepository(
suspend fun cursor(): Long = dao.cursor()?.queuePosition ?: 0
suspend fun setCursor(position: Long) = dao.setCursor(PlaybackCursor(queuePosition = position))
suspend fun setCursor(position: Long) = dao.setCursor(cursorRow().copy(queuePosition = position))
private suspend fun cursorRow(): PlaybackCursor = dao.cursor() ?: PlaybackCursor(queuePosition = 0)
private suspend fun write(entries: List<QueueEntry>) {
db.useWriterConnection { transactor ->
@@ -231,3 +307,10 @@ class QueueRepository(
return result
}
}
private fun String.parseOrder(): LongArray? =
if (isEmpty()) {
null
} else {
split(',').mapNotNull { it.toLongOrNull() }.toLongArray().takeIf { it.isNotEmpty() }
}
@@ -86,6 +86,10 @@ class Media3PlayerHandle(
controller.seekTo(positionMs)
}
override fun setRepeatOne(enabled: Boolean) {
controller.repeatMode = if (enabled) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
}
override fun addListener(listener: PlayerHandle.Listener) {
controller.addListener(
object : Player.Listener {
@@ -33,6 +33,8 @@ data class QueueContext(
val refId: String,
)
enum class RepeatMode { Off, All, One }
data class PlaybackState(
val item: QueueItem? = null,
val context: QueueContext? = null,
@@ -44,12 +46,15 @@ data class PlaybackState(
val durationMs: Long = 0,
val hasNext: Boolean = false,
val hasPrevious: Boolean = false,
val shuffle: Boolean = false,
val repeat: RepeatMode = RepeatMode.Off,
)
private sealed interface QueueUndo {
val entries: List<QueueEntry>
val cursor: Long
val current: Long?
val shuffleOrder: LongArray?
}
private data class RemovedUndo(
@@ -58,6 +63,7 @@ private data class RemovedUndo(
override val current: Long,
val position: Long,
val item: QueueItem?,
override val shuffleOrder: LongArray?,
) : QueueUndo
private data class MovedUndo(
@@ -66,11 +72,13 @@ private data class MovedUndo(
override val current: Long,
val from: Long,
val to: Long,
override val shuffleOrder: LongArray?,
) : QueueUndo
private data class ReloadUndo(
override val entries: List<QueueEntry>,
override val cursor: Long,
override val shuffleOrder: LongArray?,
) : QueueUndo {
override val current: Long? = null
}
@@ -102,6 +110,9 @@ class PlaybackController(
private var windowEnd = -1L
private var updating = false
private var lastEdit: QueueUndo? = null
private var shuffleEnabled = false
private var repeatMode = RepeatMode.Off
private var endedHandled = false
init {
scope.launch {
@@ -186,7 +197,7 @@ class PlaybackController(
if (snapshot.size == 1L) {
queueRepository.removeAt(snapshot, position)
stopLocked()
lastEdit = ReloadUndo(entries, cursor)
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleOrder)
return@withLock
}
val current = currentPosition() ?: return@withLock
@@ -198,9 +209,9 @@ class PlaybackController(
windowJob?.cancel()
lastEdit =
if (removed == null) {
ReloadUndo(entries, cursor)
ReloadUndo(entries, cursor, snapshot.shuffleOrder)
} else {
RemovedUndo(entries, cursor, target, position, removed)
RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleOrder)
}
when {
position == current -> {
@@ -240,7 +251,7 @@ class PlaybackController(
val current = currentPosition() ?: return@withLock
val cursor = queueRepository.cursor()
val entries = snapshot.entries.map { it.entry }
if (!queueRepository.move(from, to)) return@withLock
if (!queueRepository.move(snapshot, from, to)) return@withLock
this.snapshot = queueRepository.snapshot()
val target = movedCursor(current, from, to)
windowJob?.cancel()
@@ -248,9 +259,9 @@ class PlaybackController(
val toInWindow = to in windowStart..windowEnd
lastEdit =
if (fromInWindow == toInWindow) {
MovedUndo(entries, cursor, target, from, to)
MovedUndo(entries, cursor, target, from, to, snapshot.shuffleOrder)
} else {
ReloadUndo(entries, cursor)
ReloadUndo(entries, cursor, snapshot.shuffleOrder)
}
when {
fromInWindow && toInWindow -> {
@@ -290,7 +301,8 @@ class PlaybackController(
val player = player ?: return@withLock
val playing = player.playWhenReady
val positionMs = player.currentPositionMs
queueRepository.replace(undo.entries)
queueRepository.replace(undo.entries, undo.shuffleOrder)
shuffleEnabled = undo.shuffleOrder != null
val restored = queueRepository.snapshot()
this.snapshot = restored
lastEdit = null
@@ -371,9 +383,71 @@ class PlaybackController(
if (player.playWhenReady) player.pause() else player.play()
}
fun next() = skip(1)
fun next() {
val snapshot = snapshot ?: return
if (snapshot.size == 0L) return
val current = currentPosition() ?: return
when {
current < snapshot.size - 1 -> jumpTo(current + 1)
repeatMode != RepeatMode.Off -> jumpTo(0)
}
}
fun previous() = skip(-1)
fun previous() {
val player = player ?: return
if (player.currentPositionMs > RESTART_THRESHOLD_MS) {
player.seekTo(0)
refresh()
return
}
val snapshot = snapshot ?: return
if (snapshot.size == 0L) return
val current = currentPosition() ?: return
jumpTo(if (current > 0) current - 1 else snapshot.size - 1)
}
fun cycleRepeat() {
val next =
when (repeatMode) {
RepeatMode.Off -> RepeatMode.All
RepeatMode.All -> RepeatMode.One
RepeatMode.One -> RepeatMode.Off
}
repeatMode = next
player?.setRepeatOne(next == RepeatMode.One)
scope.launch { queueRepository.setRepeat(next.ordinal) }
refresh()
}
fun toggleShuffle() {
scope.launch {
startLock.withLock {
val snapshot = queueRepository.snapshot()
this@PlaybackController.snapshot = snapshot
if (snapshot.size == 0L) return@withLock
val player = player ?: return@withLock
val current = currentPosition() ?: return@withLock
val positionMs = player.currentPositionMs
val playing = player.playWhenReady
val flat = snapshot.flatPosition(current) ?: return@withLock
if (shuffleEnabled) {
queueRepository.setShuffle(false, null)
shuffleEnabled = false
queueRepository.setCursor(flat)
this@PlaybackController.snapshot = queueRepository.snapshot()
loadWindow(flat, autoplay = playing, startPositionMs = positionMs)
} else {
queueRepository.setShuffle(true, shuffledOrder(snapshot.size, flat))
shuffleEnabled = true
queueRepository.setCursor(0)
this@PlaybackController.snapshot = queueRepository.snapshot()
loadWindow(0, autoplay = playing, startPositionMs = positionMs)
}
endedHandled = false
refresh(if (shuffleEnabled) 0L else flat)
}
}
}
fun seekTo(positionMs: Long) {
player?.seekTo(positionMs)
@@ -413,6 +487,11 @@ class PlaybackController(
lastDurationMs = 0
lastError = null
lastEdit = null
endedHandled = false
val modes = queueRepository.modes()
shuffleEnabled = modes.shuffle
repeatMode = modes.repeat.toRepeatMode()
player?.setRepeatOne(repeatMode == RepeatMode.One)
queueRepository.replace(entries)
val snapshot = queueRepository.snapshot()
this.snapshot = snapshot
@@ -421,7 +500,14 @@ class PlaybackController(
stopLocked()
return@withLock
}
val position = startPosition.coerceIn(0, snapshot.size - 1)
val start = startPosition.coerceIn(0, snapshot.size - 1)
val position =
if (shuffleEnabled) {
queueRepository.setShuffle(true, shuffledOrder(snapshot.size, start))
0L
} else {
start
}
queueRepository.setCursor(position)
loadWindow(position, autoplay = true)
}
@@ -431,6 +517,11 @@ class PlaybackController(
windowJob?.cancel()
windowStart = 0
windowEnd = -1
endedHandled = false
val modes = queueRepository.modes()
shuffleEnabled = modes.shuffle
repeatMode = modes.repeat.toRepeatMode()
player?.setRepeatOne(repeatMode == RepeatMode.One)
val snapshot = queueRepository.snapshot()
this.snapshot = snapshot
val queueSourceId =
@@ -492,22 +583,24 @@ class PlaybackController(
refresh(position)
}
private fun skip(delta: Long) {
private fun jumpTo(target: Long) {
scope.launch {
val current = currentPosition() ?: return@launch
val snapshot = queueRepository.snapshot()
this@PlaybackController.snapshot = snapshot
if (snapshot.size == 0L) return@launch
val position = current.coerceIn(0, snapshot.size - 1)
val target = (position + delta).coerceIn(0, snapshot.size - 1)
if (target == position) return@launch
val current = currentPosition() ?: return@launch
val dest = target.coerceIn(0, snapshot.size - 1)
if (dest == current) return@launch
windowJob?.cancel()
queueRepository.setCursor(target)
shiftWindow(target)
queueRepository.setCursor(dest)
shiftWindow(dest)
val player = player ?: return@launch
val wasEnded = player.isEnded
player.ensurePrepared()
player.seekToIndex((target - windowStart).toInt())
refresh(target)
player.seekToIndex((dest - windowStart).toInt())
if (wasEnded) player.play()
endedHandled = false
refresh(dest)
}
}
@@ -588,11 +681,15 @@ class PlaybackController(
object : PlayerHandle.Listener {
override fun onTransition() {
lastError = null
endedHandled = false
if (updating) return
scope.launch { onPositionChanged() }
}
override fun onEvents() = refresh()
override fun onEvents() {
if (player?.isEnded == true) handleEnded()
refresh()
}
override fun onError(message: String) {
lastError = message
@@ -600,6 +697,22 @@ class PlaybackController(
}
}
private fun handleEnded() {
if (endedHandled) return
endedHandled = true
if (repeatMode == RepeatMode.All) jumpTo(0)
}
private fun shuffledOrder(
size: Long,
first: Long,
): LongArray {
val rest = (0 until size).filter { it != first }.shuffled()
return (listOf(first) + rest).toLongArray()
}
private fun Int.toRepeatMode(): RepeatMode = RepeatMode.entries.getOrElse(this) { RepeatMode.Off }
private fun contextAt(position: Long?): QueueContext? {
val entry = position?.let { snapshot?.locate(it)?.first } ?: return null
return QueueContext(entry.kind, entry.refId)
@@ -626,7 +739,6 @@ class PlaybackController(
private fun refresh(position: Long? = currentPosition()) {
val player = player ?: return
updateBuffering()
val size = snapshot?.size ?: 0
lastDurationMs = player.durationMs.takeIf { it > 0 } ?: lastDurationMs
_state.value =
PlaybackState(
@@ -638,8 +750,10 @@ class PlaybackController(
isPlaying = player.playWhenReady && !player.isIdle && !player.isEnded,
positionMs = player.currentPositionMs,
durationMs = lastDurationMs,
hasNext = position != null && position < size - 1,
hasPrevious = position != null && position > 0,
hasNext = true,
hasPrevious = true,
shuffle = shuffleEnabled,
repeat = repeatMode,
)
if (player.isPlaying) startPositionTicker() else stopPositionTicker()
}
@@ -670,5 +784,6 @@ class PlaybackController(
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
const val QUEUE_WINDOW_RADIUS = 25L
const val WINDOW_SHIFT_DELAY_MS = 400L
const val RESTART_THRESHOLD_MS = 3_000L
}
}
@@ -52,6 +52,8 @@ interface PlayerHandle {
fun seekTo(positionMs: Long)
fun setRepeatOne(enabled: Boolean)
fun addListener(listener: Listener)
interface Listener {
@@ -20,6 +20,9 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
import androidx.compose.material.icons.rounded.PauseCircle
import androidx.compose.material.icons.rounded.PlayCircle
import androidx.compose.material.icons.rounded.Repeat
import androidx.compose.material.icons.rounded.RepeatOne
import androidx.compose.material.icons.rounded.Shuffle
import androidx.compose.material.icons.rounded.SkipNext
import androidx.compose.material.icons.rounded.SkipPrevious
import androidx.compose.material3.CircularProgressIndicator
@@ -57,6 +60,7 @@ import coil3.request.ImageRequest
import com.subtracks.data.model.CoverArtRef
import com.subtracks.playback.PlaybackController
import com.subtracks.playback.PlaybackState
import com.subtracks.playback.RepeatMode
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkSeedCache
@@ -91,6 +95,8 @@ fun NowPlayingRoute(
onPlayPause = controller::togglePlayPause,
onNext = controller::next,
onPrevious = controller::previous,
onShuffle = controller::toggleShuffle,
onRepeat = controller::cycleRepeat,
onSeek = controller::seekTo,
modifier = modifier,
)
@@ -121,6 +127,8 @@ fun NowPlayingScreen(
onPlayPause: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onShuffle: () -> Unit = {},
onRepeat: () -> Unit = {},
onSeek: (Long) -> Unit,
thumbnailRef: CoverArtRef? = null,
modifier: Modifier = Modifier,
@@ -315,7 +323,8 @@ fun NowPlayingScreen(
}
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 56.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
IconButton(onClick = onQueue, modifier = Modifier.size(40.dp)) {
Icon(
@@ -324,6 +333,41 @@ fun NowPlayingScreen(
modifier = Modifier.size(30.dp),
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onShuffle, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = Icons.Rounded.Shuffle,
contentDescription = "Shuffle",
tint =
if (state.shuffle) {
MaterialTheme.colorScheme.onBackground
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(26.dp),
)
}
IconButton(onClick = onRepeat, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = if (state.repeat == RepeatMode.One) Icons.Rounded.RepeatOne else Icons.Rounded.Repeat,
contentDescription =
when (state.repeat) {
RepeatMode.Off -> "Repeat off"
RepeatMode.All -> "Repeat all"
RepeatMode.One -> "Repeat one"
},
tint =
if (state.repeat ==
RepeatMode.Off
) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onBackground
},
modifier = Modifier.size(26.dp),
)
}
}
}
}
}