keep playback going through undo and polish the queue drag

This commit is contained in:
austinried
2026-10-04 00:29:51 +00:00
parent 7fc142ce6f
commit dde78bb9fb
5 changed files with 235 additions and 58 deletions
@@ -46,6 +46,11 @@ class Media3PlayerHandle(
override fun addLast(item: QueueItem) = controller.addMediaItems(controller.mediaItemCount, listOf(toMediaItem(item))) override fun addLast(item: QueueItem) = controller.addMediaItems(controller.mediaItemCount, listOf(toMediaItem(item)))
override fun insertAt(
index: Int,
item: QueueItem,
) = controller.addMediaItems(index, listOf(toMediaItem(item)))
override fun removeFirst() = controller.removeMediaItem(0) override fun removeFirst() = controller.removeMediaItem(0)
override fun removeLast() = controller.removeMediaItem(controller.mediaItemCount - 1) override fun removeLast() = controller.removeMediaItem(controller.mediaItemCount - 1)
@@ -46,10 +46,29 @@ data class PlaybackState(
val hasPrevious: Boolean = false, val hasPrevious: Boolean = false,
) )
private data class QueueUndo( private sealed interface QueueUndo {
val entries: List<QueueEntry>, val entries: List<QueueEntry>
val cursor: Long, val cursor: Long
) }
private data class RemovedUndo(
override val entries: List<QueueEntry>,
override val cursor: Long,
val position: Long,
val item: QueueItem?,
) : QueueUndo
private data class MovedUndo(
override val entries: List<QueueEntry>,
override val cursor: Long,
val from: Long,
val to: Long,
) : QueueUndo
private data class ReloadUndo(
override val entries: List<QueueEntry>,
override val cursor: Long,
) : QueueUndo
class PlaybackController( class PlaybackController(
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
@@ -157,20 +176,27 @@ class PlaybackController(
val snapshot = snapshot ?: return@withLock val snapshot = snapshot ?: return@withLock
if (position !in 0 until snapshot.size) return@withLock if (position !in 0 until snapshot.size) return@withLock
val player = player ?: return@withLock val player = player ?: return@withLock
val undo = QueueUndo(snapshot.entries.map { it.entry }, queueRepository.cursor()) val cursor = queueRepository.cursor()
val entries = snapshot.entries.map { it.entry }
if (snapshot.size == 1L) { if (snapshot.size == 1L) {
queueRepository.removeAt(snapshot, position) queueRepository.removeAt(snapshot, position)
stopLocked() stopLocked()
lastEdit = undo lastEdit = ReloadUndo(entries, cursor)
return@withLock return@withLock
} }
val current = currentPosition() ?: return@withLock val current = currentPosition() ?: return@withLock
val target = (if (position < current) current - 1 else current).coerceIn(0, snapshot.size - 2) val target = (if (position < current) current - 1 else current).coerceIn(0, snapshot.size - 2)
val playing = player.playWhenReady val playing = player.playWhenReady
val removed = if (position == current) null else queueRepository.itemAt(snapshot, position)?.toQueueItem()
queueRepository.removeAt(snapshot, position) queueRepository.removeAt(snapshot, position)
this.snapshot = queueRepository.snapshot() this.snapshot = queueRepository.snapshot()
windowJob?.cancel() windowJob?.cancel()
lastEdit = undo lastEdit =
if (removed == null) {
ReloadUndo(entries, cursor)
} else {
RemovedUndo(entries, cursor, position, removed)
}
when { when {
position == current -> { position == current -> {
loadWindow(target, autoplay = playing, startPositionMs = 0) loadWindow(target, autoplay = playing, startPositionMs = 0)
@@ -207,13 +233,20 @@ class PlaybackController(
if (from !in 0 until snapshot.size || to !in 0 until snapshot.size) return@withLock if (from !in 0 until snapshot.size || to !in 0 until snapshot.size) return@withLock
val player = player ?: return@withLock val player = player ?: return@withLock
val current = currentPosition() ?: return@withLock val current = currentPosition() ?: return@withLock
val undo = QueueUndo(snapshot.entries.map { it.entry }, queueRepository.cursor()) val cursor = queueRepository.cursor()
val entries = snapshot.entries.map { it.entry }
if (!queueRepository.move(from, to)) return@withLock if (!queueRepository.move(from, to)) return@withLock
this.snapshot = queueRepository.snapshot() this.snapshot = queueRepository.snapshot()
val target = movedCursor(current, from, to) val target = movedCursor(current, from, to)
windowJob?.cancel() windowJob?.cancel()
lastEdit = undo val inWindow = from in windowStart..windowEnd && to in windowStart..windowEnd
if (from in windowStart..windowEnd && to in windowStart..windowEnd) { lastEdit =
if (inWindow) {
MovedUndo(entries, cursor, from, to)
} else {
ReloadUndo(entries, cursor)
}
if (inWindow) {
updating = true updating = true
player.move((from - windowStart).toInt(), (to - windowStart).toInt()) player.move((from - windowStart).toInt(), (to - windowStart).toInt())
updating = false updating = false
@@ -230,7 +263,6 @@ class PlaybackController(
val undo = lastEdit ?: return@withLock val undo = lastEdit ?: return@withLock
val player = player ?: return@withLock val player = player ?: return@withLock
val playing = player.playWhenReady val playing = player.playWhenReady
val playingId = player.currentItem?.id
val positionMs = player.currentPositionMs val positionMs = player.currentPositionMs
queueRepository.replace(undo.entries) queueRepository.replace(undo.entries)
val restored = queueRepository.snapshot() val restored = queueRepository.snapshot()
@@ -241,9 +273,44 @@ class PlaybackController(
return@withLock return@withLock
} }
val target = undo.cursor.coerceIn(0, restored.size - 1) val target = undo.cursor.coerceIn(0, restored.size - 1)
val restoredId = queueRepository.itemAt(restored, target)?.song?.id
windowJob?.cancel() windowJob?.cancel()
val same = queueRepository.itemAt(restored, target)?.song?.id == playingId val canMirror = player.currentItem?.id != null && player.currentItem?.id == restoredId
loadWindow(target, autoplay = playing, startPositionMs = if (same) positionMs else 0) val mirrored =
when {
!canMirror -> {
false
}
undo is RemovedUndo && undo.item != null -> {
if (undo.position < windowStart) {
windowStart++
windowEnd++
} else if (undo.position <= windowEnd) {
updating = true
player.insertAt((undo.position - windowStart).toInt(), undo.item)
updating = false
windowEnd++
}
true
}
undo is MovedUndo && undo.from in windowStart..windowEnd && undo.to in windowStart..windowEnd -> {
updating = true
player.move((undo.to - windowStart).toInt(), (undo.from - windowStart).toInt())
updating = false
true
}
else -> {
false
}
}
if (mirrored) {
shiftWindowLocked(target)
} else {
loadWindow(target, autoplay = playing, startPositionMs = if (canMirror) positionMs else 0)
}
queueRepository.setCursor(target) queueRepository.setCursor(target)
refresh(target) refresh(target)
} }
@@ -22,6 +22,11 @@ interface PlayerHandle {
fun addLast(item: QueueItem) fun addLast(item: QueueItem)
fun insertAt(
index: Int,
item: QueueItem,
)
fun removeFirst() fun removeFirst()
fun removeLast() fun removeLast()
@@ -1,11 +1,16 @@
package com.subtracks.ui.playback package com.subtracks.ui.playback
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -17,6 +22,7 @@ import androidx.compose.material.icons.rounded.DragHandle
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
@@ -34,9 +40,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -58,6 +66,7 @@ import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.library.SongRow import com.subtracks.ui.library.SongRow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
@@ -127,6 +136,8 @@ fun QueueRoute(
) )
} }
private enum class DropIndicator { Above, Below }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun QueueScreen( fun QueueScreen(
@@ -143,9 +154,11 @@ fun QueueScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val density = LocalDensity.current
var rowHeight by remember { mutableFloatStateOf(0f) } var rowHeight by remember { mutableFloatStateOf(0f) }
var dragging by remember { mutableStateOf<Long?>(null) } var dragging by remember { mutableStateOf<Long?>(null) }
var dragOffset by remember { mutableFloatStateOf(0f) } var dragOffset by remember { mutableFloatStateOf(0f) }
var target by remember { mutableStateOf<Long?>(null) }
var centered by remember { mutableStateOf(false) } var centered by remember { mutableStateOf(false) }
val firstPosition = if (items.itemCount > 0) items.peek(0)?.position else null val firstPosition = if (items.itemCount > 0) items.peek(0)?.position else null
@@ -159,6 +172,30 @@ fun QueueScreen(
} }
} }
LaunchedEffect(dragging) {
if (dragging == null) return@LaunchedEffect
val threshold = with(density) { 72.dp.toPx() }
val step = with(density) { 10.dp.toPx() }
while (true) {
val from = dragging ?: break
val info = listState.layoutInfo.visibleItemsInfo.firstOrNull { items.peek(it.index)?.position == from }
if (info != null) {
val edge =
when {
info.offset < listState.layoutInfo.viewportStartOffset + threshold -> -step
info.offset + info.size > listState.layoutInfo.viewportEndOffset - threshold -> step
else -> 0f
}
if (edge != 0f) {
val consumed = listState.scrollBy(edge)
dragOffset += consumed
target = dropTarget(items, from, dragOffset, rowHeight)
}
}
delay(16)
}
}
val showUndo: (String) -> Unit = { message -> val showUndo: (String) -> Unit = { message ->
scope.launch { scope.launch {
snackbarHostState.currentSnackbarData?.dismiss() snackbarHostState.currentSnackbarData?.dismiss()
@@ -205,6 +242,7 @@ fun QueueScreen(
coverArt = coverArt, coverArt = coverArt,
dragging = dragging == item.position, dragging = dragging == item.position,
dragOffset = if (dragging == item.position) dragOffset else 0f, dragOffset = if (dragging == item.position) dragOffset else 0f,
indicator = indicatorFor(item.position, dragging, target),
onClick = { onPlay(item.position) }, onClick = { onPlay(item.position) },
onRemove = { onRemove = {
onRemove(item.position) onRemove(item.position)
@@ -214,23 +252,27 @@ fun QueueScreen(
onDragStart = { onDragStart = {
dragging = item.position dragging = item.position
dragOffset = 0f dragOffset = 0f
target = item.position
},
onDrag = { amount ->
dragOffset += amount
target = dropTarget(items, item.position, dragOffset, rowHeight)
}, },
onDrag = { dragOffset += it },
onDragEnd = { onDragEnd = {
val from = dragging val from = dragging
if (from != null && rowHeight > 0f) { val to = target
val to = (from + (dragOffset / rowHeight).roundToInt()).coerceAtLeast(0) if (from != null && to != null && to != from) {
if (to != from) { onMove(from, to)
onMove(from, to) showUndo("Queue reordered")
showUndo("Queue reordered")
}
} }
dragging = null dragging = null
dragOffset = 0f dragOffset = 0f
target = null
}, },
onDragCancel = { onDragCancel = {
dragging = null dragging = null
dragOffset = 0f dragOffset = 0f
target = null
}, },
) )
} }
@@ -241,6 +283,30 @@ fun QueueScreen(
} }
} }
private fun dropTarget(
items: LazyPagingItems<QueueWindowItem>,
from: Long,
offset: Float,
rowHeight: Float,
): Long {
if (items.itemCount == 0 || rowHeight <= 0f) return from
val first = items.peek(0)?.position ?: from
val last = items.peek(items.itemCount - 1)?.position ?: from
return (from + (offset / rowHeight).roundToInt()).coerceIn(minOf(first, last), maxOf(first, last))
}
private fun indicatorFor(
position: Long,
dragging: Long?,
target: Long?,
): DropIndicator? =
when {
dragging == null || target == null || position != target -> null
target > dragging -> DropIndicator.Below
target < dragging -> DropIndicator.Above
else -> null
}
@Composable @Composable
private fun QueueRow( private fun QueueRow(
item: QueueWindowItem, item: QueueWindowItem,
@@ -248,6 +314,7 @@ private fun QueueRow(
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
dragging: Boolean, dragging: Boolean,
dragOffset: Float, dragOffset: Float,
indicator: DropIndicator?,
onClick: () -> Unit, onClick: () -> Unit,
onRemove: () -> Unit, onRemove: () -> Unit,
onSize: (Int) -> Unit, onSize: (Int) -> Unit,
@@ -256,46 +323,70 @@ private fun QueueRow(
onDragEnd: () -> Unit, onDragEnd: () -> Unit,
onDragCancel: () -> Unit, onDragCancel: () -> Unit,
) { ) {
SongRow( Column(
song = item.item.song,
coverArtId = item.item.coverArt,
coverArt = coverArt,
isPlaying = isPlaying,
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onRemove) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Remove from queue",
modifier = Modifier.size(20.dp),
)
}
Box(
modifier =
Modifier
.size(40.dp)
.pointerInput(item.position) {
detectDragGestures(
onDragStart = { onDragStart() },
onDrag = { change, amount ->
change.consume()
onDrag(amount.y)
},
onDragEnd = onDragEnd,
onDragCancel = onDragCancel,
)
},
contentAlignment = Alignment.Center,
) {
Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
},
modifier = modifier =
Modifier Modifier
.zIndex(if (dragging) 1f else 0f) .zIndex(if (dragging) 1f else 0f)
.graphicsLayer { translationY = dragOffset } .graphicsLayer { translationY = dragOffset },
.onSizeChanged { size -> onSize(size.height) } ) {
.clickable(onClick = onClick), if (indicator == DropIndicator.Above) DropIndicatorLine()
SongRow(
song = item.item.song,
coverArtId = item.item.coverArt,
coverArt = coverArt,
isPlaying = isPlaying,
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onRemove) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Remove from queue",
modifier = Modifier.size(20.dp),
)
}
Box(
modifier =
Modifier
.size(40.dp)
.pointerInput(item.position) {
detectDragGestures(
onDragStart = { onDragStart() },
onDrag = { change, amount ->
change.consume()
onDrag(amount.y)
},
onDragEnd = onDragEnd,
onDragCancel = onDragCancel,
)
},
contentAlignment = Alignment.Center,
) {
Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
},
modifier =
Modifier
.background(
if (dragging) {
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.95f)
} else {
Color.Transparent
},
).onSizeChanged { size -> onSize(size.height) }
.clickable(onClick = onClick),
)
if (indicator == DropIndicator.Below) DropIndicatorLine()
}
}
@Composable
private fun DropIndicatorLine() {
Box(
modifier =
Modifier
.fillMaxWidth()
.height(2.dp)
.background(MaterialTheme.colorScheme.primary),
) )
} }
@@ -86,6 +86,15 @@ class FakePlayerHandle : PlayerHandle {
items += item items += item
} }
override fun insertAt(
index: Int,
item: QueueItem,
) {
operations += "insertAt($index, ${item.id})"
items.add(index, item)
if (index <= this.index) this.index++
}
override fun removeFirst() { override fun removeFirst() {
operations += "removeFirst" operations += "removeFirst"
items.removeAt(0) items.removeAt(0)