diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index ddd8b427..b3c3ea82 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -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> + + @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> + + @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 + + @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 + + @Query("$PLAYLIST_SONGS_SELECT AND $DOWNLOADED_SONG$PLAYLIST_SONGS_ORDER") + fun playlistSongsDownloaded( + sourceId: Long, + playlistId: String, + ): PagingSource + + @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> } diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index 90217aa1..5667106a 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -113,6 +113,14 @@ class UserPreferences( private val scrobblingKey = booleanPreferencesKey("scrobbling") + fun offlineMode(): Flow = 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 diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index 6d062ae5..e7638296 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -184,7 +184,13 @@ class LibraryRepository( fun artistAlbums( sourceId: Long, artistId: String, - ): Flow> = db.libraryDao().albumsForArtist(sourceId, artistId) + downloaded: Boolean = false, + ): Flow> = + 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> = db.libraryDao().songsByAlbum(sourceId, albumId) + downloaded: Boolean = false, + ): Flow> = + 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> = pager(60) { db.libraryDao().playlistSongs(sourceId, playlistId) } + downloaded: Boolean = false, + ): Flow> = + 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) diff --git a/app/src/main/kotlin/com/subtracks/data/repo/OfflineException.kt b/app/src/main/kotlin/com/subtracks/data/repo/OfflineException.kt new file mode 100644 index 00000000..89f0888b --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/repo/OfflineException.kt @@ -0,0 +1,3 @@ +package com.subtracks.data.repo + +internal class OfflineException : Exception("Offline mode is on") diff --git a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt index eca9a3e1..c8d4173c 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -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 { + 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, diff --git a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt index 3e8aaa34..c8b0c6cb 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt @@ -55,8 +55,15 @@ class SourceRepository( @Volatile private var allowMeteredDownloads = false + private val _offline = MutableStateFlow(false) + val offline: StateFlow = _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 = - 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()) } diff --git a/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt b/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt index d5e620e0..75c69e86 100644 --- a/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt @@ -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 { diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 27edc44c..a4f94d81 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -125,6 +125,7 @@ class PlaybackController( private var connecting = false private var pendingPlay: PendingPlay? = null private var pendingShuffle: QueueEntry? = null + private var pendingShuffleList: List? = 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 { + 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) { 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) = 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, 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 } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt index 8bae3515..4bb06404 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt @@ -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 = sourceId.flatMapLatest { libraryRepository.album(it, albumId) } - val songs: Flow> = sourceId.flatMapLatest { libraryRepository.albumSongs(it, albumId) } + val songs: Flow> = + combine(sourceId, sourceRepository.offline) { id, offline -> id to offline } + .flatMapLatest { (id, offline) -> libraryRepository.albumSongs(id, albumId, offline) } val discs: Flow> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt index ffcca189..cd998d06 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt @@ -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 = sourceId.flatMapLatest { libraryRepository.artist(it, artistId) } - val albums: Flow> = sourceId.flatMapLatest { libraryRepository.artistAlbums(it, artistId) } + val albums: Flow> = + combine(sourceId, sourceRepository.offline) { id, offline -> id to offline } + .flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) } val downloadStatus: StateFlow = sourceId diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index a4a0d9d8..1f328e86 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -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, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt index 3ac317d2..5e94bb3f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt @@ -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> = 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> = 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 = sourceRepository.offline + + fun setOffline(enabled: Boolean) = sourceRepository.setOfflineMode(enabled) + val syncing: StateFlow = syncManager.status .map { it == SyncStatus.Running } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt index e9377d69..b8f2cce8 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt @@ -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 = sourceId.flatMapLatest { libraryRepository.playlist(it, playlistId) } val songs: Flow> = - 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> = diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt index 0379df11..90b37a03 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -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") }, diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt index aa892433..7310ae07 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt @@ -53,6 +53,12 @@ class SettingsViewModel( .scrobbling() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true) + val offline: StateFlow = sourceRepository.offline + + fun setOfflineMode(enabled: Boolean) { + sourceRepository.setOfflineMode(enabled) + } + fun selectSource(id: Long) { viewModelScope.launch { sourceRepository.selectSource(id) } } diff --git a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt index 84b5ac39..97b565c8 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -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 { diff --git a/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt index 55061434..64f83b2b 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt @@ -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 = diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt index 3724adc1..abb6e1e6 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt @@ -224,4 +224,15 @@ class SyncManagerTest { "" + "" } + @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) + } }