diff --git a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt index 11a14ad4..ad13b80e 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -378,6 +378,10 @@ class QueueRepository( return Reanchored(play, true) } + suspend fun clearUpNext() { + writeUpNext(emptyList()) + } + suspend fun removeUpNextAt( snapshot: QueueSnapshot, index: Long, diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 4387d341..570b15f8 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -346,7 +346,19 @@ class PlaybackController( } if (snapshot.upNextSize > 0L || updated.upNextSize > 0L) { queueRepository.setCursor(target) - loadWindow(target, autoplay = playing, startPositionMs = if (position == current) 0 else player.currentPositionMs) + if (position != current) { + rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target) + refresh(target) + } else if (target - windowStart in 0 until player.itemCount) { + updating = true + player.removeAt((target - windowStart).toInt()) + updating = false + windowEnd-- + shiftWindowLocked(target) + refresh(target) + } else { + loadWindow(target, autoplay = playing) + } return@withLock } when { @@ -389,7 +401,8 @@ class PlaybackController( val entries = snapshot.entries.map { it.entry } val upNext = snapshot.upNext.map { it.entry } if (!queueRepository.move(snapshot, from, to)) return@withLock - this.snapshot = readSnapshot() + val updated = readSnapshot() + this.snapshot = updated val target = movedCursor(current, from, to) lastPosition = target windowJob?.cancel() @@ -425,7 +438,7 @@ class PlaybackController( } else -> { - loadWindow(target, autoplay = player.playWhenReady, startPositionMs = player.currentPositionMs) + rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target) } } shiftWindowLocked(target) @@ -567,6 +580,34 @@ class PlaybackController( } } + fun clearUpNext() { + scope.launch { + startLock.withLock { + val snapshot = snapshot ?: return@withLock + if (snapshot.upNextSize == 0L) return@withLock + player ?: return@withLock + val current = currentPosition() ?: return@withLock + val wasUpNext = snapshot.isUpNext(current) + queueRepository.clearUpNext() + val updated = readSnapshot() + this@PlaybackController.snapshot = updated + layoutVersion++ + windowJob?.cancel() + endedHandled = false + val contextPlay = if (wasUpNext) snapshot.anchorPlay + 1 else snapshot.contextPlay(current) + val target = contextPlay.coerceIn(0, (updated.size - 1).coerceAtLeast(0)) + queueRepository.setCursor(target) + lastPosition = target + if (wasUpNext) { + loadWindow(target, autoplay = true) + } else { + rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target) + refresh(target) + } + } + } + } + fun previous() { val player = player ?: return if (player.currentPositionMs > RESTART_THRESHOLD_MS) { 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 271a3308..95c82c8c 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt @@ -299,6 +299,7 @@ fun QueueRoute( currentSongId = playback.item?.id, shuffle = playback.shuffle, contextTitle = sourceTitle.value, + onClearUpNext = controller::clearUpNext, coverArt = sourceRepository::coverArt, onBack = onBack, onPlay = viewModel::play, @@ -332,6 +333,7 @@ fun QueueScreen( generation: Int = 0, shuffle: Boolean = false, contextTitle: String? = null, + onClearUpNext: () -> Unit = {}, ) { val listState = rememberLazyListState() val fill = rememberViewportFill(listState) @@ -403,7 +405,10 @@ fun QueueScreen( rows.forEachIndexed { index, row -> if (index == 0 || rows[index - 1].upNext != row.upNext) { item(key = "header-$index") { - QueueSectionHeader(if (row.upNext) "Up next" else contextTitle ?: "Next up") + QueueSectionHeader( + label = if (row.upNext) "Up next" else contextTitle ?: "Next up", + onClear = if (row.upNext) onClearUpNext else null, + ) } } val enabled = row.upNext || !shuffle @@ -471,13 +476,26 @@ fun QueueScreen( } @Composable -private fun QueueSectionHeader(label: String) { - Text( - text = label, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 16.dp, bottom = 4.dp), - ) +private fun QueueSectionHeader( + label: String, + onClear: (() -> Unit)? = null, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 16.dp, end = 4.dp, bottom = 4.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + if (onClear != null) { + IconButton(onClick = onClear) { + Icon(Icons.Rounded.Close, contentDescription = "Clear up next", modifier = Modifier.size(20.dp)) + } + } + } } private fun List.rowIndexOf(key: Any?): Int { diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 21c5135f..191527ff 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -851,6 +851,81 @@ class PlaybackControllerTest { assertFalse(handle.operations.any { it.startsWith("setWindow") }) } + @Test + fun clearingTheQueueResumesTheContext() { + seedAlbum(3, sourceId = 1) + seedSong("x1", "al2") + + controller.playAlbum(1, "al1", 0) + await { + controller.state.value.item + ?.id == "s1" + } + controller.addToQueue(1, QueueKind.Song, "x1") + controller.next() + await { + controller.state.value.item + ?.id == "x1" + } + + controller.clearUpNext() + await { + controller.state.value.item + ?.id == "s2" + } + + assertTrue(runBlocking { queues.snapshot().upNext }.isEmpty()) + } + + @Test + fun clearingTheQueueWhileOnTheContextKeepsPlaying() { + seedAlbum(3, sourceId = 1) + seedSong("x1", "al2") + + controller.playAlbum(1, "al1", 0) + await { + controller.state.value.item + ?.id == "s1" + } + controller.addToQueue(1, QueueKind.Song, "x1") + await { runBlocking { controller.upcomingItem()?.id } == "x1" } + + controller.clearUpNext() + Thread.sleep(200) + + assertEquals( + "s1", + controller.state.value.item + ?.id, + ) + assertTrue(runBlocking { queues.snapshot().upNext }.isEmpty()) + } + + @Test + fun movingAFarQueuedTrackDoesNotReloadTheWindow() { + seedAlbum(3, sourceId = 1) + seedAlbumTracks("al2", 60) + + controller.playAlbum(1, "al1", 0) + await { + controller.state.value.item + ?.id == "s1" + } + controller.addToQueue(1, QueueKind.Album, "al2") + await { runBlocking { controller.upcomingItem()?.id } == "al2-b1" } + + handle.operations.clear() + runBlocking { controller.move(50, 2) } + Thread.sleep(300) + + assertEquals( + "s1", + controller.state.value.item + ?.id, + ) + assertFalse(handle.operations.any { it.startsWith("setWindow") }) + } + @Test fun nextFollowsTheReorderedQueue() { seedAlbum(3, sourceId = 1) @@ -1846,6 +1921,32 @@ class PlaybackControllerTest { } } + private fun seedAlbumTracks( + albumId: String, + count: Int, + ) { + runBlocking { + db.libraryDao().upsertSongs( + (1..count).map { track -> + Song( + sourceId = 1, + id = "$albumId-b$track", + albumId = albumId, + artistId = "ar1", + title = "Album $albumId track $track", + album = "Album $albumId", + artist = "Artist", + duration = 100, + track = track.toLong(), + disc = 1, + starred = null, + genre = null, + ) + }, + ) + } + } + private fun seedSong( id: String, albumId: String,