diff --git a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt index 4ac7e591..ec56624f 100644 --- a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt @@ -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) diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index ab7f7a61..d0a142a5 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -46,10 +46,29 @@ data class PlaybackState( val hasPrevious: Boolean = false, ) -private data class QueueUndo( - val entries: List, - val cursor: Long, -) +private sealed interface QueueUndo { + val entries: List + val cursor: Long +} + +private data class RemovedUndo( + override val entries: List, + override val cursor: Long, + val position: Long, + val item: QueueItem?, +) : QueueUndo + +private data class MovedUndo( + override val entries: List, + override val cursor: Long, + val from: Long, + val to: Long, +) : QueueUndo + +private data class ReloadUndo( + override val entries: List, + 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) } diff --git a/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt index 8c2209ee..38986474 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt @@ -22,6 +22,11 @@ interface PlayerHandle { fun addLast(item: QueueItem) + fun insertAt( + index: Int, + item: QueueItem, + ) + fun removeFirst() fun removeLast() diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt index ed9969a9..d94fe0c8 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt @@ -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(null) } var dragOffset by remember { mutableFloatStateOf(0f) } + var target by remember { mutableStateOf(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, + 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), ) } diff --git a/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt b/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt index 49d65c71..4efc746a 100644 --- a/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt +++ b/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt @@ -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)