3 Commits
Author SHA1 Message Date
austinried 11276c2e1f playback: revert the restored session prepare
Preparing the restored window on launch was meant to make the paused
session reachable from Bluetooth and Automate. The follow-up on the issue
shows the notification appears, but the car's play command still does not
reach the app until the phone is unlocked, and the speculative prepare
buffered up to 50 seconds of the restored track on every launch while
claiming the media controls whenever the app opened.

Removing the prepare also removes the error gate it needed, which could
hide genuine playback errors while paused.

Reverts #230.
2026-11-05 13:13:22 +00:00
austinried eb55858cc4 playback: preserve null keys and ranged duplicate ranks 2026-11-04 13:15:54 +00:00
austinried 95ad993996 playback: resolve shuffled windows without flat ID lists 2026-11-04 13:06:45 +00:00
7 changed files with 379 additions and 221 deletions
@@ -214,14 +214,34 @@ interface QueueDao {
): List<String>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.id IN (:ids)",
"SELECT CASE WHEN EXISTS (SELECT 1 FROM songs WHERE sourceId = :sourceId AND albumId = :albumId AND id = :songId) " +
"THEN (SELECT COUNT(*) FROM songs ranked CROSS JOIN songs target " +
"WHERE ranked.sourceId = :sourceId AND ranked.albumId = :albumId " +
"AND target.sourceId = :sourceId AND target.albumId = :albumId AND target.id = :songId " +
"AND ((ranked.disc IS NULL AND target.disc IS NOT NULL) OR ranked.disc < target.disc OR " +
"(ranked.disc IS target.disc AND ((ranked.track IS NULL AND target.track IS NOT NULL) OR ranked.track < target.track OR " +
"(ranked.track IS target.track AND ranked.id < target.id))))) END",
)
suspend fun songsByIds(
suspend fun albumRank(
sourceId: Long,
ids: List<String>,
): List<AlbumSongItem>
albumId: String,
songId: String,
): Long?
@Query(
"SELECT CASE WHEN firstPosition IS NULL THEN NULL ELSE " +
"(SELECT COUNT(*) - 1 FROM playlist_songs " +
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId AND position <= firstPosition) END " +
"FROM (SELECT MIN(position) AS firstPosition FROM playlist_songs " +
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId AND songId = :songId)",
)
suspend fun playlistRank(
sourceId: Long,
playlistId: String,
songId: String,
): Long?
@Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre")
suspend fun genreLength(
@@ -243,17 +263,6 @@ interface QueueDao {
limit: Int,
): List<AlbumSongItem>
@Query(
"SELECT songs.id FROM song_genres " +
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
)
suspend fun genreSongIds(
sourceId: Long,
genre: String,
): List<String>
@Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'")
suspend fun downloadedLength(sourceId: Long): Long
@@ -272,12 +281,6 @@ interface QueueDao {
limit: Int,
): List<AlbumSongItem>
@Query(
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
"ORDER BY starred DESC, sortName COLLATE NOCASE, id",
)
suspend fun starredSongIds(sourceId: Long): List<String>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM song_downloads d " +
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
@@ -290,12 +293,4 @@ interface QueueDao {
offset: Long,
limit: Int,
): List<AlbumSongItem>
@Query(
"SELECT songs.id FROM song_downloads d " +
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
)
suspend fun downloadedSongIds(sourceId: Long): List<String>
}
@@ -360,10 +360,7 @@ class LibraryRepository(
sourceId: Long,
albumId: String,
songId: String,
): Long {
val index = db.queueDao().albumSongIds(sourceId, albumId).indexOf(songId)
return if (index < 0) 0L else index.toLong()
}
): Long = db.queueDao().albumRank(sourceId, albumId, songId) ?: 0L
fun playlist(
sourceId: Long,
@@ -209,6 +209,202 @@ data class Reanchored(
val changed: Boolean,
)
private interface QueueKindResolver {
fun supportsKeyset(key: OrderKey): Boolean = false
suspend fun length(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
): Long
suspend fun rows(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
offset: Long,
limit: Int,
): List<SongItem>
suspend fun rowsFrom(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
): List<SongItem>? = null
suspend fun rowsBefore(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
): List<SongItem>? = null
suspend fun rank(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
songId: String,
): Long? = null
}
private val queueKindResolvers =
mapOf<QueueKind, QueueKindResolver>(
QueueKind.Song to
object : QueueKindResolver {
override suspend fun length(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
) = dao.songLength(entry.sourceId, entry.refId)
override suspend fun rows(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
offset: Long,
limit: Int,
) = dao.song(entry.sourceId, entry.refId, offset, limit)
override suspend fun rank(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
songId: String,
) = if (entry.refId == songId && length(dao, entry) > 0) 0L else null
},
QueueKind.Playlist to
object : QueueKindResolver {
override fun supportsKeyset(key: OrderKey) = key.position != null
override suspend fun length(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
) = dao.playlistLength(entry.sourceId, entry.refId)
override suspend fun rows(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
offset: Long,
limit: Int,
) = dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
override suspend fun rowsFrom(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
) = dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
override suspend fun rowsBefore(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
) = dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
override suspend fun rank(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
songId: String,
) = dao.playlistRank(entry.sourceId, entry.refId, songId)
},
QueueKind.Album to
object : QueueKindResolver {
override fun supportsKeyset(key: OrderKey) = key.disc != null && key.track != null
override suspend fun length(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
) = dao.albumLength(entry.sourceId, entry.refId)
override suspend fun rows(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
offset: Long,
limit: Int,
) = dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
override suspend fun rowsFrom(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
) = dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
override suspend fun rowsBefore(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
) = dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
override suspend fun rank(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
songId: String,
) = dao.albumRank(entry.sourceId, entry.refId, songId)
},
QueueKind.Artist to
object : QueueKindResolver {
override suspend fun length(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
) = dao.artistLength(entry.sourceId, entry.refId)
override suspend fun rows(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
offset: Long,
limit: Int,
) = dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
},
QueueKind.Genre to
object : QueueKindResolver {
override suspend fun length(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
) = dao.genreLength(entry.sourceId, entry.refId)
override suspend fun rows(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
offset: Long,
limit: Int,
) = dao.genreSongs(entry.sourceId, entry.refId, offset, limit)
},
QueueKind.Downloaded to
object : QueueKindResolver {
override suspend fun length(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
) = dao.downloadedLength(entry.sourceId)
override suspend fun rows(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
offset: Long,
limit: Int,
) = dao.downloadedSongs(entry.sourceId, offset, limit)
},
QueueKind.Starred to
object : QueueKindResolver {
override suspend fun length(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
) = dao.starredLength(entry.sourceId)
override suspend fun rows(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
offset: Long,
limit: Int,
) = dao.starredSongs(entry.sourceId, offset, limit)
},
)
private fun resolverFor(kind: QueueKind): QueueKindResolver = queueKindResolvers.getValue(kind)
class QueueRepository(
private val db: SubtracksDatabase,
) {
@@ -216,12 +412,6 @@ class QueueRepository(
private val queueVersion = AtomicLong()
@Volatile
private var cachedFlatIds: List<String>? = null
@Volatile
private var cachedFlatVersion = -1L
@Volatile
private var shuffleRemoved: ShuffleState? = null
@@ -235,6 +425,9 @@ class QueueRepository(
internal var offsetSeeks = 0L
private set
internal var resolvedRowCount = 0L
private set
private val cursorMutex = Mutex()
fun playlistEntry(
@@ -388,7 +581,6 @@ class QueueRepository(
}
fun invalidateLibraryCache() {
cachedFlatIds = null
lengthCache.clear()
keyCache.clear()
shuffleRemoved = null
@@ -409,8 +601,8 @@ class QueueRepository(
snapshot: QueueSnapshot,
songId: String,
): Long? {
val upIndex = idsFor(snapshot.upNext).indexOf(songId)
if (upIndex >= 0) return snapshot.anchorPlay + 1 + upIndex
val upIndex = flatIndexOf(snapshot.upNext, songId)
if (upIndex != null) return snapshot.anchorPlay + 1 + upIndex
return combinedContextIndexOf(snapshot, songId)
}
@@ -418,9 +610,8 @@ class QueueRepository(
snapshot: QueueSnapshot,
songId: String,
): Long? {
val flat = flatIds(snapshot).indexOf(songId)
if (flat < 0) return null
return snapshot.playContext(flat.toLong())?.let { snapshot.combined(it) }
val flat = flatIndexOf(snapshot.entries, songId) ?: return null
return snapshot.playContext(flat)?.let { snapshot.combined(it) }
}
suspend fun range(
@@ -633,7 +824,6 @@ class QueueRepository(
}
}
queueVersion.incrementAndGet()
cachedFlatIds = null
}
private suspend fun writeUpNext(entries: List<QueueEntry>) {
@@ -650,34 +840,40 @@ class QueueRepository(
queueVersion.incrementAndGet()
}
private suspend fun flatIds(snapshot: QueueSnapshot): List<String> {
val cached = cachedFlatIds
if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.contextSize) return cached
val ids = idsFor(snapshot.entries)
cachedFlatIds = ids
cachedFlatVersion = snapshot.version
return ids
private suspend fun flatIndexOf(
entries: List<ResolvedQueueEntry>,
songId: String,
): Long? {
var start = 0L
for (resolved in entries) {
val entry = resolved.entry
val rank = resolverFor(entry.kind).rank(dao, entry, songId)
if (rank != null && rank >= entry.offset && rank < entry.offset + resolved.length) {
return start + rank - entry.offset
}
val fallback = fallbackRank(entry, resolved.length, songId)
if (fallback != null) return start + fallback - entry.offset
start += resolved.length
}
return null
}
private suspend fun idsFor(resolved: List<ResolvedQueueEntry>): List<String> {
val ids = ArrayList<String>()
for (entry in resolved) {
val ref = entry.entry
val entryIds =
when (ref.kind) {
QueueKind.Song -> listOf(ref.refId)
QueueKind.Album -> dao.albumSongIds(ref.sourceId, ref.refId)
QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId)
QueueKind.Artist -> dao.artistSongIds(ref.sourceId, ref.refId)
QueueKind.Genre -> dao.genreSongIds(ref.sourceId, ref.refId)
QueueKind.Downloaded -> dao.downloadedSongIds(ref.sourceId)
QueueKind.Starred -> dao.starredSongIds(ref.sourceId)
}
val from = ref.offset.toInt().coerceIn(0, entryIds.size)
val to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size)
ids.addAll(entryIds.subList(from, to))
private suspend fun fallbackRank(
entry: QueueEntry,
length: Long,
songId: String,
): Long? {
var offset = entry.offset
val end = entry.offset + length
while (offset < end) {
val limit = minOf(QUEUE_CHUNK.toLong(), end - offset).toInt()
val batch = rows(entry, offset, limit)
val index = batch.indexOfFirst { it.song.id == songId }
if (index >= 0) return offset + index
if (batch.size < limit) return null
offset += batch.size
}
return ids
return null
}
private suspend fun contextPlayRange(
@@ -688,21 +884,11 @@ class QueueRepository(
if (first > last) return emptyList()
val items = mutableListOf<QueueWindowItem>()
if (snapshot.shuffled) {
val sourceId =
snapshot.entries
.firstOrNull()
?.entry
?.sourceId ?: return emptyList()
val ids = flatIds(snapshot)
val requested = mutableListOf<Pair<Long, String>>()
for (play in first..last) {
val flat = snapshot.flatContext(play) ?: continue
val id = ids.getOrNull(flat.toInt()) ?: continue
requested += play to id
val located = snapshot.locateContext(play) ?: continue
val item = rows(located.first, located.first.offset + located.second, 1).firstOrNull() ?: continue
items += QueueWindowItem(snapshot.combined(play), item, playlistPosition = item.playlistPosition)
}
if (requested.isEmpty()) return emptyList()
val byId = dao.songsByIds(sourceId, requested.map { it.second }.distinct()).associateBy { it.song.id }
requested.forEach { (play, id) -> byId[id]?.let { items += QueueWindowItem(snapshot.combined(play), it) } }
return items
}
var start = 0L
@@ -757,27 +943,20 @@ class QueueRepository(
): List<SongItem> {
if (entry.kind == QueueKind.Song) {
offsetSeeks++
return dao.song(entry.sourceId, entry.refId, offset, limit)
return resolverFor(entry.kind).rows(dao, entry, offset, limit).also { resolvedRowCount += it.size }
}
val ref = QueueRef(entry.kind, entry.sourceId, entry.refId)
val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) }
if (anchored != null) {
keysetSeeks++
if (anchored.isNotEmpty()) remember(ref, offset, anchored)
resolvedRowCount += anchored.size
return anchored
}
offsetSeeks++
val fresh =
when (entry.kind) {
QueueKind.Playlist -> dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
QueueKind.Album -> dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
QueueKind.Artist -> dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
QueueKind.Genre -> dao.genreSongs(entry.sourceId, entry.refId, offset, limit)
QueueKind.Downloaded -> dao.downloadedSongs(entry.sourceId, offset, limit)
QueueKind.Starred -> dao.starredSongs(entry.sourceId, offset, limit)
QueueKind.Song -> emptyList()
}
val fresh = resolverFor(entry.kind).rows(dao, entry, offset, limit)
if (fresh.isNotEmpty()) remember(ref, offset, fresh)
resolvedRowCount += fresh.size
return fresh
}
@@ -810,7 +989,7 @@ class QueueRepository(
limit: Int,
cursor: KeyCursor,
): List<SongItem>? {
if (!cursor.key.seekable(entry.kind)) return null
if (!resolverFor(entry.kind).supportsKeyset(cursor.key)) return null
val from = cursor.ordinal
// A backward seek from a far cursor is not cheaper than the offset query: the album
// predicate is not sargable (it has to include NULL disc/track), so it scans the partition
@@ -836,43 +1015,14 @@ class QueueRepository(
key: OrderKey,
skip: Long,
limit: Int,
): List<SongItem> =
when (entry.kind) {
QueueKind.Playlist -> dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
QueueKind.Album -> dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
QueueKind.Artist -> emptyList()
QueueKind.Genre -> emptyList()
QueueKind.Downloaded -> emptyList()
QueueKind.Starred -> emptyList()
QueueKind.Song -> emptyList()
}
): List<SongItem> = resolverFor(entry.kind).rowsFrom(dao, entry, key, skip, limit) ?: emptyList()
private suspend fun rowsBefore(
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
): List<SongItem> =
when (entry.kind) {
QueueKind.Playlist -> dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
QueueKind.Album -> dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
QueueKind.Artist -> emptyList()
QueueKind.Genre -> emptyList()
QueueKind.Downloaded -> emptyList()
QueueKind.Starred -> emptyList()
QueueKind.Song -> emptyList()
}
private fun OrderKey.seekable(kind: QueueKind): Boolean =
when (kind) {
QueueKind.Playlist -> position != null
QueueKind.Album -> disc != null && track != null
QueueKind.Artist -> false
QueueKind.Genre -> false
QueueKind.Downloaded -> false
QueueKind.Starred -> false
QueueKind.Song -> false
}
): List<SongItem> = resolverFor(entry.kind).rowsBefore(dao, entry, key, skip, limit) ?: emptyList()
private suspend fun lengthOf(
entry: QueueEntry,
@@ -897,36 +1047,7 @@ class QueueRepository(
)
private suspend fun QueueEntry.resolvedLength(): Long {
val total =
when (kind) {
QueueKind.Song -> {
dao.songLength(sourceId, refId)
}
QueueKind.Playlist -> {
dao.playlistLength(sourceId, refId)
}
QueueKind.Album -> {
dao.albumLength(sourceId, refId)
}
QueueKind.Artist -> {
dao.artistLength(sourceId, refId)
}
QueueKind.Genre -> {
dao.genreLength(sourceId, refId)
}
QueueKind.Downloaded -> {
dao.downloadedLength(sourceId)
}
QueueKind.Starred -> {
dao.starredLength(sourceId)
}
}
val total = resolverFor(kind).length(dao, this)
val available = (total - offset).coerceAtLeast(0)
return count?.coerceAtMost(available) ?: available
}
@@ -1070,7 +1070,6 @@ class PlaybackController(
}
lastSavedPositionMs = savedPositionMs
loadWindow(anchor.position, autoplay = false, startPositionMs = savedPositionMs)
player?.takeIf { it.itemCount > 0 }?.ensurePrepared()
}
private suspend fun stop() = startLock.withLock { stopLocked() }
@@ -1452,7 +1451,7 @@ class PlaybackController(
}
override fun onError(message: UiMessage) {
if (player?.playWhenReady == true) showMessage(message)
showMessage(message)
refresh()
}
}
@@ -150,6 +150,18 @@ class QueueKeysetTest {
assertTrue("backward keyset seeks", repository.keysetSeeks > beforeBackward)
}
@Test
fun scanningAnAlbumWithNullDiscAndTrackForwardFallsBackToOffsetRows() =
runTest {
seedLibrary()
repository.replace(listOf(repository.albumEntry(1, "mixed")))
val snapshot = repository.snapshot()
val resolved = (0 until snapshot.size).map { repository.itemAt(snapshot, it)?.song?.id }
assertEquals(listOf("n1", "n2", "s1", "s2"), resolved)
}
@Test
fun scanningAnAlbumWithNullDiscAndTrackBackwardsResolvesEveryRow() =
runTest {
@@ -260,6 +272,22 @@ class QueueKeysetTest {
assertTrue(ids.all { it in expected })
}
@Test
fun thePlaylistWindowQuerySeeksThePositionIndex() =
runTest {
seedLibrary()
val plan =
queryPlan(
"SELECT songs.*, playlist_songs.position AS playlistPosition, albums.coverArt AS coverArt " +
"FROM playlist_songs JOIN songs ON songs.sourceId = playlist_songs.sourceId " +
"AND songs.id = playlist_songs.songId LEFT JOIN albums ON albums.sourceId = songs.sourceId " +
"AND albums.id = songs.albumId WHERE playlist_songs.sourceId = 1 " +
"AND playlist_songs.playlistId = 'pl1' ORDER BY playlist_songs.position LIMIT 51 OFFSET 100000",
)
assertTrue(plan.joinToString("\n"), plan.any { it.contains("sqlite_autoindex_playlist_songs_1") })
}
@Test
fun theAlbumKeysetQuerySeeksTheOrderIndex() =
runTest {
@@ -727,6 +727,32 @@ class QueueRepositoryTest {
assertEquals(listOf("s1", "s2", "s3"), resolveAll(restored))
}
@Test
fun shuffledLargeContextWindowOnlyResolvesRequestedRows() =
runTest {
seedLibrary()
db.libraryDao().upsertPlaylists(listOf(playlist("huge", "Huge")))
db.useWriterConnection { transactor ->
transactor.usePrepared(
"WITH RECURSIVE n(x) AS (VALUES(0) UNION ALL SELECT x + 1 FROM n WHERE x < 199999) " +
"INSERT INTO playlist_songs SELECT 1, 'huge', 's1', x FROM n",
) { statement ->
statement.step()
}
}
repository.snapshotAfter(listOf(repository.playlistEntry(1, "huge")))
repository.setShuffle(42L)
val snapshot = repository.snapshot()
val window = repository.window(snapshot, center = 100_000, radius = 25)
assertEquals(200_000L, snapshot.contextSize)
assertEquals(51, window.size)
assertEquals(51L, repository.resolvedRowCount)
assertEquals((99_975L..100_025L).toList(), window.map { it.position })
assertTrue(window.all { it.item.song.id == "s1" })
}
@Test
fun shuffledRangeResolvesEntriesInPositionOrder() =
runTest {
@@ -851,6 +877,55 @@ class QueueRepositoryTest {
}
}
@Test
fun contextIndexUsesTheFirstDuplicateOrdinalAcrossSparsePositions() =
runTest {
seedLibrary()
db.libraryDao().upsertPlaylists(listOf(playlist("duplicates", "Duplicates")))
db.libraryDao().upsertPlaylistSongs(
listOf(
PlaylistSong(1, "duplicates", "s2", 10),
PlaylistSong(1, "duplicates", "s1", 20),
PlaylistSong(1, "duplicates", "s2", 30),
),
)
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "duplicates")))
assertEquals(0L, repository.combinedContextIndexOf(snapshot, "s2"))
assertEquals(1L, repository.combinedContextIndexOf(snapshot, "s1"))
}
@Test
fun contextIndexFindsADuplicateInsideTheEntryRange() =
runTest {
seedLibrary()
db.libraryDao().upsertPlaylists(listOf(playlist("duplicates", "Duplicates")))
db.libraryDao().upsertPlaylistSongs(
listOf(
PlaylistSong(1, "duplicates", "s2", 10),
PlaylistSong(1, "duplicates", "s1", 20),
PlaylistSong(1, "duplicates", "s2", 30),
),
)
val entry = repository.playlistEntry(1, "duplicates").copy(rangeStart = 1, rangeEnd = 2)
val snapshot = repository.snapshotAfter(listOf(entry))
assertEquals(1L, repository.combinedContextIndexOf(snapshot, "s2"))
}
@Test
fun contextIndexFallsBackToBoundedRowsForGenre() =
runTest {
seedLibrary()
insertGenre("s1", "Rock")
insertGenre("s3", "Rock")
insertGenre("s4", "Rock")
val snapshot = repository.snapshotAfter(listOf(repository.genreEntry(1, "Rock")))
assertEquals(2L, repository.combinedContextIndexOf(snapshot, "s4"))
assertTrue(repository.resolvedRowCount <= QUEUE_CHUNK)
}
@Test
fun upNextCombinedIndexOfCountsFromTheAnchorIntoTheBlock() =
runTest {
@@ -343,75 +343,18 @@ class PlaybackControllerTest {
}
@Test
fun restoringAPreviousQueuePreparesTheSessionWithoutPlaying() {
seedAlbum(3, sourceId = 1)
runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) }
controller.connect()
await { handle.operations.contains("prepare") }
assertFalse(handle.operations.contains("play"))
assertFalse(handle.isIdle)
assertFalse(handle.playWhenReady)
assertFalse(controller.state.value.isPlaying)
}
@Test
fun aPrepareFailureBeforePlaybackIsNotReported() {
seedAlbum(3, sourceId = 1)
runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) }
controller.connect()
await { handle.operations.contains("prepare") }
handle.fail(UiMessage(R.string.playback_error_network))
assertTrue(messages.isEmpty())
controller.togglePlayPause()
await { handle.operations.contains("play") }
handle.fail(UiMessage(R.string.playback_error_network))
await { messages.isNotEmpty() }
assertEquals(1, messages.size)
}
@Test
fun restoringAnEmptyQueueDoesNotPrepare() {
seedAlbum(3, sourceId = 1)
controller.connect()
await { controller.ready.value }
assertTrue(handle.items.isEmpty())
assertFalse(handle.operations.contains("prepare"))
}
@Test
fun restoringAnOfflineQueueWithoutDownloadsDoesNotPrepare() {
seedAlbum(3, sourceId = 1)
runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) }
setOffline()
controller.connect()
await { controller.ready.value }
assertTrue(handle.items.isEmpty())
assertFalse(handle.operations.contains("prepare"))
}
@Test
fun togglingPlayOnARestoredQueuePlaysThePreparedWindow() {
fun togglingPlayOnARestoredQueuePreparesBeforePlaying() {
seedAlbum(5, sourceId = 1)
runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) }
controller.connect()
await { handle.operations.contains("prepare") }
await { handle.items.isNotEmpty() }
handle.operations.clear()
controller.togglePlayPause()
await { handle.operations.contains("play") }
assertEquals(listOf("play"), handle.operations)
assertEquals(listOf("prepare", "play"), handle.operations)
}
@Test