mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
track the finger position while dragging queue rows
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
package com.subtracks.ui.playback
|
package com.subtracks.ui.playback
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animate
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
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.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
|
||||||
@@ -14,6 +15,7 @@ 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
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
@@ -38,6 +40,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
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.Color
|
||||||
@@ -68,10 +71,12 @@ 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.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.floor
|
||||||
|
|
||||||
class QueueViewModel(
|
class QueueViewModel(
|
||||||
private val queueRepository: QueueRepository,
|
private val queueRepository: QueueRepository,
|
||||||
@@ -138,6 +143,13 @@ fun QueueRoute(
|
|||||||
|
|
||||||
private enum class DropIndicator { Above, Below }
|
private enum class DropIndicator { Above, Below }
|
||||||
|
|
||||||
|
private data class Settle(
|
||||||
|
val from: Long,
|
||||||
|
val to: Long,
|
||||||
|
val songId: String,
|
||||||
|
val fingerY: Float,
|
||||||
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun QueueScreen(
|
fun QueueScreen(
|
||||||
@@ -157,8 +169,11 @@ fun QueueScreen(
|
|||||||
val density = LocalDensity.current
|
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 fingerY by remember { mutableFloatStateOf(0f) }
|
||||||
var target by remember { mutableStateOf<Long?>(null) }
|
var target by remember { mutableStateOf<Long?>(null) }
|
||||||
|
var settle by remember { mutableStateOf<Settle?>(null) }
|
||||||
|
var settleProgress by remember { mutableFloatStateOf(1f) }
|
||||||
|
var settleReady by remember { mutableStateOf(false) }
|
||||||
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
|
||||||
@@ -175,28 +190,48 @@ fun QueueScreen(
|
|||||||
LaunchedEffect(dragging) {
|
LaunchedEffect(dragging) {
|
||||||
if (dragging == null) return@LaunchedEffect
|
if (dragging == null) return@LaunchedEffect
|
||||||
val threshold = with(density) { 72.dp.toPx() }
|
val threshold = with(density) { 72.dp.toPx() }
|
||||||
val step = with(density) { 10.dp.toPx() }
|
val step = with(density) { 12.dp.toPx() }
|
||||||
while (true) {
|
while (true) {
|
||||||
val from = dragging ?: break
|
val start = listState.layoutInfo.viewportStartOffset.toFloat()
|
||||||
val info = listState.layoutInfo.visibleItemsInfo.firstOrNull { items.peek(it.index)?.position == from }
|
val end = listState.layoutInfo.viewportEndOffset.toFloat()
|
||||||
if (info != null) {
|
|
||||||
val top = info.offset + dragOffset
|
|
||||||
val edge =
|
val edge =
|
||||||
when {
|
when {
|
||||||
top < listState.layoutInfo.viewportStartOffset + threshold -> -step
|
fingerY < start + threshold -> -step
|
||||||
top + info.size > listState.layoutInfo.viewportEndOffset - threshold -> step
|
fingerY > end - threshold -> step
|
||||||
else -> 0f
|
else -> 0f
|
||||||
}
|
}
|
||||||
if (edge != 0f) {
|
if (edge != 0f) listState.scrollBy(edge)
|
||||||
val consumed = listState.scrollBy(edge)
|
|
||||||
dragOffset += consumed
|
|
||||||
target = dropTarget(items, from, dragOffset, rowHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delay(16)
|
delay(16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(settle) {
|
||||||
|
val current = settle ?: return@LaunchedEffect
|
||||||
|
val first = if (items.itemCount > 0) items.peek(0)?.position else null
|
||||||
|
if (first != null) {
|
||||||
|
val index = (current.to - first).toInt()
|
||||||
|
withTimeoutOrNull(SETTLE_TIMEOUT_MS) {
|
||||||
|
snapshotFlow {
|
||||||
|
if (index in 0 until items.itemCount) {
|
||||||
|
items
|
||||||
|
.peek(index)
|
||||||
|
?.item
|
||||||
|
?.song
|
||||||
|
?.id
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.first { it == current.songId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settleReady = true
|
||||||
|
settleProgress = 0f
|
||||||
|
animate(0f, 1f, animationSpec = tween(SETTLE_DURATION_MS)) { value, _ -> settleProgress = value }
|
||||||
|
settle = null
|
||||||
|
settleReady = false
|
||||||
|
settleProgress = 1f
|
||||||
|
}
|
||||||
|
|
||||||
val showUndo: (String) -> Unit = { message ->
|
val showUndo: (String) -> Unit = { message ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
snackbarHostState.currentSnackbarData?.dismiss()
|
snackbarHostState.currentSnackbarData?.dismiss()
|
||||||
@@ -237,12 +272,30 @@ fun QueueScreen(
|
|||||||
items(count = items.itemCount) { index ->
|
items(count = items.itemCount) { index ->
|
||||||
val item = items[index]
|
val item = items[index]
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
|
val active = settle
|
||||||
|
val draggedPosition =
|
||||||
|
when {
|
||||||
|
active == null -> dragging
|
||||||
|
!settleReady -> active.from
|
||||||
|
else -> active.to
|
||||||
|
}
|
||||||
QueueRow(
|
QueueRow(
|
||||||
item = item,
|
item = item,
|
||||||
isPlaying = item.position == currentPosition,
|
isPlaying = item.position == currentPosition,
|
||||||
coverArt = coverArt,
|
coverArt = coverArt,
|
||||||
dragging = dragging == item.position,
|
floating = draggedPosition == item.position,
|
||||||
dragOffset = if (dragging == item.position) dragOffset else 0f,
|
translation =
|
||||||
|
translationFor(
|
||||||
|
item.position,
|
||||||
|
active,
|
||||||
|
dragging,
|
||||||
|
fingerY,
|
||||||
|
settleReady,
|
||||||
|
settleProgress,
|
||||||
|
rowHeight,
|
||||||
|
listState,
|
||||||
|
items,
|
||||||
|
),
|
||||||
indicator = indicatorFor(item.position, dragging, target),
|
indicator = indicatorFor(item.position, dragging, target),
|
||||||
onClick = { onPlay(item.position) },
|
onClick = { onPlay(item.position) },
|
||||||
onRemove = {
|
onRemove = {
|
||||||
@@ -251,28 +304,31 @@ fun QueueScreen(
|
|||||||
},
|
},
|
||||||
onSize = { rowHeight = it.toFloat() },
|
onSize = { rowHeight = it.toFloat() },
|
||||||
onDragStart = {
|
onDragStart = {
|
||||||
|
if (settle == null) {
|
||||||
dragging = item.position
|
dragging = item.position
|
||||||
dragOffset = 0f
|
|
||||||
target = item.position
|
target = item.position
|
||||||
|
fingerY =
|
||||||
|
(slotOffset(listState, items, rowHeight, item.position) ?: 0f) +
|
||||||
|
rowHeight / 2f
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDrag = { amount ->
|
onDrag = { amount ->
|
||||||
dragOffset += amount
|
fingerY += amount
|
||||||
target = dropTarget(items, item.position, dragOffset, rowHeight)
|
target = pointerTarget(listState, items, rowHeight, fingerY, item.position)
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
val from = dragging
|
val from = dragging
|
||||||
val to = target
|
val to = target
|
||||||
if (from != null && to != null && to != from) {
|
if (from != null && to != null && to != from) {
|
||||||
|
settle = Settle(from, to, item.item.song.id, fingerY)
|
||||||
onMove(from, to)
|
onMove(from, to)
|
||||||
showUndo("Queue reordered")
|
showUndo("Queue reordered")
|
||||||
}
|
}
|
||||||
dragging = null
|
dragging = null
|
||||||
dragOffset = 0f
|
|
||||||
target = null
|
target = null
|
||||||
},
|
},
|
||||||
onDragCancel = {
|
onDragCancel = {
|
||||||
dragging = null
|
dragging = null
|
||||||
dragOffset = 0f
|
|
||||||
target = null
|
target = null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -284,16 +340,68 @@ fun QueueScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun dropTarget(
|
private fun slotOffset(
|
||||||
|
listState: LazyListState,
|
||||||
items: LazyPagingItems<QueueWindowItem>,
|
items: LazyPagingItems<QueueWindowItem>,
|
||||||
from: Long,
|
|
||||||
offset: Float,
|
|
||||||
rowHeight: Float,
|
rowHeight: Float,
|
||||||
|
position: Long,
|
||||||
|
): Float? {
|
||||||
|
val first = listState.layoutInfo.visibleItemsInfo.firstOrNull() ?: return null
|
||||||
|
val firstPosition = items.peek(first.index)?.position ?: return null
|
||||||
|
return first.offset + (position - firstPosition) * rowHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pointerTarget(
|
||||||
|
listState: LazyListState,
|
||||||
|
items: LazyPagingItems<QueueWindowItem>,
|
||||||
|
rowHeight: Float,
|
||||||
|
fingerY: Float,
|
||||||
|
from: Long,
|
||||||
): Long {
|
): Long {
|
||||||
if (items.itemCount == 0 || rowHeight <= 0f) return from
|
if (items.itemCount == 0 || rowHeight <= 0f) return from
|
||||||
val first = items.peek(0)?.position ?: from
|
val first = listState.layoutInfo.visibleItemsInfo.firstOrNull() ?: return from
|
||||||
val last = items.peek(items.itemCount - 1)?.position ?: from
|
val firstPosition = items.peek(first.index)?.position ?: return from
|
||||||
return (from + (offset / rowHeight).roundToInt()).coerceIn(minOf(first, last), maxOf(first, last))
|
val index = firstPosition + floor((fingerY - first.offset) / rowHeight).toLong()
|
||||||
|
val firstLoaded = items.peek(0)?.position ?: firstPosition
|
||||||
|
val lastLoaded = items.peek(items.itemCount - 1)?.position ?: firstPosition
|
||||||
|
return index.coerceIn(minOf(firstLoaded, lastLoaded), maxOf(firstLoaded, lastLoaded))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun translationFor(
|
||||||
|
position: Long,
|
||||||
|
settle: Settle?,
|
||||||
|
dragging: Long?,
|
||||||
|
fingerY: Float,
|
||||||
|
settleReady: Boolean,
|
||||||
|
progress: Float,
|
||||||
|
rowHeight: Float,
|
||||||
|
listState: LazyListState,
|
||||||
|
items: LazyPagingItems<QueueWindowItem>,
|
||||||
|
): Float =
|
||||||
|
when {
|
||||||
|
settle == null -> {
|
||||||
|
if (dragging == position) fingerY - (slotOffset(listState, items, rowHeight, position) ?: fingerY) else 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
!settleReady -> {
|
||||||
|
if (position == settle.from) settle.fingerY - (slotOffset(listState, items, rowHeight, position) ?: settle.fingerY) else 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
position == settle.to -> {
|
||||||
|
(settle.fingerY - (slotOffset(listState, items, rowHeight, position) ?: settle.fingerY)) * (1f - progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
settle.to > settle.from && position in settle.from until settle.to -> {
|
||||||
|
rowHeight * (1f - progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
settle.to < settle.from && position in (settle.to + 1)..settle.from -> {
|
||||||
|
-rowHeight * (1f - progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
0f
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun indicatorFor(
|
private fun indicatorFor(
|
||||||
@@ -313,8 +421,8 @@ private fun QueueRow(
|
|||||||
item: QueueWindowItem,
|
item: QueueWindowItem,
|
||||||
isPlaying: Boolean,
|
isPlaying: Boolean,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
dragging: Boolean,
|
floating: Boolean,
|
||||||
dragOffset: Float,
|
translation: Float,
|
||||||
indicator: DropIndicator?,
|
indicator: DropIndicator?,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onRemove: () -> Unit,
|
onRemove: () -> Unit,
|
||||||
@@ -324,13 +432,12 @@ private fun QueueRow(
|
|||||||
onDragEnd: () -> Unit,
|
onDragEnd: () -> Unit,
|
||||||
onDragCancel: () -> Unit,
|
onDragCancel: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.zIndex(if (dragging) 1f else 0f)
|
.zIndex(if (floating) 1f else 0f)
|
||||||
.graphicsLayer { translationY = dragOffset },
|
.graphicsLayer { translationY = translation },
|
||||||
) {
|
) {
|
||||||
if (indicator == DropIndicator.Above) DropIndicatorLine()
|
|
||||||
SongRow(
|
SongRow(
|
||||||
song = item.item.song,
|
song = item.item.song,
|
||||||
coverArtId = item.item.coverArt,
|
coverArtId = item.item.coverArt,
|
||||||
@@ -369,7 +476,7 @@ private fun QueueRow(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.background(
|
.background(
|
||||||
if (dragging) {
|
if (floating) {
|
||||||
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.95f)
|
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.95f)
|
||||||
} else {
|
} else {
|
||||||
Color.Transparent
|
Color.Transparent
|
||||||
@@ -377,17 +484,24 @@ private fun QueueRow(
|
|||||||
).onSizeChanged { size -> onSize(size.height) }
|
).onSizeChanged { size -> onSize(size.height) }
|
||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
)
|
)
|
||||||
if (indicator == DropIndicator.Below) DropIndicatorLine()
|
when (indicator) {
|
||||||
|
DropIndicator.Above -> DropIndicatorLine(Modifier.align(Alignment.TopStart))
|
||||||
|
DropIndicator.Below -> DropIndicatorLine(Modifier.align(Alignment.BottomStart))
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DropIndicatorLine() {
|
private fun DropIndicatorLine(modifier: Modifier = Modifier) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(2.dp)
|
.height(2.dp)
|
||||||
.background(MaterialTheme.colorScheme.primary),
|
.background(MaterialTheme.colorScheme.primary),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val SETTLE_DURATION_MS = 200
|
||||||
|
private const val SETTLE_TIMEOUT_MS = 1_000L
|
||||||
|
|||||||
Reference in New Issue
Block a user