address review: anchor relocation and empty-context positions

- clamp anchorPlay to the context size, so an empty context with a queued
  block puts the block at combined 0 (previously position 0 was
  unresolvable and the queue view emptied).
- move/removeAt now derive the playing track's new position from its id
  after an edit, instead of assuming a fixed list delta; moving or
  removing the anchor relocates the whole block, which movedCursor
  ignored.
- removing the currently playing track reloads at the model's next
  position (the anchor may have relocated) rather than removing it from
  the player in place.
- the queue view's remove reconciles its rows from the repository instead
  of decrementing positions by one, so a block relocation stays in sync.
This commit is contained in:
austinried
2026-10-04 00:30:00 +00:00
parent 996b0f8f48
commit 34f0e15a7a
5 changed files with 144 additions and 50 deletions
@@ -926,6 +926,81 @@ class PlaybackControllerTest {
assertFalse(handle.operations.any { it.startsWith("setWindow") })
}
@Test
fun removingTheLastContextTrackWithAQueuedTrackKeepsTheQueue() {
seedAlbum(1, 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" }
runBlocking { controller.removeAt(0) }
await {
controller.state.value.item
?.id == "x1"
}
val snapshot = runBlocking { queues.snapshot() }
assertEquals(1L, snapshot.size)
assertTrue(runBlocking { queues.range(snapshot, 0, 0) }.isNotEmpty())
}
@Test
fun movingTheAnchorKeepsThePlayingTrackPositionInSync() {
seedAlbum(4, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 1)
await {
controller.state.value.item
?.id == "s2"
}
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
runBlocking { controller.move(1, 0) }
await {
controller.state.value.item
?.id == "x1"
}
val snapshot = runBlocking { queues.snapshot() }
assertEquals(runBlocking { queues.combinedIndexOf(snapshot, "x1") }, controller.state.value.position)
}
@Test
fun removingThePlayingAnchorTrackPlaysTheModelNextTrack() {
seedAlbum(4, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 1)
await {
controller.state.value.item
?.id == "s2"
}
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
runBlocking { controller.removeAt(1) }
await {
controller.state.value.item
?.id == "s3"
}
val snapshot = runBlocking { queues.snapshot() }
assertEquals(runBlocking { queues.combinedIndexOf(snapshot, "s3") }, controller.state.value.position)
}
@Test
fun nextFollowsTheReorderedQueue() {
seedAlbum(3, sourceId = 1)
@@ -6,6 +6,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongListItem
import com.subtracks.data.model.Source
@@ -225,6 +226,37 @@ class QueueViewModelTest {
assertEquals((220L..399L).toList(), viewModel.rows.map { it.position })
}
@Test
fun removingATrackUnderTheBlockReloadsRows() {
runBlocking { seedSongs(10) }
runBlocking {
db.libraryDao().upsertSongs(
listOf(Song(1, "x1", "al2", "ar1", "X", "Other Album", "Artist", 100, 1, 1, null, null)),
)
}
controller.playAlbum(1, "al1", 2)
await { controller.state.value.position == 2L }
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
viewModel.open()
await { viewModel.ready }
viewModel.remove(2)
await {
val positions = viewModel.rows.map { it.position }
positions.isNotEmpty() && positions == (positions.first()..positions.last()).toList()
}
val snapshot = runBlocking { queues.snapshot() }
val expected =
runBlocking {
(viewModel.rows.first().position..viewModel.rows.last().position)
.mapNotNull { queues.itemAt(snapshot, it)?.song?.id }
}
assertEquals(expected, viewModel.rows.map { it.song.song.id })
}
@Test
fun removeAndUndoKeepTheBoundedWindowConsistent() {
runBlocking { seedSongs(400) }