mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +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 -> 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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user