mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
fix the offline queue mapping and cover it with tests
This commit is contained in:
@@ -1,3 +0,0 @@
|
|||||||
package com.subtracks.data.repo
|
|
||||||
|
|
||||||
internal class OfflineException : Exception("Offline mode is on")
|
|
||||||
@@ -225,7 +225,7 @@ class SourceRepository(
|
|||||||
useTokenAuth: Boolean,
|
useTokenAuth: Boolean,
|
||||||
): Result<Boolean> =
|
): Result<Boolean> =
|
||||||
if (_offline.value) {
|
if (_offline.value) {
|
||||||
Result.failure(OfflineException())
|
Result.failure(IllegalStateException("Offline mode is on"))
|
||||||
} else {
|
} else {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
var fellBack = false
|
var fellBack = false
|
||||||
|
|||||||
@@ -124,8 +124,7 @@ class PlaybackController(
|
|||||||
private var player: PlayerHandle? = null
|
private var player: PlayerHandle? = null
|
||||||
private var connecting = false
|
private var connecting = false
|
||||||
private var pendingPlay: PendingPlay? = null
|
private var pendingPlay: PendingPlay? = null
|
||||||
private var pendingShuffle: QueueEntry? = null
|
private var pendingShuffle: List<QueueEntry>? = null
|
||||||
private var pendingShuffleList: List<QueueEntry>? = null
|
|
||||||
private var positionJob: Job? = null
|
private var positionJob: Job? = null
|
||||||
private var windowJob: Job? = null
|
private var windowJob: Job? = null
|
||||||
private var bufferingJob: Job? = null
|
private var bufferingJob: Job? = null
|
||||||
@@ -144,8 +143,9 @@ class PlaybackController(
|
|||||||
private var repeatMode = RepeatMode.Off
|
private var repeatMode = RepeatMode.Off
|
||||||
private var endedHandled = false
|
private var endedHandled = false
|
||||||
private var lastSavedPositionMs = 0L
|
private var lastSavedPositionMs = 0L
|
||||||
private var offline = false
|
private val offline: Boolean get() = sourceRepository.offline.value
|
||||||
private var windowPositions = LongArray(0)
|
private var windowPositions = LongArray(0)
|
||||||
|
private var windowFiltered = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -154,11 +154,7 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
sourceRepository.offline.collect { value ->
|
sourceRepository.offline.drop(1).collect { reloadForOffline() }
|
||||||
if (value == offline) return@collect
|
|
||||||
offline = value
|
|
||||||
reloadForOffline()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
sourceRepository.quality.drop(1).collect {
|
sourceRepository.quality.drop(1).collect {
|
||||||
@@ -190,9 +186,8 @@ class PlaybackController(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
val pending = pendingPlay
|
val pending = pendingPlay
|
||||||
pendingPlay = null
|
pendingPlay = null
|
||||||
val shuffle = pendingShuffle?.let { listOf(it) } ?: pendingShuffleList
|
val shuffle = pendingShuffle
|
||||||
pendingShuffle = null
|
pendingShuffle = null
|
||||||
pendingShuffleList = null
|
|
||||||
when {
|
when {
|
||||||
pending != null -> start(pending.entries, pending.position, pending.disableShuffle)
|
pending != null -> start(pending.entries, pending.position, pending.disableShuffle)
|
||||||
shuffle != null -> startShuffled(shuffle)
|
shuffle != null -> startShuffled(shuffle)
|
||||||
@@ -287,7 +282,7 @@ class PlaybackController(
|
|||||||
|
|
||||||
private fun shufflePlay(entries: List<QueueEntry>) {
|
private fun shufflePlay(entries: List<QueueEntry>) {
|
||||||
if (player == null) {
|
if (player == null) {
|
||||||
if (entries.size == 1) pendingShuffle = entries.first() else pendingShuffleList = entries
|
pendingShuffle = entries
|
||||||
connect()
|
connect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1022,6 +1017,8 @@ class PlaybackController(
|
|||||||
queueSourceId = null
|
queueSourceId = null
|
||||||
windowStart = 0
|
windowStart = 0
|
||||||
windowEnd = -1
|
windowEnd = -1
|
||||||
|
windowPositions = LongArray(0)
|
||||||
|
windowFiltered = false
|
||||||
lastPosition = null
|
lastPosition = null
|
||||||
lastEdit = null
|
lastEdit = null
|
||||||
player?.run {
|
player?.run {
|
||||||
@@ -1054,6 +1051,7 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
val actual = window[startIndex].position
|
val actual = window[startIndex].position
|
||||||
windowPositions = LongArray(window.size) { window[it].position }
|
windowPositions = LongArray(window.size) { window[it].position }
|
||||||
|
windowFiltered = offline
|
||||||
windowStart = window.first().position
|
windowStart = window.first().position
|
||||||
windowEnd = window.last().position
|
windowEnd = window.last().position
|
||||||
lastPosition = actual
|
lastPosition = actual
|
||||||
@@ -1262,18 +1260,18 @@ class PlaybackController(
|
|||||||
val player = player ?: return null
|
val player = player ?: return null
|
||||||
if (player.itemCount == 0) return null
|
if (player.itemCount == 0) return null
|
||||||
val index = player.currentIndex
|
val index = player.currentIndex
|
||||||
return if (offline) windowPositions.getOrNull(index) ?: (windowStart + index) else (windowStart + index)
|
return if (windowFiltered) windowPositions.getOrNull(index) ?: (windowStart + index) else (windowStart + index)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun windowIndexOf(position: Long): Int =
|
private fun windowIndexOf(position: Long): Int =
|
||||||
if (offline) {
|
if (windowFiltered) {
|
||||||
windowPositions.indexOf(position)
|
windowPositions.indexOf(position)
|
||||||
} else {
|
} else {
|
||||||
(position - windowStart).toInt()
|
(position - windowStart).toInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun inWindow(position: Long): Boolean =
|
private fun inWindow(position: Long): Boolean =
|
||||||
if (offline) {
|
if (windowFiltered) {
|
||||||
windowPositions.contains(position)
|
windowPositions.contains(position)
|
||||||
} else {
|
} else {
|
||||||
position in windowStart..windowEnd
|
position in windowStart..windowEnd
|
||||||
@@ -1309,7 +1307,7 @@ class PlaybackController(
|
|||||||
startLock.withLock {
|
startLock.withLock {
|
||||||
val player = player ?: return@withLock
|
val player = player ?: return@withLock
|
||||||
val snapshot = snapshot ?: return@withLock
|
val snapshot = snapshot ?: return@withLock
|
||||||
if (snapshot.size == 0L || player.isEnded) return@withLock
|
if (snapshot.size == 0L) return@withLock
|
||||||
downloads.awaitLoaded()
|
downloads.awaitLoaded()
|
||||||
val current = currentPosition() ?: return@withLock
|
val current = currentPosition() ?: return@withLock
|
||||||
val positionMs = player.currentPositionMs
|
val positionMs = player.currentPositionMs
|
||||||
@@ -1362,6 +1360,18 @@ class PlaybackController(
|
|||||||
private fun handleEnded() {
|
private fun handleEnded() {
|
||||||
if (endedHandled) return
|
if (endedHandled) return
|
||||||
endedHandled = true
|
endedHandled = true
|
||||||
|
if (offline) {
|
||||||
|
scope.launch {
|
||||||
|
val snapshot = snapshot ?: return@launch
|
||||||
|
val current = currentPosition() ?: return@launch
|
||||||
|
downloads.awaitLoaded()
|
||||||
|
val target =
|
||||||
|
downloadedAtOrAfter(snapshot, current + 1)
|
||||||
|
?: if (repeatMode == RepeatMode.All) downloadedAtOrAfter(snapshot, 0L) else null
|
||||||
|
target?.let { jumpTo(it) }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (repeatMode == RepeatMode.All) jumpTo(0)
|
if (repeatMode == RepeatMode.All) jumpTo(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1472,6 +1482,9 @@ class PlaybackController(
|
|||||||
const val RESTART_THRESHOLD_MS = 3_000L
|
const val RESTART_THRESHOLD_MS = 3_000L
|
||||||
const val SEED_PIN_TRIES = 1_000_000
|
const val SEED_PIN_TRIES = 1_000_000
|
||||||
const val FILE_SCHEME = "file:"
|
const val FILE_SCHEME = "file:"
|
||||||
|
|
||||||
|
// ponytail: walks the queue one row at a time to find the next downloaded track; swap for a
|
||||||
|
// single query over the downloaded ids if a sparse queue ever makes the scan noticeable.
|
||||||
const val OFFLINE_SCAN_LIMIT = 10_000L
|
const val OFFLINE_SCAN_LIMIT = 10_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ fun LibraryRoute(
|
|||||||
LibraryTab.entries.associateWith { tab ->
|
LibraryTab.entries.associateWith { tab ->
|
||||||
val query by viewModel.listQuery(tab.listTab()).collectAsStateWithLifecycle()
|
val query by viewModel.listQuery(tab.listTab()).collectAsStateWithLifecycle()
|
||||||
val term by viewModel.search(tab.listTab()).collectAsStateWithLifecycle()
|
val term by viewModel.search(tab.listTab()).collectAsStateWithLifecycle()
|
||||||
"${query.sort}|${query.descending}|${query.starred}|${query.downloaded || offline}|$term|$offline"
|
"${query.sort}|${query.descending}|${query.starred}|${query.downloaded || offline}|$term"
|
||||||
}
|
}
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true))
|
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true))
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ 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.Album
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.DownloadStatus
|
||||||
import com.subtracks.data.model.QueueEntry
|
import com.subtracks.data.model.QueueEntry
|
||||||
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.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
@@ -25,9 +27,11 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.asCoroutineDispatcher
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
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
|
||||||
@@ -52,6 +56,7 @@ class PlaybackControllerTest {
|
|||||||
private lateinit var sources: SourceRepository
|
private lateinit var sources: SourceRepository
|
||||||
private lateinit var queues: QueueRepository
|
private lateinit var queues: QueueRepository
|
||||||
private lateinit var downloads: DownloadRepository
|
private lateinit var downloads: DownloadRepository
|
||||||
|
private lateinit var downloadsDir: File
|
||||||
private lateinit var handle: FakePlayerHandle
|
private lateinit var handle: FakePlayerHandle
|
||||||
private lateinit var controller: PlaybackController
|
private lateinit var controller: PlaybackController
|
||||||
private val messages = CopyOnWriteArrayList<String>()
|
private val messages = CopyOnWriteArrayList<String>()
|
||||||
@@ -68,15 +73,16 @@ class PlaybackControllerTest {
|
|||||||
networkMode = MutableStateFlow(NetworkMode.Wifi)
|
networkMode = MutableStateFlow(NetworkMode.Wifi)
|
||||||
sources = SourceRepository(db, OkHttpClient(), prefs, ArtworkStore(File(context.cacheDir, "art")), networkMode = networkMode)
|
sources = SourceRepository(db, OkHttpClient(), prefs, ArtworkStore(File(context.cacheDir, "art")), networkMode = networkMode)
|
||||||
queues = QueueRepository(db)
|
queues = QueueRepository(db)
|
||||||
|
downloadsDir = File(context.cacheDir, "downloads")
|
||||||
downloads =
|
downloads =
|
||||||
DownloadRepository(
|
DownloadRepository(
|
||||||
db,
|
db,
|
||||||
sources,
|
sources,
|
||||||
FakeDownloadEngine(),
|
FakeDownloadEngine(),
|
||||||
File(context.cacheDir, "downloads"),
|
downloadsDir,
|
||||||
artworkStore = ArtworkStore(File(context.cacheDir, "art")),
|
artworkStore = ArtworkStore(File(context.cacheDir, "art")),
|
||||||
artworkFetcher = { ByteArray(0) },
|
artworkFetcher = { ByteArray(0) },
|
||||||
)
|
).also { it.start() }
|
||||||
handle = FakePlayerHandle()
|
handle = FakePlayerHandle()
|
||||||
controller =
|
controller =
|
||||||
PlaybackController(
|
PlaybackController(
|
||||||
@@ -2451,6 +2457,191 @@ class PlaybackControllerTest {
|
|||||||
assertEquals(windows, handle.operations.count { it.startsWith("setWindow") })
|
assertEquals(windows, handle.operations.count { it.startsWith("setWindow") })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun togglingOnlineKeepsTheCurrentDownloadedTrack() {
|
||||||
|
seedAlbum(5, sourceId = 1)
|
||||||
|
markDownloaded(1, "s1")
|
||||||
|
markDownloaded(1, "s3")
|
||||||
|
markDownloaded(1, "s5")
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s1"
|
||||||
|
}
|
||||||
|
|
||||||
|
setOffline()
|
||||||
|
await { handle.items.map { it.id } == listOf("s1", "s3", "s5") }
|
||||||
|
|
||||||
|
handle.advanceTo(2)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s5"
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
"s5",
|
||||||
|
controller.state.value.item
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
handle.operations.clear()
|
||||||
|
setOnline()
|
||||||
|
|
||||||
|
await { handle.operations.any { it.startsWith("setWindow") } }
|
||||||
|
assertEquals(
|
||||||
|
"s5",
|
||||||
|
controller.state.value.item
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun offlinePlayAlbumBuildsADownloadedOnlyQueue() {
|
||||||
|
seedAlbum(4, sourceId = 1)
|
||||||
|
markDownloaded(1, "s1")
|
||||||
|
markDownloaded(1, "s3")
|
||||||
|
setOffline()
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
|
||||||
|
await { handle.items.map { it.id } == listOf("s1", "s3") }
|
||||||
|
assertEquals(listOf("s1", "s3"), handle.items.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun togglingOfflineKeepsADownloadedCurrentTrack() {
|
||||||
|
seedAlbum(4, sourceId = 1)
|
||||||
|
(1..4).forEach { markDownloaded(1, "s$it") }
|
||||||
|
controller.playAlbum(1, "al1", 1)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s2"
|
||||||
|
}
|
||||||
|
handle.operations.clear()
|
||||||
|
|
||||||
|
setOffline()
|
||||||
|
|
||||||
|
await { handle.operations.any { it.startsWith("setWindow") } }
|
||||||
|
assertEquals(
|
||||||
|
"s2",
|
||||||
|
controller.state.value.item
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun togglingOfflineSkipsANonDownloadedCurrentTrack() {
|
||||||
|
seedAlbum(4, sourceId = 1)
|
||||||
|
markDownloaded(1, "s3")
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s1"
|
||||||
|
}
|
||||||
|
|
||||||
|
setOffline()
|
||||||
|
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s3"
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
"s3",
|
||||||
|
controller.state.value.item
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun offlineNextCrossesTheLoadedWindowBoundary() {
|
||||||
|
seedAlbum(30, sourceId = 1)
|
||||||
|
(1..30).forEach { markDownloaded(1, "s$it") }
|
||||||
|
setOffline()
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s1"
|
||||||
|
}
|
||||||
|
assertTrue("expected a bounded window, was ${handle.itemCount}", handle.itemCount < 30)
|
||||||
|
|
||||||
|
handle.advanceTo(handle.itemCount - 1)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s26"
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.next()
|
||||||
|
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s27"
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
"s27",
|
||||||
|
controller.state.value.item
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun offlineEndedPlaybackAdvancesPastTheLoadedWindow() {
|
||||||
|
seedAlbum(30, sourceId = 1)
|
||||||
|
(1..30).forEach { markDownloaded(1, "s$it") }
|
||||||
|
setOffline()
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s1"
|
||||||
|
}
|
||||||
|
handle.advanceTo(handle.itemCount - 1)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s26"
|
||||||
|
}
|
||||||
|
|
||||||
|
handle.finish()
|
||||||
|
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s27"
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
"s27",
|
||||||
|
controller.state.value.item
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markDownloaded(
|
||||||
|
sourceId: Long,
|
||||||
|
songId: String,
|
||||||
|
) {
|
||||||
|
val file = File(downloadsDir, "$sourceId/$songId")
|
||||||
|
val deadline = System.nanoTime() + 5_000_000_000L
|
||||||
|
while (System.nanoTime() < deadline) {
|
||||||
|
runBlocking {
|
||||||
|
db.downloadDao().upsert(SongDownload(sourceId, songId, DownloadStatus.Completed))
|
||||||
|
file.parentFile?.mkdirs()
|
||||||
|
file.writeBytes(byteArrayOf(1))
|
||||||
|
}
|
||||||
|
if (downloads.localUri(songId) != null) return
|
||||||
|
Thread.sleep(10)
|
||||||
|
}
|
||||||
|
assertTrue("Timed out marking $songId downloaded", downloads.localUri(songId) != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setOffline() {
|
||||||
|
sources.setOfflineMode(true)
|
||||||
|
runBlocking { withTimeout(5_000) { sources.offline.first { it } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setOnline() {
|
||||||
|
sources.setOfflineMode(false)
|
||||||
|
runBlocking { withTimeout(5_000) { sources.offline.first { !it } } }
|
||||||
|
}
|
||||||
|
|
||||||
private fun seedAlbum(
|
private fun seedAlbum(
|
||||||
count: Int,
|
count: Int,
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
|
|||||||
Reference in New Issue
Block a user