mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
add an offline mode that stays on the downloaded library
This commit is contained in:
@@ -26,6 +26,10 @@ internal const val PLAYLIST_SONGS_ORDER = " ORDER BY playlist_songs.position"
|
||||
|
||||
internal const val PLAYLIST_SONGS_SQL = PLAYLIST_SONGS_SELECT + PLAYLIST_SONGS_ORDER
|
||||
|
||||
internal const val DOWNLOADED_SONG =
|
||||
"EXISTS (SELECT 1 FROM song_downloads sd WHERE sd.sourceId = playlist_songs.sourceId " +
|
||||
"AND sd.songId = playlist_songs.songId AND sd.status = 'Completed')"
|
||||
|
||||
internal const val ALBUMS_FILTER =
|
||||
"FROM albums WHERE sourceId = :sourceId " +
|
||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
||||
@@ -455,4 +459,55 @@ interface LibraryDao {
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
): Flow<List<Song>>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.* FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.albumId = :albumId " +
|
||||
"ORDER BY songs.disc, songs.track, songs.id",
|
||||
)
|
||||
fun songsByAlbumDownloaded(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
): Flow<List<Song>>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.id FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.albumId = :albumId " +
|
||||
"ORDER BY songs.disc, songs.track, songs.id",
|
||||
)
|
||||
suspend fun downloadedAlbumSongIds(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
): List<String>
|
||||
|
||||
@Query(
|
||||
"SELECT playlist_songs.songId FROM playlist_songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = playlist_songs.sourceId " +
|
||||
"AND sd.songId = playlist_songs.songId AND sd.status = 'Completed' " +
|
||||
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " +
|
||||
"ORDER BY playlist_songs.position",
|
||||
)
|
||||
suspend fun downloadedPlaylistSongIds(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
): List<String>
|
||||
|
||||
@Query("$PLAYLIST_SONGS_SELECT AND $DOWNLOADED_SONG$PLAYLIST_SONGS_ORDER")
|
||||
fun playlistSongsDownloaded(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
): PagingSource<Int, PlaylistSongItem>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND artistId = :artistId " +
|
||||
"AND EXISTS (SELECT 1 FROM songs JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"ORDER BY year DESC, name COLLATE NOCASE, id",
|
||||
)
|
||||
fun albumsForArtistDownloaded(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<List<Album>>
|
||||
}
|
||||
|
||||
@@ -113,6 +113,14 @@ class UserPreferences(
|
||||
|
||||
private val scrobblingKey = booleanPreferencesKey("scrobbling")
|
||||
|
||||
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
|
||||
|
||||
suspend fun setOfflineMode(enabled: Boolean) {
|
||||
store.edit { prefs -> prefs[offlineModeKey] = enabled }
|
||||
}
|
||||
|
||||
private val offlineModeKey = booleanPreferencesKey("offline_mode")
|
||||
|
||||
suspend fun lastSeed(): ArtworkSeedValue? {
|
||||
val prefs = store.data.first()
|
||||
val cacheKey = prefs[lastSeedKey] ?: return null
|
||||
|
||||
@@ -184,7 +184,13 @@ class LibraryRepository(
|
||||
fun artistAlbums(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<List<Album>> = db.libraryDao().albumsForArtist(sourceId, artistId)
|
||||
downloaded: Boolean = false,
|
||||
): Flow<List<Album>> =
|
||||
if (downloaded) {
|
||||
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
|
||||
} else {
|
||||
db.libraryDao().albumsForArtist(sourceId, artistId)
|
||||
}
|
||||
|
||||
fun albumDiscs(
|
||||
sourceId: Long,
|
||||
@@ -194,7 +200,13 @@ class LibraryRepository(
|
||||
fun albumSongs(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
): Flow<List<Song>> = db.libraryDao().songsByAlbum(sourceId, albumId)
|
||||
downloaded: Boolean = false,
|
||||
): Flow<List<Song>> =
|
||||
if (downloaded) {
|
||||
db.libraryDao().songsByAlbumDownloaded(sourceId, albumId)
|
||||
} else {
|
||||
db.libraryDao().songsByAlbum(sourceId, albumId)
|
||||
}
|
||||
|
||||
fun playlist(
|
||||
sourceId: Long,
|
||||
@@ -204,7 +216,15 @@ class LibraryRepository(
|
||||
fun playlistSongs(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
): Flow<PagingData<PlaylistSongItem>> = pager(60) { db.libraryDao().playlistSongs(sourceId, playlistId) }
|
||||
downloaded: Boolean = false,
|
||||
): Flow<PagingData<PlaylistSongItem>> =
|
||||
pager(60) {
|
||||
if (downloaded) {
|
||||
db.libraryDao().playlistSongsDownloaded(sourceId, playlistId)
|
||||
} else {
|
||||
db.libraryDao().playlistSongs(sourceId, playlistId)
|
||||
}
|
||||
}
|
||||
|
||||
private val starLock = Mutex()
|
||||
private val starScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.subtracks.data.repo
|
||||
|
||||
internal class OfflineException : Exception("Offline mode is on")
|
||||
@@ -250,6 +250,19 @@ class QueueRepository(
|
||||
songId: String,
|
||||
) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Song, refId = songId)
|
||||
|
||||
suspend fun downloadedSongIds(
|
||||
sourceId: Long,
|
||||
kind: QueueKind,
|
||||
refId: String,
|
||||
): List<String> {
|
||||
val library = db.libraryDao()
|
||||
return when (kind) {
|
||||
QueueKind.Album -> library.downloadedAlbumSongIds(sourceId, refId)
|
||||
QueueKind.Playlist -> library.downloadedPlaylistSongIds(sourceId, refId)
|
||||
QueueKind.Song -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sourceName(
|
||||
kind: QueueKind,
|
||||
sourceId: Long,
|
||||
|
||||
@@ -55,8 +55,15 @@ class SourceRepository(
|
||||
@Volatile
|
||||
private var allowMeteredDownloads = false
|
||||
|
||||
private val _offline = MutableStateFlow(false)
|
||||
val offline: StateFlow<Boolean> = _offline
|
||||
|
||||
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
||||
|
||||
fun setOfflineMode(enabled: Boolean) {
|
||||
scope.launch { prefs.setOfflineMode(enabled) }
|
||||
}
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
prefs.downloadQuality().collect { downloadQuality = it }
|
||||
@@ -64,6 +71,9 @@ class SourceRepository(
|
||||
scope.launch {
|
||||
prefs.downloadOverMetered().collect { allowMeteredDownloads = it }
|
||||
}
|
||||
scope.launch {
|
||||
prefs.offlineMode().collect { _offline.value = it }
|
||||
}
|
||||
scope.launch {
|
||||
combine(
|
||||
db.sourcesDao().activeSubsonicConfig(),
|
||||
@@ -78,7 +88,7 @@ class SourceRepository(
|
||||
_quality.value = quality
|
||||
val sourceChanged = config?.id != activeSourceId
|
||||
activeSourceId = config?.id
|
||||
if (sourceChanged && config != null && config.useTokenAuth) {
|
||||
if (!_offline.value && sourceChanged && config != null && config.useTokenAuth) {
|
||||
scope.launch { runCatching { config.toClient().check("ping") } }
|
||||
}
|
||||
}
|
||||
@@ -107,6 +117,7 @@ class SourceRepository(
|
||||
if (coverArt == null) return null
|
||||
val cacheKey = coverArtKey(source.id, coverArt, thumbnail)
|
||||
artworkStore.uri(source.id, cacheKey)?.let { return CoverArtRef(url = it, cacheKey = cacheKey) }
|
||||
if (_offline.value) return null
|
||||
val url = source.coverArtUri(coverArt, thumbnail)?.toString() ?: return null
|
||||
return CoverArtRef(url = url, cacheKey = cacheKey)
|
||||
}
|
||||
@@ -116,6 +127,7 @@ class SourceRepository(
|
||||
coverArt: String,
|
||||
thumbnail: Boolean,
|
||||
): String? {
|
||||
if (_offline.value) return null
|
||||
val source = active ?: return null
|
||||
if (source.id != sourceId) return null
|
||||
return source.coverArtUri(coverArt, thumbnail)?.toString()
|
||||
@@ -126,6 +138,7 @@ class SourceRepository(
|
||||
durationMs: Long?,
|
||||
quality: StreamQuality,
|
||||
): String? {
|
||||
if (_offline.value) return null
|
||||
val uri = active?.streamUri(songId)?.toString() ?: return null
|
||||
val length = declaredStreamLength(durationMs, quality) ?: return uri
|
||||
return uri + streamLengthSuffix(length)
|
||||
@@ -135,6 +148,7 @@ class SourceRepository(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
): String? {
|
||||
if (_offline.value) return null
|
||||
val source = active?.takeIf { activeSourceId == sourceId } ?: return null
|
||||
// A transcode preference downloads the stream, so the saved file is already the wanted
|
||||
// quality rather than the original that would then have to be transcoded on every play.
|
||||
@@ -210,14 +224,19 @@ class SourceRepository(
|
||||
password: String,
|
||||
useTokenAuth: Boolean,
|
||||
): Result<Boolean> =
|
||||
withContext(Dispatchers.IO) {
|
||||
var fellBack = false
|
||||
runCatching {
|
||||
client(address, username, password, useTokenAuth) { fellBack = true }.check("ping")
|
||||
}.map { fellBack }
|
||||
if (_offline.value) {
|
||||
Result.failure(OfflineException())
|
||||
} else {
|
||||
withContext(Dispatchers.IO) {
|
||||
var fellBack = false
|
||||
runCatching {
|
||||
client(address, username, password, useTokenAuth) { fellBack = true }.check("ping")
|
||||
}.map { fellBack }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun activeMusicSource(): MusicSource? {
|
||||
if (_offline.value) return null
|
||||
val config = db.sourcesDao().activeSubsonicConfigOnce() ?: return null
|
||||
return config.toMusicSource(quality.value, prefs.syncConcurrency().first())
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ class SyncManager(
|
||||
}
|
||||
|
||||
private suspend fun runSync() {
|
||||
if (sourceRepository.offline.value) {
|
||||
showMessage("Offline mode is on")
|
||||
_status.value = SyncStatus.Failed("Offline mode is on")
|
||||
return
|
||||
}
|
||||
_status.value = SyncStatus.Running
|
||||
val result =
|
||||
try {
|
||||
|
||||
@@ -125,6 +125,7 @@ class PlaybackController(
|
||||
private var connecting = false
|
||||
private var pendingPlay: PendingPlay? = null
|
||||
private var pendingShuffle: QueueEntry? = null
|
||||
private var pendingShuffleList: List<QueueEntry>? = null
|
||||
private var positionJob: Job? = null
|
||||
private var windowJob: Job? = null
|
||||
private var bufferingJob: Job? = null
|
||||
@@ -143,6 +144,8 @@ class PlaybackController(
|
||||
private var repeatMode = RepeatMode.Off
|
||||
private var endedHandled = false
|
||||
private var lastSavedPositionMs = 0L
|
||||
private var offline = false
|
||||
private var windowPositions = LongArray(0)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
@@ -150,6 +153,13 @@ class PlaybackController(
|
||||
if (queueSourceId != null && sourceId != queueSourceId) stop()
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
sourceRepository.offline.collect { value ->
|
||||
if (value == offline) return@collect
|
||||
offline = value
|
||||
reloadForOffline()
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
sourceRepository.quality.drop(1).collect {
|
||||
try {
|
||||
@@ -180,8 +190,9 @@ class PlaybackController(
|
||||
scope.launch {
|
||||
val pending = pendingPlay
|
||||
pendingPlay = null
|
||||
val shuffle = pendingShuffle
|
||||
val shuffle = pendingShuffle?.let { listOf(it) } ?: pendingShuffleList
|
||||
pendingShuffle = null
|
||||
pendingShuffleList = null
|
||||
when {
|
||||
pending != null -> start(pending.entries, pending.position, pending.disableShuffle)
|
||||
shuffle != null -> startShuffled(shuffle)
|
||||
@@ -203,45 +214,89 @@ class PlaybackController(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
startOrdinal: Long,
|
||||
) = play(listOf(queueRepository.albumEntry(sourceId, albumId)), startOrdinal)
|
||||
) = playContext(queueRepository.albumEntry(sourceId, albumId), startOrdinal)
|
||||
|
||||
fun playPlaylist(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
startPosition: Long,
|
||||
) = play(listOf(queueRepository.playlistEntry(sourceId, playlistId)), startPosition)
|
||||
) = playContext(queueRepository.playlistEntry(sourceId, playlistId), startPosition)
|
||||
|
||||
fun playAlbumInOrder(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
) = play(listOf(queueRepository.albumEntry(sourceId, albumId)), 0, disableShuffle = true)
|
||||
) = playContext(queueRepository.albumEntry(sourceId, albumId), 0, disableShuffle = true)
|
||||
|
||||
fun playPlaylistInOrder(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
) = play(listOf(queueRepository.playlistEntry(sourceId, playlistId)), 0, disableShuffle = true)
|
||||
) = playContext(queueRepository.playlistEntry(sourceId, playlistId), 0, disableShuffle = true)
|
||||
|
||||
fun shuffleAlbum(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
) = shufflePlay(queueRepository.albumEntry(sourceId, albumId))
|
||||
) = shuffleContext(queueRepository.albumEntry(sourceId, albumId))
|
||||
|
||||
fun shufflePlaylist(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
) = shufflePlay(queueRepository.playlistEntry(sourceId, playlistId))
|
||||
) = shuffleContext(queueRepository.playlistEntry(sourceId, playlistId))
|
||||
|
||||
private fun shufflePlay(entry: QueueEntry) {
|
||||
private fun playContext(
|
||||
entry: QueueEntry,
|
||||
start: Long,
|
||||
disableShuffle: Boolean = false,
|
||||
) {
|
||||
if (!offline) {
|
||||
play(listOf(entry), start, disableShuffle)
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
val entries = downloadedEntries(entry)
|
||||
if (entries.isEmpty()) {
|
||||
showMessage("Nothing downloaded for offline playback")
|
||||
return@launch
|
||||
}
|
||||
play(entries, start, disableShuffle = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shuffleContext(entry: QueueEntry) {
|
||||
if (!offline) {
|
||||
shufflePlay(listOf(entry))
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
val entries = downloadedEntries(entry)
|
||||
if (entries.isEmpty()) {
|
||||
showMessage("Nothing downloaded for offline playback")
|
||||
return@launch
|
||||
}
|
||||
shufflePlay(entries)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadedEntries(entry: QueueEntry): List<QueueEntry> {
|
||||
if (entry.kind == QueueKind.Song) {
|
||||
return if (downloads.localUri(entry.refId) != null) listOf(entry) else emptyList()
|
||||
}
|
||||
return queueRepository
|
||||
.downloadedSongIds(entry.sourceId, entry.kind, entry.refId)
|
||||
.map { QueueEntry(position = 0, sourceId = entry.sourceId, kind = QueueKind.Song, refId = it) }
|
||||
}
|
||||
|
||||
private fun shufflePlay(entries: List<QueueEntry>) {
|
||||
if (player == null) {
|
||||
pendingShuffle = entry
|
||||
if (entries.size == 1) pendingShuffle = entries.first() else pendingShuffleList = entries
|
||||
connect()
|
||||
return
|
||||
}
|
||||
scope.launch { startShuffled(entry) }
|
||||
scope.launch { startShuffled(entries) }
|
||||
}
|
||||
|
||||
private suspend fun startShuffled(entry: QueueEntry) =
|
||||
private suspend fun startShuffled(entries: List<QueueEntry>) =
|
||||
startLock.withLock {
|
||||
val entry = entries.first()
|
||||
windowJob?.cancel()
|
||||
windowStart = 0
|
||||
windowEnd = -1
|
||||
@@ -251,7 +306,7 @@ class PlaybackController(
|
||||
val previousEntry = previous?.entries?.firstOrNull()?.entry
|
||||
val same = previousEntry != null && previousEntry.kind == entry.kind && previousEntry.refId == entry.refId
|
||||
val avoid = if (same) currentPosition()?.let { previous.flatContext(previous.anchorContextPlay(it)) } else null
|
||||
queueRepository.replace(listOf(entry))
|
||||
queueRepository.replace(entries)
|
||||
var snap = queueRepository.snapshot()
|
||||
if (snap.contextSize == 0L) {
|
||||
stopLocked()
|
||||
@@ -303,20 +358,25 @@ class PlaybackController(
|
||||
this@PlaybackController.snapshot = snapshot
|
||||
layoutVersion++
|
||||
}
|
||||
val target = anchor.position.coerceIn(0, snapshot.size - 1)
|
||||
val requested = anchor.position.coerceIn(0, snapshot.size - 1)
|
||||
downloads.awaitLoaded()
|
||||
val target =
|
||||
if (offline) {
|
||||
downloadedAtOrAfter(snapshot, requested) ?: return@withLock
|
||||
} else {
|
||||
requested
|
||||
}
|
||||
windowJob?.cancel()
|
||||
queueRepository.setCursor(target)
|
||||
if (anchor.changed) {
|
||||
if (anchor.changed || !inWindow(target)) {
|
||||
loadWindow(target, autoplay = true)
|
||||
} else if (target in windowStart..windowEnd) {
|
||||
} else {
|
||||
player.ensurePrepared()
|
||||
lastPosition = target
|
||||
player.seekToIndex((target - windowStart).toInt())
|
||||
player.seekToIndex(windowIndexOf(target))
|
||||
player.play()
|
||||
shiftWindowLocked(target)
|
||||
refresh(target)
|
||||
} else {
|
||||
loadWindow(target, autoplay = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,6 +453,12 @@ class PlaybackController(
|
||||
} else {
|
||||
RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
||||
}
|
||||
if (offline) {
|
||||
queueRepository.setCursor(target)
|
||||
loadWindow(target, autoplay = playing, startPositionMs = 0)
|
||||
refresh(target)
|
||||
return@withLock
|
||||
}
|
||||
if (snapshot.upNextSize > 0L || updated.upNextSize > 0L || snapshot.shuffled) {
|
||||
queueRepository.setCursor(target)
|
||||
if (position != current) {
|
||||
@@ -480,6 +546,12 @@ class PlaybackController(
|
||||
} else {
|
||||
ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
||||
}
|
||||
if (offline) {
|
||||
queueRepository.setCursor(target)
|
||||
loadWindow(target, autoplay = player.playWhenReady, startPositionMs = player.currentPositionMs)
|
||||
refresh(target)
|
||||
return@withLock
|
||||
}
|
||||
when {
|
||||
fromInWindow && toInWindow -> {
|
||||
updating = true
|
||||
@@ -531,6 +603,12 @@ class PlaybackController(
|
||||
lastPosition = target
|
||||
val restoredId = queueRepository.itemAt(restored, target)?.song?.id
|
||||
windowJob?.cancel()
|
||||
if (offline) {
|
||||
loadWindow(target, autoplay = playing, startPositionMs = 0)
|
||||
queueRepository.setCursor(target)
|
||||
refresh(target)
|
||||
return@withLock
|
||||
}
|
||||
val canMirror =
|
||||
undo.current != null &&
|
||||
player.currentItem?.id != null &&
|
||||
@@ -614,6 +692,16 @@ class PlaybackController(
|
||||
scope.launch { consumeUpNext(current) }
|
||||
return
|
||||
}
|
||||
if (offline) {
|
||||
scope.launch {
|
||||
downloads.awaitLoaded()
|
||||
val target =
|
||||
downloadedAtOrAfter(snapshot, current + 1)
|
||||
?: if (repeatMode != RepeatMode.Off) downloadedAtOrAfter(snapshot, 0L) else null
|
||||
target?.let { jumpTo(it) }
|
||||
}
|
||||
return
|
||||
}
|
||||
when {
|
||||
current < snapshot.size - 1 -> jumpTo(current + 1)
|
||||
repeatMode != RepeatMode.Off -> jumpTo(0)
|
||||
@@ -633,6 +721,10 @@ class PlaybackController(
|
||||
endedHandled = false
|
||||
val next = if (position < updated.size) position else 0L
|
||||
queueRepository.setCursor(next)
|
||||
if (offline) {
|
||||
loadWindow(next, autoplay = true)
|
||||
return@withLock
|
||||
}
|
||||
val player = player
|
||||
if (next == position && player != null && position - windowStart in 0 until player.itemCount) {
|
||||
updating = true
|
||||
@@ -686,6 +778,21 @@ class PlaybackController(
|
||||
val snapshot = snapshot ?: return
|
||||
if (snapshot.size == 0L) return
|
||||
val current = currentPosition() ?: return
|
||||
if (offline) {
|
||||
scope.launch {
|
||||
downloads.awaitLoaded()
|
||||
val target =
|
||||
downloadedAtOrBefore(snapshot, current - 1)
|
||||
?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null
|
||||
if (target != null) {
|
||||
jumpTo(target)
|
||||
} else {
|
||||
player.seekTo(0)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
when {
|
||||
current > 0 -> {
|
||||
jumpTo(current - 1)
|
||||
@@ -934,12 +1041,23 @@ class PlaybackController(
|
||||
val player = player ?: return
|
||||
val snapshot = snapshot ?: return
|
||||
downloads.awaitLoaded()
|
||||
val window = queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS)
|
||||
val startIndex = window.indexOfFirst { it.position == position }
|
||||
if (startIndex < 0) return
|
||||
val raw = queueRepository.window(snapshot, position, 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 }
|
||||
if (startIndex < 0) {
|
||||
startIndex = window.indexOfFirst { it.position > position }
|
||||
if (startIndex < 0) startIndex = window.lastIndex
|
||||
}
|
||||
val actual = window[startIndex].position
|
||||
windowPositions = LongArray(window.size) { window[it].position }
|
||||
windowStart = window.first().position
|
||||
windowEnd = window.last().position
|
||||
lastPosition = position
|
||||
lastPosition = actual
|
||||
if (actual != position) queueRepository.setCursor(actual)
|
||||
updating = true
|
||||
player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs)
|
||||
updating = false
|
||||
@@ -947,7 +1065,7 @@ class PlaybackController(
|
||||
player.prepare()
|
||||
player.play()
|
||||
}
|
||||
refresh(position)
|
||||
refresh(actual)
|
||||
}
|
||||
|
||||
private suspend fun reloadQuality() =
|
||||
@@ -991,7 +1109,7 @@ class PlaybackController(
|
||||
val snapshot = snapshot ?: return
|
||||
if (snapshot.size == 0L) return
|
||||
if (player.isEnded) return
|
||||
val position = (windowStart + player.currentIndex).coerceIn(0, snapshot.size - 1)
|
||||
val position = (currentPosition() ?: return).coerceIn(0, snapshot.size - 1)
|
||||
val positionMs = player.currentPositionMs
|
||||
loadWindow(position, autoplay = player.playWhenReady, startPositionMs = positionMs)
|
||||
}
|
||||
@@ -1009,12 +1127,19 @@ class PlaybackController(
|
||||
}
|
||||
this@PlaybackController.snapshot = snapshot
|
||||
val current = currentPosition() ?: return@withLock
|
||||
val dest = anchor.position.coerceIn(0, snapshot.size - 1)
|
||||
val requested = anchor.position.coerceIn(0, snapshot.size - 1)
|
||||
downloads.awaitLoaded()
|
||||
val dest =
|
||||
if (offline) {
|
||||
downloadedAtOrAfter(snapshot, requested) ?: return@withLock
|
||||
} else {
|
||||
requested
|
||||
}
|
||||
val wasEnded = player.isEnded
|
||||
if (!anchor.changed && dest == current && !wasEnded) return@withLock
|
||||
windowJob?.cancel()
|
||||
queueRepository.setCursor(dest)
|
||||
if (anchor.changed) {
|
||||
if (anchor.changed || !inWindow(dest)) {
|
||||
loadWindow(dest, autoplay = player.playWhenReady || wasEnded)
|
||||
endedHandled = false
|
||||
return@withLock
|
||||
@@ -1022,7 +1147,7 @@ class PlaybackController(
|
||||
lastPosition = dest
|
||||
shiftWindowLocked(dest)
|
||||
player.ensurePrepared()
|
||||
player.seekToIndex((dest - windowStart).toInt())
|
||||
player.seekToIndex(windowIndexOf(dest))
|
||||
if (wasEnded) player.play()
|
||||
endedHandled = false
|
||||
refresh(dest)
|
||||
@@ -1034,7 +1159,7 @@ class PlaybackController(
|
||||
val player = player ?: return
|
||||
val snapshot = snapshot ?: return
|
||||
if (snapshot.size == 0L) return
|
||||
val position = (windowStart + player.currentIndex).coerceIn(0, snapshot.size - 1)
|
||||
val position = (currentPosition() ?: return).coerceIn(0, snapshot.size - 1)
|
||||
val previous = lastPosition
|
||||
lastPosition = position
|
||||
if (previous != null && previous != position && snapshot.isUpNext(previous)) {
|
||||
@@ -1061,6 +1186,10 @@ class PlaybackController(
|
||||
|
||||
private suspend fun shiftWindowLocked(center: Long) {
|
||||
val player = player ?: return
|
||||
if (offline) {
|
||||
lastPosition = center
|
||||
return
|
||||
}
|
||||
val previous = snapshot
|
||||
val snapshot = readSnapshot()
|
||||
this.snapshot = snapshot
|
||||
@@ -1104,10 +1233,14 @@ class PlaybackController(
|
||||
lastPosition = target
|
||||
}
|
||||
|
||||
private fun rebuildWindow(
|
||||
private suspend fun rebuildWindow(
|
||||
window: List<QueueWindowItem>,
|
||||
position: Long,
|
||||
) {
|
||||
if (offline) {
|
||||
loadWindow(position, autoplay = player?.playWhenReady == true, startPositionMs = player?.currentPositionMs ?: 0)
|
||||
return
|
||||
}
|
||||
val player = player ?: return
|
||||
val index = window.indexOfFirst { it.position == position }
|
||||
if (index < 0) return
|
||||
@@ -1128,9 +1261,73 @@ class PlaybackController(
|
||||
private fun currentPosition(): Long? {
|
||||
val player = player ?: return null
|
||||
if (player.itemCount == 0) return null
|
||||
return windowStart + player.currentIndex
|
||||
val index = player.currentIndex
|
||||
return if (offline) windowPositions.getOrNull(index) ?: (windowStart + index) else (windowStart + index)
|
||||
}
|
||||
|
||||
private fun windowIndexOf(position: Long): Int =
|
||||
if (offline) {
|
||||
windowPositions.indexOf(position)
|
||||
} else {
|
||||
(position - windowStart).toInt()
|
||||
}
|
||||
|
||||
private fun inWindow(position: Long): Boolean =
|
||||
if (offline) {
|
||||
windowPositions.contains(position)
|
||||
} else {
|
||||
position in windowStart..windowEnd
|
||||
}
|
||||
|
||||
private suspend fun downloadedAtOrAfter(
|
||||
snapshot: QueueSnapshot,
|
||||
from: Long,
|
||||
): Long? = downloadedInDirection(snapshot, from, 1L)
|
||||
|
||||
private suspend fun downloadedAtOrBefore(
|
||||
snapshot: QueueSnapshot,
|
||||
from: Long,
|
||||
): Long? = downloadedInDirection(snapshot, from, -1L)
|
||||
|
||||
private suspend fun downloadedInDirection(
|
||||
snapshot: QueueSnapshot,
|
||||
from: Long,
|
||||
step: Long,
|
||||
): Long? {
|
||||
var position = from
|
||||
var scanned = 0L
|
||||
while (position in 0 until snapshot.size && scanned < OFFLINE_SCAN_LIMIT) {
|
||||
val songId = queueRepository.itemAt(snapshot, position)?.song?.id
|
||||
if (songId != null && downloads.localUri(songId) != null) return position
|
||||
position += step
|
||||
scanned++
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun reloadForOffline() =
|
||||
startLock.withLock {
|
||||
val player = player ?: return@withLock
|
||||
val snapshot = snapshot ?: return@withLock
|
||||
if (snapshot.size == 0L || player.isEnded) return@withLock
|
||||
downloads.awaitLoaded()
|
||||
val current = currentPosition() ?: return@withLock
|
||||
val positionMs = player.currentPositionMs
|
||||
val autoplay = player.playWhenReady
|
||||
val target =
|
||||
if (offline) {
|
||||
downloadedAtOrAfter(snapshot, current) ?: run {
|
||||
stopLocked()
|
||||
return@withLock
|
||||
}
|
||||
} else {
|
||||
current
|
||||
}
|
||||
windowJob?.cancel()
|
||||
queueRepository.setCursor(target)
|
||||
loadWindow(target, autoplay = autoplay, startPositionMs = if (target == current) positionMs else 0)
|
||||
}
|
||||
|
||||
private fun movedCursor(
|
||||
current: Long,
|
||||
from: Long,
|
||||
@@ -1275,5 +1472,6 @@ class PlaybackController(
|
||||
const val RESTART_THRESHOLD_MS = 3_000L
|
||||
const val SEED_PIN_TRIES = 1_000_000
|
||||
const val FILE_SCHEME = "file:"
|
||||
const val OFFLINE_SCAN_LIMIT = 10_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
@@ -36,7 +37,9 @@ class AlbumDetailViewModel(
|
||||
|
||||
val album: Flow<Album?> = sourceId.flatMapLatest { libraryRepository.album(it, albumId) }
|
||||
|
||||
val songs: Flow<List<Song>> = sourceId.flatMapLatest { libraryRepository.albumSongs(it, albumId) }
|
||||
val songs: Flow<List<Song>> =
|
||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||
.flatMapLatest { (id, offline) -> libraryRepository.albumSongs(id, albumId, offline) }
|
||||
|
||||
val discs: Flow<List<Disc>> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
@@ -37,7 +38,9 @@ class ArtistDetailViewModel(
|
||||
|
||||
val artist: Flow<Artist?> = sourceId.flatMapLatest { libraryRepository.artist(it, artistId) }
|
||||
|
||||
val albums: Flow<List<Album>> = sourceId.flatMapLatest { libraryRepository.artistAlbums(it, artistId) }
|
||||
val albums: Flow<List<Album>> =
|
||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
||||
|
||||
val downloadStatus: StateFlow<ListDownloadStatus> =
|
||||
sourceId
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.rounded.Album
|
||||
import androidx.compose.material.icons.rounded.ArrowDownward
|
||||
import androidx.compose.material.icons.rounded.ArrowUpward
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.CloudOff
|
||||
import androidx.compose.material.icons.rounded.DownloadDone
|
||||
import androidx.compose.material.icons.rounded.FilterAltOff
|
||||
import androidx.compose.material.icons.rounded.Person
|
||||
@@ -211,12 +212,14 @@ fun LibraryRoute(
|
||||
}
|
||||
}
|
||||
val listQuery by viewModel.listQuery(listTab).collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
val displayQuery = if (offline) listQuery.copy(downloaded = true) else listQuery
|
||||
val search by viewModel.search(listTab).collectAsStateWithLifecycle()
|
||||
val resetKeys =
|
||||
LibraryTab.entries.associateWith { tab ->
|
||||
val query by viewModel.listQuery(tab.listTab()).collectAsStateWithLifecycle()
|
||||
val term by viewModel.search(tab.listTab()).collectAsStateWithLifecycle()
|
||||
"${query.sort}|${query.descending}|${query.starred}|${query.downloaded}|$term"
|
||||
"${query.sort}|${query.descending}|${query.starred}|${query.downloaded || offline}|$term|$offline"
|
||||
}
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true))
|
||||
@@ -259,8 +262,10 @@ fun LibraryRoute(
|
||||
onSync = viewModel::sync,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onItemLongClick = { contextMenuHost?.show(it, itemActions) },
|
||||
listQuery = listQuery,
|
||||
listQuery = displayQuery,
|
||||
resetKeys = resetKeys,
|
||||
offline = offline,
|
||||
onExitOffline = { viewModel.setOffline(false) },
|
||||
sortOptions = sortOptionsFor(selectedTab),
|
||||
starredSupported = listTab.supportsStarred,
|
||||
onSortChange = { viewModel.setListQuery(listTab, listQuery.copy(sort = it)) },
|
||||
@@ -310,6 +315,8 @@ fun LibraryScreen(
|
||||
onPlaylistClick: (Playlist) -> Unit,
|
||||
onSync: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
offline: Boolean = false,
|
||||
onExitOffline: () -> Unit = {},
|
||||
onItemLongClick: (MenuTarget) -> Unit = {},
|
||||
syncing: Boolean = false,
|
||||
bottomInset: Dp = 0.dp,
|
||||
@@ -479,6 +486,8 @@ fun LibraryScreen(
|
||||
pagerState = pagerState,
|
||||
onTabSelected = onTabSelected,
|
||||
onOpenSettings = onOpenSettings,
|
||||
offline = offline,
|
||||
onExitOffline = onExitOffline,
|
||||
artwork = artwork,
|
||||
)
|
||||
}
|
||||
@@ -762,6 +771,8 @@ internal fun LibraryTabs(
|
||||
pagerState: PagerState,
|
||||
onTabSelected: (LibraryTab) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
offline: Boolean = false,
|
||||
onExitOffline: () -> Unit = {},
|
||||
artwork: ArtworkColors?,
|
||||
) {
|
||||
val indicatorStretch = 18.dp
|
||||
@@ -844,6 +855,15 @@ internal fun LibraryTabs(
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (offline) {
|
||||
IconButton(onClick = onExitOffline) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.CloudOff,
|
||||
contentDescription = "Offline mode; tap to go online",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
|
||||
@@ -59,35 +59,62 @@ class LibraryViewModel(
|
||||
libraryRepository.activeSourceId
|
||||
.filterNotNull()
|
||||
.flatMapLatest { sourceId ->
|
||||
combine(listQueries.getValue(LibraryListTab.Albums), searches.getValue(LibraryListTab.Albums)) { query, search ->
|
||||
query to search
|
||||
}.flatMapLatest { (query, search) ->
|
||||
libraryRepository.albums(sourceId, query.albumSort(), query.descending, query.starred, search, query.downloaded)
|
||||
}
|
||||
combine(
|
||||
listQueries.getValue(LibraryListTab.Albums),
|
||||
searches.getValue(LibraryListTab.Albums),
|
||||
sourceRepository.offline,
|
||||
) { query, search, offline -> Triple(query, search, offline) }
|
||||
.flatMapLatest { (query, search, offline) ->
|
||||
libraryRepository.albums(
|
||||
sourceId,
|
||||
query.albumSort(),
|
||||
query.descending,
|
||||
query.starred,
|
||||
search,
|
||||
query.downloaded || offline,
|
||||
)
|
||||
}
|
||||
}.cachedIn(viewModelScope)
|
||||
|
||||
val artists: Flow<PagingData<Artist>> =
|
||||
libraryRepository.activeSourceId
|
||||
.filterNotNull()
|
||||
.flatMapLatest { sourceId ->
|
||||
combine(listQueries.getValue(LibraryListTab.Artists), searches.getValue(LibraryListTab.Artists)) { query, search ->
|
||||
query to search
|
||||
}.flatMapLatest { (query, search) ->
|
||||
libraryRepository.artists(sourceId, query.artistSort(), query.descending, query.starred, search, query.downloaded)
|
||||
}
|
||||
combine(
|
||||
listQueries.getValue(LibraryListTab.Artists),
|
||||
searches.getValue(LibraryListTab.Artists),
|
||||
sourceRepository.offline,
|
||||
) { query, search, offline -> Triple(query, search, offline) }
|
||||
.flatMapLatest { (query, search, offline) ->
|
||||
libraryRepository.artists(
|
||||
sourceId,
|
||||
query.artistSort(),
|
||||
query.descending,
|
||||
query.starred,
|
||||
search,
|
||||
query.downloaded || offline,
|
||||
)
|
||||
}
|
||||
}.cachedIn(viewModelScope)
|
||||
|
||||
val playlists: Flow<PagingData<Playlist>> =
|
||||
libraryRepository.activeSourceId
|
||||
.filterNotNull()
|
||||
.flatMapLatest { sourceId ->
|
||||
combine(listQueries.getValue(LibraryListTab.Playlists), searches.getValue(LibraryListTab.Playlists)) { query, search ->
|
||||
query to search
|
||||
}.flatMapLatest { (query, search) ->
|
||||
libraryRepository.playlists(sourceId, query.playlistSort(), query.descending, search, query.downloaded)
|
||||
}
|
||||
combine(
|
||||
listQueries.getValue(LibraryListTab.Playlists),
|
||||
searches.getValue(LibraryListTab.Playlists),
|
||||
sourceRepository.offline,
|
||||
) { query, search, offline -> Triple(query, search, offline) }
|
||||
.flatMapLatest { (query, search, offline) ->
|
||||
libraryRepository.playlists(sourceId, query.playlistSort(), query.descending, search, query.downloaded || offline)
|
||||
}
|
||||
}.cachedIn(viewModelScope)
|
||||
|
||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||
|
||||
fun setOffline(enabled: Boolean) = sourceRepository.setOfflineMode(enabled)
|
||||
|
||||
val syncing: StateFlow<Boolean> =
|
||||
syncManager.status
|
||||
.map { it == SyncStatus.Running }
|
||||
|
||||
@@ -20,6 +20,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
@@ -39,8 +40,8 @@ class PlaylistDetailViewModel(
|
||||
val playlist: Flow<Playlist?> = sourceId.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
||||
|
||||
val songs: Flow<PagingData<PlaylistSongItem>> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.playlistSongs(it, playlistId) }
|
||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||
.flatMapLatest { (id, offline) -> libraryRepository.playlistSongs(id, playlistId, offline) }
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
val downloads: StateFlow<Map<String, SongDownload>> =
|
||||
|
||||
@@ -74,6 +74,7 @@ fun SettingsRoute(
|
||||
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
|
||||
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
|
||||
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
SettingsScreen(
|
||||
sources = sources,
|
||||
activeSourceId = activeSourceId,
|
||||
@@ -83,6 +84,7 @@ fun SettingsRoute(
|
||||
downloadQuality = downloadQuality,
|
||||
downloadOverMetered = downloadOverMetered,
|
||||
scrobbling = scrobbling,
|
||||
offline = offline,
|
||||
onSelectSource = viewModel::selectSource,
|
||||
onEditServer = onEditServer,
|
||||
onWifiQualityChange = viewModel::setWifiQuality,
|
||||
@@ -91,6 +93,7 @@ fun SettingsRoute(
|
||||
onDownloadQualityChange = viewModel::setDownloadQuality,
|
||||
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
||||
onScrobblingChange = viewModel::setScrobbling,
|
||||
onOfflineChange = viewModel::setOfflineMode,
|
||||
onAddServer = onAddServer,
|
||||
onOpenDownloads = onOpenDownloads,
|
||||
onBack = onBack,
|
||||
@@ -108,6 +111,7 @@ fun SettingsScreen(
|
||||
downloadQuality: StreamQuality,
|
||||
downloadOverMetered: Boolean,
|
||||
scrobbling: Boolean,
|
||||
offline: Boolean = false,
|
||||
onSelectSource: (Long) -> Unit,
|
||||
onEditServer: (Long) -> Unit,
|
||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||
@@ -116,6 +120,7 @@ fun SettingsScreen(
|
||||
onDownloadQualityChange: (StreamQuality) -> Unit,
|
||||
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
||||
onScrobblingChange: (Boolean) -> Unit,
|
||||
onOfflineChange: (Boolean) -> Unit = {},
|
||||
onAddServer: () -> Unit,
|
||||
onOpenDownloads: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
@@ -178,6 +183,19 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
item { SectionHeader("Network") }
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Offline mode") },
|
||||
supportingContent = {
|
||||
Text(if (offline) "Don't use the internet to sync or play music." else "Use the internet to sync music.")
|
||||
},
|
||||
trailingContent = {
|
||||
Switch(checked = offline, onCheckedChange = onOfflineChange)
|
||||
},
|
||||
modifier = Modifier.clickable { onOfflineChange(!offline) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Stream quality: Wi-Fi") },
|
||||
|
||||
@@ -53,6 +53,12 @@ class SettingsViewModel(
|
||||
.scrobbling()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
|
||||
|
||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||
|
||||
fun setOfflineMode(enabled: Boolean) {
|
||||
sourceRepository.setOfflineMode(enabled)
|
||||
}
|
||||
|
||||
fun selectSource(id: Long) {
|
||||
viewModelScope.launch { sourceRepository.selectSource(id) }
|
||||
}
|
||||
|
||||
@@ -349,6 +349,44 @@ class LibraryDaoTest {
|
||||
assertEquals(3, dao.albumsByName(sourceId, 0, "").page().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineListQueriesReturnOnlyCompletedDownloads() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(listOf(album(sourceId, "al-1", "Album", year = null, starred = null)))
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "One", starred = null, track = 1),
|
||||
song(sourceId, "s2", "Two", starred = null, track = 2),
|
||||
song(sourceId, "s3", "Three", starred = null, track = 3),
|
||||
),
|
||||
)
|
||||
dao.upsertPlaylists(listOf(playlist(sourceId, "pl-1", "List", created = 0, changed = 0)))
|
||||
dao.upsertPlaylistSongs(
|
||||
listOf(
|
||||
PlaylistSong(sourceId, "pl-1", "s1", 0),
|
||||
PlaylistSong(sourceId, "pl-1", "s2", 1),
|
||||
PlaylistSong(sourceId, "pl-1", "s3", 2),
|
||||
),
|
||||
)
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Queued))
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s3", DownloadStatus.Completed))
|
||||
|
||||
assertEquals(listOf("s1", "s3"), dao.songsByAlbumDownloaded(sourceId, "al-1").first().map { it.id })
|
||||
assertEquals(listOf("s1", "s3"), dao.downloadedAlbumSongIds(sourceId, "al-1"))
|
||||
assertEquals(listOf("s1", "s3"), dao.downloadedPlaylistSongIds(sourceId, "pl-1"))
|
||||
assertEquals(
|
||||
listOf("s1", "s3"),
|
||||
dao.playlistSongsDownloaded(sourceId, "pl-1").page().map { it.song.id },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("al-1"),
|
||||
dao.albumsForArtistDownloaded(sourceId, "ar-1").first().map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchMatchesInfixCaseInsensitivelyAndFallsBackToAScanBelowThreeCharacters() =
|
||||
runTest {
|
||||
|
||||
@@ -244,6 +244,35 @@ class SourceRepositoryTest {
|
||||
assertTrue("expected the server url, was ${ref.url}", ref.url.startsWith("http"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineModeDisablesNetworkAndStreaming() =
|
||||
runBlocking {
|
||||
repository.addSource("server", "http://a.example/", "u", "p", true)
|
||||
val sourceId = awaitActiveSourceId()
|
||||
repository.setOfflineMode(true)
|
||||
withTimeout(5_000) { repository.offline.first { it } }
|
||||
|
||||
assertEquals(null, repository.streamUri("s1", 1_000L, StreamQuality()))
|
||||
assertEquals(null, repository.networkCoverArt(sourceId, "art-1", false))
|
||||
assertEquals(null, repository.downloadUri(sourceId, "s1"))
|
||||
assertEquals(null, repository.activeMusicSource())
|
||||
assertEquals(null, repository.coverArt("art-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineModeStillServesDownloadedArtwork() =
|
||||
runBlocking {
|
||||
repository.addSource("server", "http://a.example/", "u", "p", true)
|
||||
val sourceId = awaitActiveSourceId()
|
||||
artwork.write(sourceId, coverArtKey(sourceId, "art-1", false), byteArrayOf(1, 2, 3))
|
||||
repository.setOfflineMode(true)
|
||||
withTimeout(5_000) { repository.offline.first { it } }
|
||||
|
||||
val ref = repository.coverArt("art-1")
|
||||
|
||||
assertTrue("expected the stored file, was ${ref?.url}", ref?.url?.startsWith("file:") == true)
|
||||
}
|
||||
|
||||
private suspend fun awaitActiveSourceId(): Long = withTimeout(5_000) { repository.activeSourceId().first { it != null }!! }
|
||||
|
||||
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
|
||||
|
||||
@@ -224,4 +224,15 @@ class SyncManagerTest {
|
||||
"<song id=\"s1\" title=\"Song One\" albumId=\"al1\" artistId=\"ar1\" track=\"1\"/>" +
|
||||
"</searchResult3></subsonic-response>"
|
||||
}
|
||||
@Test
|
||||
fun aSyncRequestWhileOfflineIsRefusedWithoutTouchingTheNetwork() =
|
||||
runBlocking {
|
||||
sourceRepository.setOfflineMode(true)
|
||||
withTimeout(5_000) { sourceRepository.offline.first { it } }
|
||||
|
||||
manager.requestSync()
|
||||
val status = withTimeout(10_000) { manager.status.first { it is SyncStatus.Failed } }
|
||||
|
||||
assertEquals("Offline mode is on", (status as SyncStatus.Failed).message)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user