From 996b0f8f48529fa586e263533d7f6136a934ac0e Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:33:52 +0000 Subject: [PATCH] avoid reloading the window on queue edits; add clear up next - moves that straddle the player window, and deletes that are not the current track, rebuilt the playlist with setWindow, restarting the playing track (a pause). They now reuse rebuildWindow, which re-lays the window around the playing item without replacing it. - deleting the currently playing queued track now removes it from the player in place so it advances to the next track without a reload. - the Up next section header has a clear button that drops the whole queued block and resumes the context after the anchor. --- .../subtracks/data/repo/QueueRepository.kt | 4 + .../subtracks/playback/PlaybackController.kt | 47 +++++++- .../com/subtracks/ui/playback/QueueScreen.kt | 34 ++++-- .../playback/PlaybackControllerTest.kt | 101 ++++++++++++++++++ 4 files changed, 175 insertions(+), 11 deletions(-) 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,