mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
skip non-downloaded tracks in offline shuffle and the queue view
This commit is contained in:
@@ -90,7 +90,7 @@ fun appModule(
|
||||
viewModel { params -> AlbumDetailViewModel(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 { QueueViewModel(get(), get()) }
|
||||
viewModel { QueueViewModel(get(), get(), get(), get()) }
|
||||
}
|
||||
|
||||
private fun downloadsRoot(context: Context): File =
|
||||
|
||||
@@ -1003,15 +1003,24 @@ class PlaybackController(
|
||||
val player = player ?: return
|
||||
val snapshot = snapshot ?: return
|
||||
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
|
||||
if (window.isEmpty()) {
|
||||
stopLocked()
|
||||
return
|
||||
}
|
||||
var startIndex = window.indexOfFirst { it.position == position }
|
||||
var startIndex = window.indexOfFirst { it.position == requested }
|
||||
if (startIndex < 0) {
|
||||
startIndex = window.indexOfFirst { it.position > position }
|
||||
startIndex = window.indexOfFirst { it.position > requested }
|
||||
if (startIndex < 0) startIndex = window.lastIndex
|
||||
}
|
||||
val actual = window[startIndex].position
|
||||
@@ -1020,7 +1029,7 @@ class PlaybackController(
|
||||
windowStart = window.first().position
|
||||
windowEnd = window.last().position
|
||||
lastPosition = actual
|
||||
if (actual != position) queueRepository.setCursor(actual)
|
||||
if (actual != requested) queueRepository.setCursor(actual)
|
||||
updating = true
|
||||
player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs)
|
||||
updating = false
|
||||
@@ -1045,7 +1054,7 @@ class PlaybackController(
|
||||
val snapshot = snapshot ?: return@withLock
|
||||
if (snapshot.size == 0L || player.isEnded) return@withLock
|
||||
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 uris = player.itemUris()
|
||||
if (window.size != player.itemCount || uris.size != window.size) {
|
||||
|
||||
@@ -48,6 +48,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.SongItem
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.QUEUE_CHUNK
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.QueueWindowItem
|
||||
@@ -90,6 +91,8 @@ internal fun QueueRow.isPlaying(
|
||||
class QueueViewModel(
|
||||
private val queueRepository: QueueRepository,
|
||||
private val playbackController: PlaybackController,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val downloads: DownloadRepository,
|
||||
) : ViewModel() {
|
||||
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 visible(item: QueueWindowItem): Boolean = !sourceRepository.offline.value || downloads.localUri(item.item.song.id) != null
|
||||
|
||||
private fun reusedIds(): Map<String, MutableList<Long>> =
|
||||
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
|
||||
val start = (cursor - OLDER_LOAD).coerceAtLeast(0)
|
||||
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)
|
||||
}
|
||||
ready = true
|
||||
@@ -152,9 +157,9 @@ class QueueViewModel(
|
||||
rows.clear()
|
||||
return
|
||||
}
|
||||
val start = (rows.firstOrNull()?.position ?: 0L).coerceIn(0, snapshot.size - 1)
|
||||
val end = (start + rows.size.coerceAtLeast(QUEUE_CHUNK) - 1).coerceAtMost(snapshot.size - 1)
|
||||
val loaded = queueRepository.range(snapshot, start, end)
|
||||
val start = (rows.minOfOrNull { it.position } ?: 0L).coerceIn(0, 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).filter(::visible)
|
||||
val reused = reusedIds()
|
||||
val updated = loaded.map { rowFor(it, reused) }
|
||||
if (updated.size == rows.size) {
|
||||
@@ -173,9 +178,15 @@ class QueueViewModel(
|
||||
val first = rows.minOfOrNull { it.position } ?: return@withLock
|
||||
if (first <= 0L) return@withLock
|
||||
val snapshot = queueRepository.snapshot()
|
||||
val loaded = queueRepository.range(snapshot, (first - QUEUE_CHUNK).coerceAtLeast(0), first - 1)
|
||||
if (loaded.isEmpty()) return@withLock
|
||||
rows.addAll(0, loaded.map(::newRow))
|
||||
val collected = mutableListOf<QueueWindowItem>()
|
||||
var end = first - 1
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -188,10 +199,15 @@ class QueueViewModel(
|
||||
val last = rows.maxOfOrNull { it.position } ?: return@withLock
|
||||
val snapshot = queueRepository.snapshot()
|
||||
if (last >= snapshot.size - 1) return@withLock
|
||||
val to = (last + QUEUE_CHUNK).coerceAtMost(snapshot.size - 1)
|
||||
val loaded = queueRepository.range(snapshot, last + 1, to)
|
||||
if (loaded.isEmpty()) return@withLock
|
||||
rows.addAll(loaded.map(::newRow))
|
||||
val collected = mutableListOf<QueueWindowItem>()
|
||||
var start = last + 1
|
||||
while (start < snapshot.size && collected.size < QUEUE_CHUNK) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -218,17 +234,7 @@ class QueueViewModel(
|
||||
if (from == to) return
|
||||
viewModelScope.launch {
|
||||
playbackController.move(from, to)
|
||||
mutex.withLock {
|
||||
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)
|
||||
}
|
||||
mutex.withLock { reconcileLocked() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,9 +262,9 @@ class QueueViewModel(
|
||||
rows.clear()
|
||||
return
|
||||
}
|
||||
val start = (rows.firstOrNull()?.position ?: 0L).coerceIn(0, snapshot.size - 1)
|
||||
val end = (start + rows.size.coerceAtLeast(QUEUE_CHUNK) - 1).coerceAtMost(snapshot.size - 1)
|
||||
val loaded = queueRepository.range(snapshot, start, end)
|
||||
val start = (rows.minOfOrNull { it.position } ?: 0L).coerceIn(0, 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).filter(::visible)
|
||||
val reused = reusedIds()
|
||||
val updated = loaded.map { rowFor(it, reused) }
|
||||
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
|
||||
fun offlinePlayAlbumFiltersTheWindowToDownloadedSongs() {
|
||||
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.FakeDownloadEngine
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
@@ -24,10 +26,12 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -46,8 +50,9 @@ class QueueViewModelTest {
|
||||
|
||||
private lateinit var db: SubtracksDatabase
|
||||
private lateinit var sources: SourceRepository
|
||||
private lateinit var downloads: DownloadRepository
|
||||
private lateinit var queues: QueueRepository
|
||||
private lateinit var downloads: DownloadRepository
|
||||
private lateinit var downloadsDir: File
|
||||
private lateinit var controller: PlaybackController
|
||||
private lateinit var viewModel: QueueViewModel
|
||||
|
||||
@@ -63,17 +68,18 @@ class QueueViewModelTest {
|
||||
sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")))
|
||||
queues = QueueRepository(db)
|
||||
val artwork = ArtworkStore(File(context.cacheDir, "art"))
|
||||
downloadsDir = File(context.cacheDir, "downloads-${System.nanoTime()}")
|
||||
downloads =
|
||||
DownloadRepository(
|
||||
db,
|
||||
sources,
|
||||
FakeDownloadEngine(),
|
||||
File(context.cacheDir, "downloads"),
|
||||
downloadsDir,
|
||||
artworkStore = artwork,
|
||||
artworkFetcher = { ByteArray(0) },
|
||||
)
|
||||
).also { it.start() }
|
||||
controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), downloads, dispatcher = dispatcher)
|
||||
viewModel = QueueViewModel(queues, controller)
|
||||
viewModel = QueueViewModel(queues, controller, sources, downloads)
|
||||
}
|
||||
|
||||
@After
|
||||
@@ -411,6 +417,46 @@ class QueueViewModelTest {
|
||||
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) =
|
||||
QueueRow(
|
||||
position.toLong(),
|
||||
|
||||
Reference in New Issue
Block a user