skip non-downloaded tracks in offline shuffle and the queue view

This commit is contained in:
austinried
2026-10-04 00:30:04 +00:00
parent 554b7ef25d
commit aa956668b1
5 changed files with 127 additions and 35 deletions
@@ -90,7 +90,7 @@ fun appModule(
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) } viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) }
viewModel { params -> ArtistDetailViewModel(get(), get(), get(), get(), params.get()) } viewModel { params -> ArtistDetailViewModel(get(), get(), get(), get(), params.get()) }
viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), get(), params.get()) } viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), get(), params.get()) }
viewModel { QueueViewModel(get(), get()) } viewModel { QueueViewModel(get(), get(), get(), get()) }
} }
private fun downloadsRoot(context: Context): File = private fun downloadsRoot(context: Context): File =
@@ -1003,15 +1003,24 @@ class PlaybackController(
val player = player ?: return val player = player ?: return
val snapshot = snapshot ?: return val snapshot = snapshot ?: return
downloads.awaitLoaded() downloads.awaitLoaded()
val raw = queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS) val requested =
if (offline) {
downloadedAtOrAfter(snapshot, position) ?: downloadedAtOrBefore(snapshot, position - 1) ?: run {
stopLocked()
return
}
} else {
position
}
val raw = queueRepository.window(snapshot, requested, QUEUE_WINDOW_RADIUS)
val window = if (offline) raw.filter { downloads.localUri(it.item.song.id) != null } else raw val window = if (offline) raw.filter { downloads.localUri(it.item.song.id) != null } else raw
if (window.isEmpty()) { if (window.isEmpty()) {
stopLocked() stopLocked()
return return
} }
var startIndex = window.indexOfFirst { it.position == position } var startIndex = window.indexOfFirst { it.position == requested }
if (startIndex < 0) { if (startIndex < 0) {
startIndex = window.indexOfFirst { it.position > position } startIndex = window.indexOfFirst { it.position > requested }
if (startIndex < 0) startIndex = window.lastIndex if (startIndex < 0) startIndex = window.lastIndex
} }
val actual = window[startIndex].position val actual = window[startIndex].position
@@ -1020,7 +1029,7 @@ class PlaybackController(
windowStart = window.first().position windowStart = window.first().position
windowEnd = window.last().position windowEnd = window.last().position
lastPosition = actual lastPosition = actual
if (actual != position) queueRepository.setCursor(actual) if (actual != requested) queueRepository.setCursor(actual)
updating = true updating = true
player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs) player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs)
updating = false updating = false
@@ -1045,7 +1054,7 @@ class PlaybackController(
val snapshot = snapshot ?: return@withLock val snapshot = snapshot ?: return@withLock
if (snapshot.size == 0L || player.isEnded) return@withLock if (snapshot.size == 0L || player.isEnded) return@withLock
downloads.awaitLoaded() downloads.awaitLoaded()
val position = (windowStart + player.currentIndex).coerceIn(0, snapshot.size - 1) val position = (currentPosition() ?: return@withLock).coerceIn(0, snapshot.size - 1)
val window = queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS) val window = queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS)
val uris = player.itemUris() val uris = player.itemUris()
if (window.size != player.itemCount || uris.size != window.size) { if (window.size != player.itemCount || uris.size != window.size) {
@@ -48,6 +48,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.SongItem import com.subtracks.data.model.SongItem
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.QUEUE_CHUNK import com.subtracks.data.repo.QUEUE_CHUNK
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.QueueWindowItem import com.subtracks.data.repo.QueueWindowItem
@@ -90,6 +91,8 @@ internal fun QueueRow.isPlaying(
class QueueViewModel( class QueueViewModel(
private val queueRepository: QueueRepository, private val queueRepository: QueueRepository,
private val playbackController: PlaybackController, private val playbackController: PlaybackController,
private val sourceRepository: SourceRepository,
private val downloads: DownloadRepository,
) : ViewModel() { ) : ViewModel() {
val rows = mutableStateListOf<QueueRow>() val rows = mutableStateListOf<QueueRow>()
@@ -107,6 +110,8 @@ class QueueViewModel(
private fun newRow(item: QueueWindowItem) = QueueRow(nextId++, item.position, item.item, item.upNext) private fun newRow(item: QueueWindowItem) = QueueRow(nextId++, item.position, item.item, item.upNext)
private fun visible(item: QueueWindowItem): Boolean = !sourceRepository.offline.value || downloads.localUri(item.item.song.id) != null
private fun reusedIds(): Map<String, MutableList<Long>> = private fun reusedIds(): Map<String, MutableList<Long>> =
rows.groupBy { it.song.song.id }.mapValues { entry -> entry.value.map { it.id }.toMutableList() } rows.groupBy { it.song.song.id }.mapValues { entry -> entry.value.map { it.id }.toMutableList() }
@@ -132,7 +137,7 @@ class QueueViewModel(
}?.coerceIn(0, snapshot.size - 1) ?: 0L }?.coerceIn(0, snapshot.size - 1) ?: 0L
val start = (cursor - OLDER_LOAD).coerceAtLeast(0) val start = (cursor - OLDER_LOAD).coerceAtLeast(0)
val end = (start + QUEUE_CHUNK - 1).coerceAtMost(snapshot.size - 1) val end = (start + QUEUE_CHUNK - 1).coerceAtMost(snapshot.size - 1)
rows.addAll(queueRepository.range(snapshot, start, end).map { rowFor(it, reused) }) rows.addAll(queueRepository.range(snapshot, start, end).filter(::visible).map { rowFor(it, reused) })
initialIndex = rows.indexOfFirst { it.position == cursor }.coerceAtLeast(0) initialIndex = rows.indexOfFirst { it.position == cursor }.coerceAtLeast(0)
} }
ready = true ready = true
@@ -152,9 +157,9 @@ class QueueViewModel(
rows.clear() rows.clear()
return return
} }
val start = (rows.firstOrNull()?.position ?: 0L).coerceIn(0, snapshot.size - 1) val start = (rows.minOfOrNull { it.position } ?: 0L).coerceIn(0, snapshot.size - 1)
val end = (start + rows.size.coerceAtLeast(QUEUE_CHUNK) - 1).coerceAtMost(snapshot.size - 1) val end = (rows.maxOfOrNull { it.position } ?: (start + QUEUE_CHUNK - 1)).coerceIn(start, snapshot.size - 1)
val loaded = queueRepository.range(snapshot, start, end) val loaded = queueRepository.range(snapshot, start, end).filter(::visible)
val reused = reusedIds() val reused = reusedIds()
val updated = loaded.map { rowFor(it, reused) } val updated = loaded.map { rowFor(it, reused) }
if (updated.size == rows.size) { if (updated.size == rows.size) {
@@ -173,9 +178,15 @@ class QueueViewModel(
val first = rows.minOfOrNull { it.position } ?: return@withLock val first = rows.minOfOrNull { it.position } ?: return@withLock
if (first <= 0L) return@withLock if (first <= 0L) return@withLock
val snapshot = queueRepository.snapshot() val snapshot = queueRepository.snapshot()
val loaded = queueRepository.range(snapshot, (first - QUEUE_CHUNK).coerceAtLeast(0), first - 1) val collected = mutableListOf<QueueWindowItem>()
if (loaded.isEmpty()) return@withLock var end = first - 1
rows.addAll(0, loaded.map(::newRow)) while (end >= 0L && collected.size < QUEUE_CHUNK) {
val start = (end - QUEUE_CHUNK + 1).coerceAtLeast(0)
collected.addAll(0, queueRepository.range(snapshot, start, end).filter(::visible))
end = start - 1
}
if (collected.isEmpty()) return@withLock
rows.addAll(0, collected.map(::newRow))
while (rows.size > QUEUE_WINDOW_ROWS) rows.removeAt(rows.size - 1) while (rows.size > QUEUE_WINDOW_ROWS) rows.removeAt(rows.size - 1)
} }
} }
@@ -188,10 +199,15 @@ class QueueViewModel(
val last = rows.maxOfOrNull { it.position } ?: return@withLock val last = rows.maxOfOrNull { it.position } ?: return@withLock
val snapshot = queueRepository.snapshot() val snapshot = queueRepository.snapshot()
if (last >= snapshot.size - 1) return@withLock if (last >= snapshot.size - 1) return@withLock
val to = (last + QUEUE_CHUNK).coerceAtMost(snapshot.size - 1) val collected = mutableListOf<QueueWindowItem>()
val loaded = queueRepository.range(snapshot, last + 1, to) var start = last + 1
if (loaded.isEmpty()) return@withLock while (start < snapshot.size && collected.size < QUEUE_CHUNK) {
rows.addAll(loaded.map(::newRow)) val to = (start + QUEUE_CHUNK - 1).coerceAtMost(snapshot.size - 1)
collected.addAll(queueRepository.range(snapshot, start, to).filter(::visible))
start = to + 1
}
if (collected.isEmpty()) return@withLock
rows.addAll(collected.map(::newRow))
while (rows.size > QUEUE_WINDOW_ROWS) rows.removeAt(0) while (rows.size > QUEUE_WINDOW_ROWS) rows.removeAt(0)
} }
} }
@@ -218,17 +234,7 @@ class QueueViewModel(
if (from == to) return if (from == to) return
viewModelScope.launch { viewModelScope.launch {
playbackController.move(from, to) playbackController.move(from, to)
mutex.withLock { mutex.withLock { reconcileLocked() }
val base = rows.minOfOrNull { it.position } ?: return@withLock
// The loaded order already reflects the move, so only the positions need updating.
val updated =
rows.mapIndexed { index, row ->
val position = base + index
if (row.position == position) row else row.copy(position = position)
}
rows.clear()
rows.addAll(updated)
}
} }
} }
@@ -256,9 +262,9 @@ class QueueViewModel(
rows.clear() rows.clear()
return return
} }
val start = (rows.firstOrNull()?.position ?: 0L).coerceIn(0, snapshot.size - 1) val start = (rows.minOfOrNull { it.position } ?: 0L).coerceIn(0, snapshot.size - 1)
val end = (start + rows.size.coerceAtLeast(QUEUE_CHUNK) - 1).coerceAtMost(snapshot.size - 1) val end = (rows.maxOfOrNull { it.position } ?: (start + QUEUE_CHUNK - 1)).coerceIn(start, snapshot.size - 1)
val loaded = queueRepository.range(snapshot, start, end) val loaded = queueRepository.range(snapshot, start, end).filter(::visible)
val reused = reusedIds() val reused = reusedIds()
val updated = loaded.map { rowFor(it, reused) } val updated = loaded.map { rowFor(it, reused) }
rows.clear() rows.clear()
@@ -2497,6 +2497,37 @@ class PlaybackControllerTest {
) )
} }
@Test
fun offlinePlayStartsAtTheNearestDownloadedWhenTheTargetIsNotDownloaded() {
seedAlbum(60, sourceId = 1)
markDownloaded(1, "s50")
setOffline()
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s50"
}
assertTrue(handle.items.isNotEmpty())
}
@Test
fun offlineShuffleStartsOnADownloadedTrack() {
seedAlbum(60, sourceId = 1)
markDownloaded(1, "s50")
markDownloaded(1, "s60")
setOffline()
controller.shuffleAlbum(1, "al1")
await {
controller.state.value.item
?.id in listOf("s50", "s60")
}
assertTrue(handle.items.isNotEmpty())
}
@Test @Test
fun offlinePlayAlbumFiltersTheWindowToDownloadedSongs() { fun offlinePlayAlbumFiltersTheWindowToDownloadedSongs() {
seedAlbum(4, sourceId = 1) seedAlbum(4, sourceId = 1)
@@ -10,8 +10,10 @@ import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.AlbumSongItem import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
@@ -24,10 +26,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -46,8 +50,9 @@ class QueueViewModelTest {
private lateinit var db: SubtracksDatabase private lateinit var db: SubtracksDatabase
private lateinit var sources: SourceRepository private lateinit var sources: SourceRepository
private lateinit var downloads: DownloadRepository
private lateinit var queues: QueueRepository private lateinit var queues: QueueRepository
private lateinit var downloads: DownloadRepository
private lateinit var downloadsDir: File
private lateinit var controller: PlaybackController private lateinit var controller: PlaybackController
private lateinit var viewModel: QueueViewModel private lateinit var viewModel: QueueViewModel
@@ -63,17 +68,18 @@ class QueueViewModelTest {
sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art"))) sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")))
queues = QueueRepository(db) queues = QueueRepository(db)
val artwork = ArtworkStore(File(context.cacheDir, "art")) val artwork = ArtworkStore(File(context.cacheDir, "art"))
downloadsDir = File(context.cacheDir, "downloads-${System.nanoTime()}")
downloads = downloads =
DownloadRepository( DownloadRepository(
db, db,
sources, sources,
FakeDownloadEngine(), FakeDownloadEngine(),
File(context.cacheDir, "downloads"), downloadsDir,
artworkStore = artwork, artworkStore = artwork,
artworkFetcher = { ByteArray(0) }, artworkFetcher = { ByteArray(0) },
) ).also { it.start() }
controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), downloads, dispatcher = dispatcher) controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), downloads, dispatcher = dispatcher)
viewModel = QueueViewModel(queues, controller) viewModel = QueueViewModel(queues, controller, sources, downloads)
} }
@After @After
@@ -411,6 +417,46 @@ class QueueViewModelTest {
extendTo(399, first = 220) extendTo(399, first = 220)
} }
@Test
fun offlineHidesRowsThatAreNotDownloaded() {
runBlocking { seedSongs(6) }
markDownloaded(1, "s2")
markDownloaded(1, "s4")
setOffline()
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s2"
}
viewModel.open()
await { viewModel.ready }
assertEquals(listOf("s2", "s4"), rowsSnapshot().map { it.song.song.id })
}
private fun markDownloaded(
sourceId: Long,
songId: String,
) {
val file = File(downloadsDir, "$sourceId/$songId")
runBlocking {
downloads.reconcile()
db.downloadDao().upsert(SongDownload(sourceId, songId, DownloadStatus.Completed))
file.parentFile?.mkdirs()
file.writeBytes(byteArrayOf(1))
downloads.reconcile()
}
await { downloads.localUri(songId) != null }
}
private fun setOffline() {
runBlocking {
sources.setOfflineMode(true)
withTimeout(5_000) { sources.offline.first { it } }
}
}
private fun row(position: Int) = private fun row(position: Int) =
QueueRow( QueueRow(
position.toLong(), position.toLong(),