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 insertAt(
index: Int,
item: QueueItem,
) = controller.addMediaItems(index, listOf(toMediaItem(item)))
override fun removeFirst() = controller.removeMediaItem(0)
override fun removeLast() = controller.removeMediaItem(controller.mediaItemCount - 1)
@@ -46,10 +46,29 @@ data class PlaybackState(
val hasPrevious: Boolean = false,
)
private data class QueueUndo(
val entries: List<QueueEntry>,
val cursor: Long,
)
private sealed interface QueueUndo {
val entries: List<QueueEntry>
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(
private val sourceRepository: SourceRepository,
@@ -157,20 +176,27 @@ class PlaybackController(
val snapshot = snapshot ?: return@withLock
if (position !in 0 until snapshot.size) 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) {
queueRepository.removeAt(snapshot, position)
stopLocked()
lastEdit = undo
lastEdit = ReloadUndo(entries, cursor)
return@withLock
}
val current = currentPosition() ?: return@withLock
val target = (if (position < current) current - 1 else current).coerceIn(0, snapshot.size - 2)
val playing = player.playWhenReady
val removed = if (position == current) null else queueRepository.itemAt(snapshot, position)?.toQueueItem()
queueRepository.removeAt(snapshot, position)
this.snapshot = queueRepository.snapshot()
windowJob?.cancel()
lastEdit = undo
lastEdit =
if (removed == null) {
ReloadUndo(entries, cursor)
} else {
RemovedUndo(entries, cursor, position, removed)
}
when {
position == current -> {
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
val player = player ?: 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
this.snapshot = queueRepository.snapshot()
val target = movedCursor(current, from, to)
windowJob?.cancel()
lastEdit = undo
if (from in windowStart..windowEnd && to in windowStart..windowEnd) {
val inWindow = from in windowStart..windowEnd && to in windowStart..windowEnd
lastEdit =
if (inWindow) {
MovedUndo(entries, cursor, from, to)
} else {
ReloadUndo(entries, cursor)
}
if (inWindow) {
updating = true
player.move((from - windowStart).toInt(), (to - windowStart).toInt())
updating = false
@@ -230,7 +263,6 @@ class PlaybackController(
val undo = lastEdit ?: return@withLock
val player = player ?: return@withLock
val playing = player.playWhenReady
val playingId = player.currentItem?.id
val positionMs = player.currentPositionMs
queueRepository.replace(undo.entries)
val restored = queueRepository.snapshot()
@@ -241,9 +273,44 @@ class PlaybackController(
return@withLock
}
val target = undo.cursor.coerceIn(0, restored.size - 1)
val restoredId = queueRepository.itemAt(restored, target)?.song?.id
windowJob?.cancel()
val same = queueRepository.itemAt(restored, target)?.song?.id == playingId
loadWindow(target, autoplay = playing, startPositionMs = if (same) positionMs else 0)
val canMirror = player.currentItem?.id != null && player.currentItem?.id == restoredId
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)
refresh(target)
}
@@ -22,6 +22,11 @@ interface PlayerHandle {
fun addLast(item: QueueItem)
fun insertAt(
index: Int,
item: QueueItem,
)
fun removeFirst()
fun removeLast()
@@ -1,11 +1,16 @@
package com.subtracks.ui.playback
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
@@ -34,9 +40,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.ViewModel
@@ -58,6 +66,7 @@ import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.library.SongRow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
@@ -127,6 +136,8 @@ fun QueueRoute(
)
}
private enum class DropIndicator { Above, Below }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun QueueScreen(
@@ -143,9 +154,11 @@ fun QueueScreen(
val listState = rememberLazyListState()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val density = LocalDensity.current
var rowHeight by remember { mutableFloatStateOf(0f) }
var dragging by remember { mutableStateOf<Long?>(null) }
var dragOffset by remember { mutableFloatStateOf(0f) }
var target by remember { mutableStateOf<Long?>(null) }
var centered by remember { mutableStateOf(false) }
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 ->
scope.launch {
snackbarHostState.currentSnackbarData?.dismiss()
@@ -205,6 +242,7 @@ fun QueueScreen(
coverArt = coverArt,
dragging = dragging == item.position,
dragOffset = if (dragging == item.position) dragOffset else 0f,
indicator = indicatorFor(item.position, dragging, target),
onClick = { onPlay(item.position) },
onRemove = {
onRemove(item.position)
@@ -214,23 +252,27 @@ fun QueueScreen(
onDragStart = {
dragging = item.position
dragOffset = 0f
target = item.position
},
onDrag = { amount ->
dragOffset += amount
target = dropTarget(items, item.position, dragOffset, rowHeight)
},
onDrag = { dragOffset += it },
onDragEnd = {
val from = dragging
if (from != null && rowHeight > 0f) {
val to = (from + (dragOffset / rowHeight).roundToInt()).coerceAtLeast(0)
if (to != from) {
onMove(from, to)
showUndo("Queue reordered")
}
val to = target
if (from != null && to != null && to != from) {
onMove(from, to)
showUndo("Queue reordered")
}
dragging = null
dragOffset = 0f
target = null
},
onDragCancel = {
dragging = null
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
private fun QueueRow(
item: QueueWindowItem,
@@ -248,6 +314,7 @@ private fun QueueRow(
coverArt: (String?, Boolean) -> CoverArtRef?,
dragging: Boolean,
dragOffset: Float,
indicator: DropIndicator?,
onClick: () -> Unit,
onRemove: () -> Unit,
onSize: (Int) -> Unit,
@@ -256,46 +323,70 @@ private fun QueueRow(
onDragEnd: () -> Unit,
onDragCancel: () -> Unit,
) {
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")
}
}
},
Column(
modifier =
Modifier
.zIndex(if (dragging) 1f else 0f)
.graphicsLayer { translationY = dragOffset }
.onSizeChanged { size -> onSize(size.height) }
.clickable(onClick = onClick),
.graphicsLayer { translationY = dragOffset },
) {
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
}
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() {
operations += "removeFirst"
items.removeAt(0)