add an offline mode that stays on the downloaded library

This commit is contained in:
austinried
2026-10-04 00:30:04 +00:00
parent 92637deb48
commit 8cccee7065
18 changed files with 537 additions and 60 deletions
@@ -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)
}
}