fix drag drop target, serialize queue edits, guard empty ranges

This commit is contained in:
austinried
2026-10-04 00:29:52 +00:00
parent bab303ef91
commit e76976dfdf
3 changed files with 102 additions and 31 deletions
@@ -104,8 +104,8 @@ class QueueViewModel(
val start = cursor val start = cursor
val end = (start + QUEUE_CHUNK - 1).coerceAtMost(size - 1) val end = (start + QUEUE_CHUNK - 1).coerceAtMost(size - 1)
rows.addAll(queueRepository.range(snapshot, start, end).map(::newRow)) rows.addAll(queueRepository.range(snapshot, start, end).map(::newRow))
first = rows.first().position first = rows.firstOrNull()?.position ?: 0
last = rows.last().position last = rows.lastOrNull()?.position ?: -1
} }
ready = true ready = true
} }
@@ -178,30 +178,29 @@ class QueueViewModel(
fun remove(position: Long) { fun remove(position: Long) {
viewModelScope.launch { viewModelScope.launch {
val index = rows.indexOfFirst { it.position == position }
if (index >= 0) {
rows.removeAt(index)
for (i in index until rows.size) {
val row = rows[i]
rows[i] = row.copy(position = row.position - 1)
}
}
val next = (rows.lastOrNull()?.position ?: (first - 1)) + 1
playbackController.removeAt(position)
mutex.withLock { mutex.withLock {
if (rows.isEmpty()) { val index = rows.indexOfFirst { it.position == position }
size = 0 if (index >= 0) {
first = 0 rows.removeAt(index)
last = -1 for (i in index until rows.size) {
return@withLock val row = rows[i]
rows[i] = row.copy(position = row.position - 1)
}
} }
playbackController.removeAt(position)
val snapshot = queueRepository.snapshot() val snapshot = queueRepository.snapshot()
size = snapshot.size size = snapshot.size
if (next <= size - 1) { if (rows.isEmpty()) {
queueRepository.range(snapshot, next, next).firstOrNull()?.let { rows.add(newRow(it)) } reload()
return@withLock
} }
first = rows.first().position first = rows.first().position
last = rows.last().position last = rows.last().position
val next = last + 1
if (next <= size - 1) {
queueRepository.range(snapshot, next, next).firstOrNull()?.let { rows.add(newRow(it)) }
last = rows.last().position
}
} }
} }
} }
@@ -234,8 +233,8 @@ class QueueViewModel(
} }
rows.clear() rows.clear()
rows.addAll(updated) rows.addAll(updated)
first = rows.first().position first = rows.firstOrNull()?.position ?: 0
last = rows.last().position last = rows.lastOrNull()?.position ?: -1
} }
} }
@@ -286,16 +285,12 @@ fun QueueScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var dragId by remember { mutableStateOf<Long?>(null) }
var dragFrom by remember { mutableStateOf<Long?>(null) } var dragFrom by remember { mutableStateOf<Long?>(null) }
var dragTo by remember { mutableStateOf<Long?>(null) }
val reorderState = val reorderState =
rememberReorderableLazyListState(listState) { from, to -> rememberReorderableLazyListState(listState) { from, to ->
val fromPosition = rows.getOrNull(from.index)?.position
val toPosition = rows.getOrNull(to.index)?.position
onReorder(from.index, to.index) onReorder(from.index, to.index)
dragFrom = fromPosition
dragTo = toPosition
} }
LaunchedEffect(listState, ready) { LaunchedEffect(listState, ready) {
@@ -368,16 +363,22 @@ fun QueueScreen(
dragHandle = dragHandle =
Modifier.draggableHandle( Modifier.draggableHandle(
onDragStarted = { onDragStarted = {
dragId = row.id
dragFrom = row.position dragFrom = row.position
dragTo = row.position
}, },
onDragStopped = { onDragStopped = {
val id = dragId
val from = dragFrom val from = dragFrom
val to = dragTo if (id != null && from != null) {
if (from != null && to != null && from != to) { val index = rows.indexOfFirst { it.id == id }
onMove(from, to) val to = if (index < 0) from else dropTarget(rows, index, from)
showUndo("Queue reordered") if (to != from) {
onMove(from, to)
showUndo("Queue reordered")
}
} }
dragId = null
dragFrom = null
}, },
), ),
onClick = { onPlay(row.position) }, onClick = { onPlay(row.position) },
@@ -394,6 +395,17 @@ fun QueueScreen(
} }
} }
private fun dropTarget(
rows: List<QueueRow>,
index: Int,
from: Long,
): Long {
val next = rows.getOrNull(index + 1)
if (next != null) return next.position - if (next.position > from) 1 else 0
val before = rows.getOrNull(index - 1)?.position ?: return from
return (before - if (before > from) 1 else 0) + 1
}
@Composable @Composable
private fun QueueRowItem( private fun QueueRowItem(
row: QueueRow, row: QueueRow,
@@ -437,6 +437,49 @@ class PlaybackControllerTest {
) )
} }
@Test
fun movingTracksFarFromThePlayingOneDoesNotReloadTheWindow() {
seedAlbum(100, sourceId = 1)
controller.playAlbum(1, "al1", 50)
await {
controller.state.value.item
?.id == "s51"
}
handle.operations.clear()
runBlocking { controller.move(80, 90) }
assertEquals(
"s51",
controller.state.value.item
?.id,
)
assertEquals(50L, controller.state.value.position)
assertFalse(handle.operations.any { it.startsWith("setWindow") })
}
@Test
fun movingATrackAcrossTheWindowKeepsPlaying() {
seedAlbum(100, sourceId = 1)
controller.playAlbum(1, "al1", 50)
await {
controller.state.value.item
?.id == "s51"
}
handle.operations.clear()
runBlocking { controller.move(10, 90) }
assertEquals(
"s51",
controller.state.value.item
?.id,
)
assertFalse(handle.operations.any { it.startsWith("setWindow") })
}
@Test @Test
fun undoRestoresARemovedTrack() { fun undoRestoresARemovedTrack() {
seedAlbum(5, sourceId = 1) seedAlbum(5, sourceId = 1)
@@ -82,6 +82,22 @@ class QueueViewModelTest {
assertEquals((40L..199L).toList(), viewModel.rows.map { it.position }) assertEquals((40L..199L).toList(), viewModel.rows.map { it.position })
} }
@Test
fun openingAtTheEndLoadsASingleRowAndExtendsOlder() {
runBlocking { seedSongs(200) }
controller.playSongs(1, 199)
await { controller.state.value.position == 199L }
viewModel.open()
await { viewModel.ready }
assertEquals(listOf(199L), viewModel.rows.map { it.position })
viewModel.loadOlder()
await { viewModel.rows.firstOrNull()?.position == 139L }
assertEquals((139L..199L).toList(), viewModel.rows.map { it.position })
}
@Test @Test
fun reorderingMovesTheRowLocally() { fun reorderingMovesTheRowLocally() {
runBlocking { seedSongs(200) } runBlocking { seedSongs(200) }