mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
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.
This commit is contained in:
@@ -378,6 +378,10 @@ class QueueRepository(
|
|||||||
return Reanchored(play, true)
|
return Reanchored(play, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun clearUpNext() {
|
||||||
|
writeUpNext(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun removeUpNextAt(
|
suspend fun removeUpNextAt(
|
||||||
snapshot: QueueSnapshot,
|
snapshot: QueueSnapshot,
|
||||||
index: Long,
|
index: Long,
|
||||||
|
|||||||
@@ -346,7 +346,19 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
if (snapshot.upNextSize > 0L || updated.upNextSize > 0L) {
|
if (snapshot.upNextSize > 0L || updated.upNextSize > 0L) {
|
||||||
queueRepository.setCursor(target)
|
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
|
return@withLock
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
@@ -389,7 +401,8 @@ class PlaybackController(
|
|||||||
val entries = snapshot.entries.map { it.entry }
|
val entries = snapshot.entries.map { it.entry }
|
||||||
val upNext = snapshot.upNext.map { it.entry }
|
val upNext = snapshot.upNext.map { it.entry }
|
||||||
if (!queueRepository.move(snapshot, from, to)) return@withLock
|
if (!queueRepository.move(snapshot, from, to)) return@withLock
|
||||||
this.snapshot = readSnapshot()
|
val updated = readSnapshot()
|
||||||
|
this.snapshot = updated
|
||||||
val target = movedCursor(current, from, to)
|
val target = movedCursor(current, from, to)
|
||||||
lastPosition = target
|
lastPosition = target
|
||||||
windowJob?.cancel()
|
windowJob?.cancel()
|
||||||
@@ -425,7 +438,7 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
loadWindow(target, autoplay = player.playWhenReady, startPositionMs = player.currentPositionMs)
|
rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
shiftWindowLocked(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() {
|
fun previous() {
|
||||||
val player = player ?: return
|
val player = player ?: return
|
||||||
if (player.currentPositionMs > RESTART_THRESHOLD_MS) {
|
if (player.currentPositionMs > RESTART_THRESHOLD_MS) {
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ fun QueueRoute(
|
|||||||
currentSongId = playback.item?.id,
|
currentSongId = playback.item?.id,
|
||||||
shuffle = playback.shuffle,
|
shuffle = playback.shuffle,
|
||||||
contextTitle = sourceTitle.value,
|
contextTitle = sourceTitle.value,
|
||||||
|
onClearUpNext = controller::clearUpNext,
|
||||||
coverArt = sourceRepository::coverArt,
|
coverArt = sourceRepository::coverArt,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onPlay = viewModel::play,
|
onPlay = viewModel::play,
|
||||||
@@ -332,6 +333,7 @@ fun QueueScreen(
|
|||||||
generation: Int = 0,
|
generation: Int = 0,
|
||||||
shuffle: Boolean = false,
|
shuffle: Boolean = false,
|
||||||
contextTitle: String? = null,
|
contextTitle: String? = null,
|
||||||
|
onClearUpNext: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
@@ -403,7 +405,10 @@ fun QueueScreen(
|
|||||||
rows.forEachIndexed { index, row ->
|
rows.forEachIndexed { index, row ->
|
||||||
if (index == 0 || rows[index - 1].upNext != row.upNext) {
|
if (index == 0 || rows[index - 1].upNext != row.upNext) {
|
||||||
item(key = "header-$index") {
|
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
|
val enabled = row.upNext || !shuffle
|
||||||
@@ -471,13 +476,26 @@ fun QueueScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueSectionHeader(label: String) {
|
private fun QueueSectionHeader(
|
||||||
Text(
|
label: String,
|
||||||
text = label,
|
onClear: (() -> Unit)? = null,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
) {
|
||||||
color = MaterialTheme.colorScheme.primary,
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
|
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<QueueRow>.rowIndexOf(key: Any?): Int {
|
private fun List<QueueRow>.rowIndexOf(key: Any?): Int {
|
||||||
|
|||||||
@@ -851,6 +851,81 @@ class PlaybackControllerTest {
|
|||||||
assertFalse(handle.operations.any { it.startsWith("setWindow") })
|
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
|
@Test
|
||||||
fun nextFollowsTheReorderedQueue() {
|
fun nextFollowsTheReorderedQueue() {
|
||||||
seedAlbum(3, sourceId = 1)
|
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(
|
private fun seedSong(
|
||||||
id: String,
|
id: String,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
|
|||||||
Reference in New Issue
Block a user