mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +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 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 =
|
internal const val ALBUMS_FILTER =
|
||||||
"FROM albums WHERE sourceId = :sourceId " +
|
"FROM albums WHERE sourceId = :sourceId " +
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
"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,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
): Flow<List<Song>>
|
): 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")
|
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? {
|
suspend fun lastSeed(): ArtworkSeedValue? {
|
||||||
val prefs = store.data.first()
|
val prefs = store.data.first()
|
||||||
val cacheKey = prefs[lastSeedKey] ?: return null
|
val cacheKey = prefs[lastSeedKey] ?: return null
|
||||||
|
|||||||
@@ -184,7 +184,13 @@ class LibraryRepository(
|
|||||||
fun artistAlbums(
|
fun artistAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
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(
|
fun albumDiscs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -194,7 +200,13 @@ class LibraryRepository(
|
|||||||
fun albumSongs(
|
fun albumSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
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(
|
fun playlist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -204,7 +216,15 @@ class LibraryRepository(
|
|||||||
fun playlistSongs(
|
fun playlistSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
playlistId: String,
|
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 starLock = Mutex()
|
||||||
private val starScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
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,
|
songId: String,
|
||||||
) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Song, refId = songId)
|
) = 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(
|
suspend fun sourceName(
|
||||||
kind: QueueKind,
|
kind: QueueKind,
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
|
|||||||
@@ -55,8 +55,15 @@ class SourceRepository(
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var allowMeteredDownloads = false
|
private var allowMeteredDownloads = false
|
||||||
|
|
||||||
|
private val _offline = MutableStateFlow(false)
|
||||||
|
val offline: StateFlow<Boolean> = _offline
|
||||||
|
|
||||||
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
||||||
|
|
||||||
|
fun setOfflineMode(enabled: Boolean) {
|
||||||
|
scope.launch { prefs.setOfflineMode(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
prefs.downloadQuality().collect { downloadQuality = it }
|
prefs.downloadQuality().collect { downloadQuality = it }
|
||||||
@@ -64,6 +71,9 @@ class SourceRepository(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
prefs.downloadOverMetered().collect { allowMeteredDownloads = it }
|
prefs.downloadOverMetered().collect { allowMeteredDownloads = it }
|
||||||
}
|
}
|
||||||
|
scope.launch {
|
||||||
|
prefs.offlineMode().collect { _offline.value = it }
|
||||||
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
combine(
|
combine(
|
||||||
db.sourcesDao().activeSubsonicConfig(),
|
db.sourcesDao().activeSubsonicConfig(),
|
||||||
@@ -78,7 +88,7 @@ class SourceRepository(
|
|||||||
_quality.value = quality
|
_quality.value = quality
|
||||||
val sourceChanged = config?.id != activeSourceId
|
val sourceChanged = config?.id != activeSourceId
|
||||||
activeSourceId = config?.id
|
activeSourceId = config?.id
|
||||||
if (sourceChanged && config != null && config.useTokenAuth) {
|
if (!_offline.value && sourceChanged && config != null && config.useTokenAuth) {
|
||||||
scope.launch { runCatching { config.toClient().check("ping") } }
|
scope.launch { runCatching { config.toClient().check("ping") } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,6 +117,7 @@ class SourceRepository(
|
|||||||
if (coverArt == null) return null
|
if (coverArt == null) return null
|
||||||
val cacheKey = coverArtKey(source.id, coverArt, thumbnail)
|
val cacheKey = coverArtKey(source.id, coverArt, thumbnail)
|
||||||
artworkStore.uri(source.id, cacheKey)?.let { return CoverArtRef(url = it, cacheKey = cacheKey) }
|
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
|
val url = source.coverArtUri(coverArt, thumbnail)?.toString() ?: return null
|
||||||
return CoverArtRef(url = url, cacheKey = cacheKey)
|
return CoverArtRef(url = url, cacheKey = cacheKey)
|
||||||
}
|
}
|
||||||
@@ -116,6 +127,7 @@ class SourceRepository(
|
|||||||
coverArt: String,
|
coverArt: String,
|
||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): String? {
|
): String? {
|
||||||
|
if (_offline.value) return null
|
||||||
val source = active ?: return null
|
val source = active ?: return null
|
||||||
if (source.id != sourceId) return null
|
if (source.id != sourceId) return null
|
||||||
return source.coverArtUri(coverArt, thumbnail)?.toString()
|
return source.coverArtUri(coverArt, thumbnail)?.toString()
|
||||||
@@ -126,6 +138,7 @@ class SourceRepository(
|
|||||||
durationMs: Long?,
|
durationMs: Long?,
|
||||||
quality: StreamQuality,
|
quality: StreamQuality,
|
||||||
): String? {
|
): String? {
|
||||||
|
if (_offline.value) return null
|
||||||
val uri = active?.streamUri(songId)?.toString() ?: return null
|
val uri = active?.streamUri(songId)?.toString() ?: return null
|
||||||
val length = declaredStreamLength(durationMs, quality) ?: return uri
|
val length = declaredStreamLength(durationMs, quality) ?: return uri
|
||||||
return uri + streamLengthSuffix(length)
|
return uri + streamLengthSuffix(length)
|
||||||
@@ -135,6 +148,7 @@ class SourceRepository(
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songId: String,
|
songId: String,
|
||||||
): String? {
|
): String? {
|
||||||
|
if (_offline.value) return null
|
||||||
val source = active?.takeIf { activeSourceId == sourceId } ?: return null
|
val source = active?.takeIf { activeSourceId == sourceId } ?: return null
|
||||||
// A transcode preference downloads the stream, so the saved file is already the wanted
|
// 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.
|
// quality rather than the original that would then have to be transcoded on every play.
|
||||||
@@ -210,14 +224,19 @@ class SourceRepository(
|
|||||||
password: String,
|
password: String,
|
||||||
useTokenAuth: Boolean,
|
useTokenAuth: Boolean,
|
||||||
): Result<Boolean> =
|
): Result<Boolean> =
|
||||||
|
if (_offline.value) {
|
||||||
|
Result.failure(OfflineException())
|
||||||
|
} else {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
var fellBack = false
|
var fellBack = false
|
||||||
runCatching {
|
runCatching {
|
||||||
client(address, username, password, useTokenAuth) { fellBack = true }.check("ping")
|
client(address, username, password, useTokenAuth) { fellBack = true }.check("ping")
|
||||||
}.map { fellBack }
|
}.map { fellBack }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun activeMusicSource(): MusicSource? {
|
suspend fun activeMusicSource(): MusicSource? {
|
||||||
|
if (_offline.value) return null
|
||||||
val config = db.sourcesDao().activeSubsonicConfigOnce() ?: return null
|
val config = db.sourcesDao().activeSubsonicConfigOnce() ?: return null
|
||||||
return config.toMusicSource(quality.value, prefs.syncConcurrency().first())
|
return config.toMusicSource(quality.value, prefs.syncConcurrency().first())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ class SyncManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun runSync() {
|
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
|
_status.value = SyncStatus.Running
|
||||||
val result =
|
val result =
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ class PlaybackController(
|
|||||||
private var connecting = false
|
private var connecting = false
|
||||||
private var pendingPlay: PendingPlay? = null
|
private var pendingPlay: PendingPlay? = null
|
||||||
private var pendingShuffle: QueueEntry? = null
|
private var pendingShuffle: QueueEntry? = null
|
||||||
|
private var pendingShuffleList: List<QueueEntry>? = null
|
||||||
private var positionJob: Job? = null
|
private var positionJob: Job? = null
|
||||||
private var windowJob: Job? = null
|
private var windowJob: Job? = null
|
||||||
private var bufferingJob: Job? = null
|
private var bufferingJob: Job? = null
|
||||||
@@ -143,6 +144,8 @@ class PlaybackController(
|
|||||||
private var repeatMode = RepeatMode.Off
|
private var repeatMode = RepeatMode.Off
|
||||||
private var endedHandled = false
|
private var endedHandled = false
|
||||||
private var lastSavedPositionMs = 0L
|
private var lastSavedPositionMs = 0L
|
||||||
|
private var offline = false
|
||||||
|
private var windowPositions = LongArray(0)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -150,6 +153,13 @@ class PlaybackController(
|
|||||||
if (queueSourceId != null && sourceId != queueSourceId) stop()
|
if (queueSourceId != null && sourceId != queueSourceId) stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
scope.launch {
|
||||||
|
sourceRepository.offline.collect { value ->
|
||||||
|
if (value == offline) return@collect
|
||||||
|
offline = value
|
||||||
|
reloadForOffline()
|
||||||
|
}
|
||||||
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
sourceRepository.quality.drop(1).collect {
|
sourceRepository.quality.drop(1).collect {
|
||||||
try {
|
try {
|
||||||
@@ -180,8 +190,9 @@ class PlaybackController(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
val pending = pendingPlay
|
val pending = pendingPlay
|
||||||
pendingPlay = null
|
pendingPlay = null
|
||||||
val shuffle = pendingShuffle
|
val shuffle = pendingShuffle?.let { listOf(it) } ?: pendingShuffleList
|
||||||
pendingShuffle = null
|
pendingShuffle = null
|
||||||
|
pendingShuffleList = null
|
||||||
when {
|
when {
|
||||||
pending != null -> start(pending.entries, pending.position, pending.disableShuffle)
|
pending != null -> start(pending.entries, pending.position, pending.disableShuffle)
|
||||||
shuffle != null -> startShuffled(shuffle)
|
shuffle != null -> startShuffled(shuffle)
|
||||||
@@ -203,45 +214,89 @@ class PlaybackController(
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
startOrdinal: Long,
|
startOrdinal: Long,
|
||||||
) = play(listOf(queueRepository.albumEntry(sourceId, albumId)), startOrdinal)
|
) = playContext(queueRepository.albumEntry(sourceId, albumId), startOrdinal)
|
||||||
|
|
||||||
fun playPlaylist(
|
fun playPlaylist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
playlistId: String,
|
playlistId: String,
|
||||||
startPosition: Long,
|
startPosition: Long,
|
||||||
) = play(listOf(queueRepository.playlistEntry(sourceId, playlistId)), startPosition)
|
) = playContext(queueRepository.playlistEntry(sourceId, playlistId), startPosition)
|
||||||
|
|
||||||
fun playAlbumInOrder(
|
fun playAlbumInOrder(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
) = play(listOf(queueRepository.albumEntry(sourceId, albumId)), 0, disableShuffle = true)
|
) = playContext(queueRepository.albumEntry(sourceId, albumId), 0, disableShuffle = true)
|
||||||
|
|
||||||
fun playPlaylistInOrder(
|
fun playPlaylistInOrder(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
playlistId: String,
|
playlistId: String,
|
||||||
) = play(listOf(queueRepository.playlistEntry(sourceId, playlistId)), 0, disableShuffle = true)
|
) = playContext(queueRepository.playlistEntry(sourceId, playlistId), 0, disableShuffle = true)
|
||||||
|
|
||||||
fun shuffleAlbum(
|
fun shuffleAlbum(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
) = shufflePlay(queueRepository.albumEntry(sourceId, albumId))
|
) = shuffleContext(queueRepository.albumEntry(sourceId, albumId))
|
||||||
|
|
||||||
fun shufflePlaylist(
|
fun shufflePlaylist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
playlistId: String,
|
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) {
|
if (player == null) {
|
||||||
pendingShuffle = entry
|
if (entries.size == 1) pendingShuffle = entries.first() else pendingShuffleList = entries
|
||||||
connect()
|
connect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
scope.launch { startShuffled(entry) }
|
scope.launch { startShuffled(entries) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun startShuffled(entry: QueueEntry) =
|
private suspend fun startShuffled(entries: List<QueueEntry>) =
|
||||||
startLock.withLock {
|
startLock.withLock {
|
||||||
|
val entry = entries.first()
|
||||||
windowJob?.cancel()
|
windowJob?.cancel()
|
||||||
windowStart = 0
|
windowStart = 0
|
||||||
windowEnd = -1
|
windowEnd = -1
|
||||||
@@ -251,7 +306,7 @@ class PlaybackController(
|
|||||||
val previousEntry = previous?.entries?.firstOrNull()?.entry
|
val previousEntry = previous?.entries?.firstOrNull()?.entry
|
||||||
val same = previousEntry != null && previousEntry.kind == entry.kind && previousEntry.refId == entry.refId
|
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
|
val avoid = if (same) currentPosition()?.let { previous.flatContext(previous.anchorContextPlay(it)) } else null
|
||||||
queueRepository.replace(listOf(entry))
|
queueRepository.replace(entries)
|
||||||
var snap = queueRepository.snapshot()
|
var snap = queueRepository.snapshot()
|
||||||
if (snap.contextSize == 0L) {
|
if (snap.contextSize == 0L) {
|
||||||
stopLocked()
|
stopLocked()
|
||||||
@@ -303,20 +358,25 @@ class PlaybackController(
|
|||||||
this@PlaybackController.snapshot = snapshot
|
this@PlaybackController.snapshot = snapshot
|
||||||
layoutVersion++
|
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()
|
windowJob?.cancel()
|
||||||
queueRepository.setCursor(target)
|
queueRepository.setCursor(target)
|
||||||
if (anchor.changed) {
|
if (anchor.changed || !inWindow(target)) {
|
||||||
loadWindow(target, autoplay = true)
|
loadWindow(target, autoplay = true)
|
||||||
} else if (target in windowStart..windowEnd) {
|
} else {
|
||||||
player.ensurePrepared()
|
player.ensurePrepared()
|
||||||
lastPosition = target
|
lastPosition = target
|
||||||
player.seekToIndex((target - windowStart).toInt())
|
player.seekToIndex(windowIndexOf(target))
|
||||||
player.play()
|
player.play()
|
||||||
shiftWindowLocked(target)
|
shiftWindowLocked(target)
|
||||||
refresh(target)
|
refresh(target)
|
||||||
} else {
|
|
||||||
loadWindow(target, autoplay = true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,6 +453,12 @@ class PlaybackController(
|
|||||||
} else {
|
} else {
|
||||||
RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
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) {
|
if (snapshot.upNextSize > 0L || updated.upNextSize > 0L || snapshot.shuffled) {
|
||||||
queueRepository.setCursor(target)
|
queueRepository.setCursor(target)
|
||||||
if (position != current) {
|
if (position != current) {
|
||||||
@@ -480,6 +546,12 @@ class PlaybackController(
|
|||||||
} else {
|
} else {
|
||||||
ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
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 {
|
when {
|
||||||
fromInWindow && toInWindow -> {
|
fromInWindow && toInWindow -> {
|
||||||
updating = true
|
updating = true
|
||||||
@@ -531,6 +603,12 @@ class PlaybackController(
|
|||||||
lastPosition = target
|
lastPosition = target
|
||||||
val restoredId = queueRepository.itemAt(restored, target)?.song?.id
|
val restoredId = queueRepository.itemAt(restored, target)?.song?.id
|
||||||
windowJob?.cancel()
|
windowJob?.cancel()
|
||||||
|
if (offline) {
|
||||||
|
loadWindow(target, autoplay = playing, startPositionMs = 0)
|
||||||
|
queueRepository.setCursor(target)
|
||||||
|
refresh(target)
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
val canMirror =
|
val canMirror =
|
||||||
undo.current != null &&
|
undo.current != null &&
|
||||||
player.currentItem?.id != null &&
|
player.currentItem?.id != null &&
|
||||||
@@ -614,6 +692,16 @@ class PlaybackController(
|
|||||||
scope.launch { consumeUpNext(current) }
|
scope.launch { consumeUpNext(current) }
|
||||||
return
|
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 {
|
when {
|
||||||
current < snapshot.size - 1 -> jumpTo(current + 1)
|
current < snapshot.size - 1 -> jumpTo(current + 1)
|
||||||
repeatMode != RepeatMode.Off -> jumpTo(0)
|
repeatMode != RepeatMode.Off -> jumpTo(0)
|
||||||
@@ -633,6 +721,10 @@ class PlaybackController(
|
|||||||
endedHandled = false
|
endedHandled = false
|
||||||
val next = if (position < updated.size) position else 0L
|
val next = if (position < updated.size) position else 0L
|
||||||
queueRepository.setCursor(next)
|
queueRepository.setCursor(next)
|
||||||
|
if (offline) {
|
||||||
|
loadWindow(next, autoplay = true)
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
val player = player
|
val player = player
|
||||||
if (next == position && player != null && position - windowStart in 0 until player.itemCount) {
|
if (next == position && player != null && position - windowStart in 0 until player.itemCount) {
|
||||||
updating = true
|
updating = true
|
||||||
@@ -686,6 +778,21 @@ class PlaybackController(
|
|||||||
val snapshot = snapshot ?: return
|
val snapshot = snapshot ?: return
|
||||||
if (snapshot.size == 0L) return
|
if (snapshot.size == 0L) return
|
||||||
val current = currentPosition() ?: 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 {
|
when {
|
||||||
current > 0 -> {
|
current > 0 -> {
|
||||||
jumpTo(current - 1)
|
jumpTo(current - 1)
|
||||||
@@ -934,12 +1041,23 @@ class PlaybackController(
|
|||||||
val player = player ?: return
|
val player = player ?: return
|
||||||
val snapshot = snapshot ?: return
|
val snapshot = snapshot ?: return
|
||||||
downloads.awaitLoaded()
|
downloads.awaitLoaded()
|
||||||
val window = queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS)
|
val raw = queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS)
|
||||||
val startIndex = window.indexOfFirst { it.position == position }
|
val window = if (offline) raw.filter { downloads.localUri(it.item.song.id) != null } else raw
|
||||||
if (startIndex < 0) return
|
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
|
windowStart = window.first().position
|
||||||
windowEnd = window.last().position
|
windowEnd = window.last().position
|
||||||
lastPosition = position
|
lastPosition = actual
|
||||||
|
if (actual != position) queueRepository.setCursor(actual)
|
||||||
updating = true
|
updating = true
|
||||||
player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs)
|
player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs)
|
||||||
updating = false
|
updating = false
|
||||||
@@ -947,7 +1065,7 @@ class PlaybackController(
|
|||||||
player.prepare()
|
player.prepare()
|
||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
refresh(position)
|
refresh(actual)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun reloadQuality() =
|
private suspend fun reloadQuality() =
|
||||||
@@ -991,7 +1109,7 @@ class PlaybackController(
|
|||||||
val snapshot = snapshot ?: return
|
val snapshot = snapshot ?: return
|
||||||
if (snapshot.size == 0L) return
|
if (snapshot.size == 0L) return
|
||||||
if (player.isEnded) 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
|
val positionMs = player.currentPositionMs
|
||||||
loadWindow(position, autoplay = player.playWhenReady, startPositionMs = positionMs)
|
loadWindow(position, autoplay = player.playWhenReady, startPositionMs = positionMs)
|
||||||
}
|
}
|
||||||
@@ -1009,12 +1127,19 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
this@PlaybackController.snapshot = snapshot
|
this@PlaybackController.snapshot = snapshot
|
||||||
val current = currentPosition() ?: return@withLock
|
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
|
val wasEnded = player.isEnded
|
||||||
if (!anchor.changed && dest == current && !wasEnded) return@withLock
|
if (!anchor.changed && dest == current && !wasEnded) return@withLock
|
||||||
windowJob?.cancel()
|
windowJob?.cancel()
|
||||||
queueRepository.setCursor(dest)
|
queueRepository.setCursor(dest)
|
||||||
if (anchor.changed) {
|
if (anchor.changed || !inWindow(dest)) {
|
||||||
loadWindow(dest, autoplay = player.playWhenReady || wasEnded)
|
loadWindow(dest, autoplay = player.playWhenReady || wasEnded)
|
||||||
endedHandled = false
|
endedHandled = false
|
||||||
return@withLock
|
return@withLock
|
||||||
@@ -1022,7 +1147,7 @@ class PlaybackController(
|
|||||||
lastPosition = dest
|
lastPosition = dest
|
||||||
shiftWindowLocked(dest)
|
shiftWindowLocked(dest)
|
||||||
player.ensurePrepared()
|
player.ensurePrepared()
|
||||||
player.seekToIndex((dest - windowStart).toInt())
|
player.seekToIndex(windowIndexOf(dest))
|
||||||
if (wasEnded) player.play()
|
if (wasEnded) player.play()
|
||||||
endedHandled = false
|
endedHandled = false
|
||||||
refresh(dest)
|
refresh(dest)
|
||||||
@@ -1034,7 +1159,7 @@ class PlaybackController(
|
|||||||
val player = player ?: return
|
val player = player ?: return
|
||||||
val snapshot = snapshot ?: return
|
val snapshot = snapshot ?: return
|
||||||
if (snapshot.size == 0L) 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
|
val previous = lastPosition
|
||||||
lastPosition = position
|
lastPosition = position
|
||||||
if (previous != null && previous != position && snapshot.isUpNext(previous)) {
|
if (previous != null && previous != position && snapshot.isUpNext(previous)) {
|
||||||
@@ -1061,6 +1186,10 @@ class PlaybackController(
|
|||||||
|
|
||||||
private suspend fun shiftWindowLocked(center: Long) {
|
private suspend fun shiftWindowLocked(center: Long) {
|
||||||
val player = player ?: return
|
val player = player ?: return
|
||||||
|
if (offline) {
|
||||||
|
lastPosition = center
|
||||||
|
return
|
||||||
|
}
|
||||||
val previous = snapshot
|
val previous = snapshot
|
||||||
val snapshot = readSnapshot()
|
val snapshot = readSnapshot()
|
||||||
this.snapshot = snapshot
|
this.snapshot = snapshot
|
||||||
@@ -1104,10 +1233,14 @@ class PlaybackController(
|
|||||||
lastPosition = target
|
lastPosition = target
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rebuildWindow(
|
private suspend fun rebuildWindow(
|
||||||
window: List<QueueWindowItem>,
|
window: List<QueueWindowItem>,
|
||||||
position: Long,
|
position: Long,
|
||||||
) {
|
) {
|
||||||
|
if (offline) {
|
||||||
|
loadWindow(position, autoplay = player?.playWhenReady == true, startPositionMs = player?.currentPositionMs ?: 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
val player = player ?: return
|
val player = player ?: return
|
||||||
val index = window.indexOfFirst { it.position == position }
|
val index = window.indexOfFirst { it.position == position }
|
||||||
if (index < 0) return
|
if (index < 0) return
|
||||||
@@ -1128,7 +1261,71 @@ class PlaybackController(
|
|||||||
private fun currentPosition(): Long? {
|
private fun currentPosition(): Long? {
|
||||||
val player = player ?: return null
|
val player = player ?: return null
|
||||||
if (player.itemCount == 0) 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(
|
private fun movedCursor(
|
||||||
@@ -1275,5 +1472,6 @@ class PlaybackController(
|
|||||||
const val RESTART_THRESHOLD_MS = 3_000L
|
const val RESTART_THRESHOLD_MS = 3_000L
|
||||||
const val SEED_PIN_TRIES = 1_000_000
|
const val SEED_PIN_TRIES = 1_000_000
|
||||||
const val FILE_SCHEME = "file:"
|
const val FILE_SCHEME = "file:"
|
||||||
|
const val OFFLINE_SCAN_LIMIT = 10_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
@@ -36,7 +37,9 @@ class AlbumDetailViewModel(
|
|||||||
|
|
||||||
val album: Flow<Album?> = sourceId.flatMapLatest { libraryRepository.album(it, albumId) }
|
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) }
|
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.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
@@ -37,7 +38,9 @@ class ArtistDetailViewModel(
|
|||||||
|
|
||||||
val artist: Flow<Artist?> = sourceId.flatMapLatest { libraryRepository.artist(it, artistId) }
|
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> =
|
val downloadStatus: StateFlow<ListDownloadStatus> =
|
||||||
sourceId
|
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.ArrowDownward
|
||||||
import androidx.compose.material.icons.rounded.ArrowUpward
|
import androidx.compose.material.icons.rounded.ArrowUpward
|
||||||
import androidx.compose.material.icons.rounded.Close
|
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.DownloadDone
|
||||||
import androidx.compose.material.icons.rounded.FilterAltOff
|
import androidx.compose.material.icons.rounded.FilterAltOff
|
||||||
import androidx.compose.material.icons.rounded.Person
|
import androidx.compose.material.icons.rounded.Person
|
||||||
@@ -211,12 +212,14 @@ fun LibraryRoute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val listQuery by viewModel.listQuery(listTab).collectAsStateWithLifecycle()
|
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 search by viewModel.search(listTab).collectAsStateWithLifecycle()
|
||||||
val resetKeys =
|
val resetKeys =
|
||||||
LibraryTab.entries.associateWith { tab ->
|
LibraryTab.entries.associateWith { tab ->
|
||||||
val query by viewModel.listQuery(tab.listTab()).collectAsStateWithLifecycle()
|
val query by viewModel.listQuery(tab.listTab()).collectAsStateWithLifecycle()
|
||||||
val term by viewModel.search(tab.listTab()).collectAsStateWithLifecycle()
|
val term by viewModel.search(tab.listTab()).collectAsStateWithLifecycle()
|
||||||
"${query.sort}|${query.descending}|${query.starred}|${query.downloaded}|$term"
|
"${query.sort}|${query.descending}|${query.starred}|${query.downloaded || offline}|$term|$offline"
|
||||||
}
|
}
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true))
|
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true))
|
||||||
@@ -259,8 +262,10 @@ fun LibraryRoute(
|
|||||||
onSync = viewModel::sync,
|
onSync = viewModel::sync,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onItemLongClick = { contextMenuHost?.show(it, itemActions) },
|
onItemLongClick = { contextMenuHost?.show(it, itemActions) },
|
||||||
listQuery = listQuery,
|
listQuery = displayQuery,
|
||||||
resetKeys = resetKeys,
|
resetKeys = resetKeys,
|
||||||
|
offline = offline,
|
||||||
|
onExitOffline = { viewModel.setOffline(false) },
|
||||||
sortOptions = sortOptionsFor(selectedTab),
|
sortOptions = sortOptionsFor(selectedTab),
|
||||||
starredSupported = listTab.supportsStarred,
|
starredSupported = listTab.supportsStarred,
|
||||||
onSortChange = { viewModel.setListQuery(listTab, listQuery.copy(sort = it)) },
|
onSortChange = { viewModel.setListQuery(listTab, listQuery.copy(sort = it)) },
|
||||||
@@ -310,6 +315,8 @@ fun LibraryScreen(
|
|||||||
onPlaylistClick: (Playlist) -> Unit,
|
onPlaylistClick: (Playlist) -> Unit,
|
||||||
onSync: () -> Unit,
|
onSync: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
offline: Boolean = false,
|
||||||
|
onExitOffline: () -> Unit = {},
|
||||||
onItemLongClick: (MenuTarget) -> Unit = {},
|
onItemLongClick: (MenuTarget) -> Unit = {},
|
||||||
syncing: Boolean = false,
|
syncing: Boolean = false,
|
||||||
bottomInset: Dp = 0.dp,
|
bottomInset: Dp = 0.dp,
|
||||||
@@ -479,6 +486,8 @@ fun LibraryScreen(
|
|||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
onTabSelected = onTabSelected,
|
onTabSelected = onTabSelected,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
offline = offline,
|
||||||
|
onExitOffline = onExitOffline,
|
||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -762,6 +771,8 @@ internal fun LibraryTabs(
|
|||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
onTabSelected: (LibraryTab) -> Unit,
|
onTabSelected: (LibraryTab) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
offline: Boolean = false,
|
||||||
|
onExitOffline: () -> Unit = {},
|
||||||
artwork: ArtworkColors?,
|
artwork: ArtworkColors?,
|
||||||
) {
|
) {
|
||||||
val indicatorStretch = 18.dp
|
val indicatorStretch = 18.dp
|
||||||
@@ -844,6 +855,15 @@ internal fun LibraryTabs(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
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) {
|
IconButton(onClick = onOpenSettings) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Settings,
|
imageVector = Icons.Rounded.Settings,
|
||||||
|
|||||||
@@ -59,10 +59,20 @@ class LibraryViewModel(
|
|||||||
libraryRepository.activeSourceId
|
libraryRepository.activeSourceId
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.flatMapLatest { sourceId ->
|
.flatMapLatest { sourceId ->
|
||||||
combine(listQueries.getValue(LibraryListTab.Albums), searches.getValue(LibraryListTab.Albums)) { query, search ->
|
combine(
|
||||||
query to search
|
listQueries.getValue(LibraryListTab.Albums),
|
||||||
}.flatMapLatest { (query, search) ->
|
searches.getValue(LibraryListTab.Albums),
|
||||||
libraryRepository.albums(sourceId, query.albumSort(), query.descending, query.starred, search, query.downloaded)
|
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)
|
}.cachedIn(viewModelScope)
|
||||||
|
|
||||||
@@ -70,10 +80,20 @@ class LibraryViewModel(
|
|||||||
libraryRepository.activeSourceId
|
libraryRepository.activeSourceId
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.flatMapLatest { sourceId ->
|
.flatMapLatest { sourceId ->
|
||||||
combine(listQueries.getValue(LibraryListTab.Artists), searches.getValue(LibraryListTab.Artists)) { query, search ->
|
combine(
|
||||||
query to search
|
listQueries.getValue(LibraryListTab.Artists),
|
||||||
}.flatMapLatest { (query, search) ->
|
searches.getValue(LibraryListTab.Artists),
|
||||||
libraryRepository.artists(sourceId, query.artistSort(), query.descending, query.starred, search, query.downloaded)
|
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)
|
}.cachedIn(viewModelScope)
|
||||||
|
|
||||||
@@ -81,13 +101,20 @@ class LibraryViewModel(
|
|||||||
libraryRepository.activeSourceId
|
libraryRepository.activeSourceId
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.flatMapLatest { sourceId ->
|
.flatMapLatest { sourceId ->
|
||||||
combine(listQueries.getValue(LibraryListTab.Playlists), searches.getValue(LibraryListTab.Playlists)) { query, search ->
|
combine(
|
||||||
query to search
|
listQueries.getValue(LibraryListTab.Playlists),
|
||||||
}.flatMapLatest { (query, search) ->
|
searches.getValue(LibraryListTab.Playlists),
|
||||||
libraryRepository.playlists(sourceId, query.playlistSort(), query.descending, search, query.downloaded)
|
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)
|
}.cachedIn(viewModelScope)
|
||||||
|
|
||||||
|
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||||
|
|
||||||
|
fun setOffline(enabled: Boolean) = sourceRepository.setOfflineMode(enabled)
|
||||||
|
|
||||||
val syncing: StateFlow<Boolean> =
|
val syncing: StateFlow<Boolean> =
|
||||||
syncManager.status
|
syncManager.status
|
||||||
.map { it == SyncStatus.Running }
|
.map { it == SyncStatus.Running }
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
@@ -39,8 +40,8 @@ class PlaylistDetailViewModel(
|
|||||||
val playlist: Flow<Playlist?> = sourceId.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
val playlist: Flow<Playlist?> = sourceId.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
||||||
|
|
||||||
val songs: Flow<PagingData<PlaylistSongItem>> =
|
val songs: Flow<PagingData<PlaylistSongItem>> =
|
||||||
sourceId
|
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||||
.flatMapLatest { libraryRepository.playlistSongs(it, playlistId) }
|
.flatMapLatest { (id, offline) -> libraryRepository.playlistSongs(id, playlistId, offline) }
|
||||||
.cachedIn(viewModelScope)
|
.cachedIn(viewModelScope)
|
||||||
|
|
||||||
val downloads: StateFlow<Map<String, SongDownload>> =
|
val downloads: StateFlow<Map<String, SongDownload>> =
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ fun SettingsRoute(
|
|||||||
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
|
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
|
||||||
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
|
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
|
||||||
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
||||||
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
sources = sources,
|
sources = sources,
|
||||||
activeSourceId = activeSourceId,
|
activeSourceId = activeSourceId,
|
||||||
@@ -83,6 +84,7 @@ fun SettingsRoute(
|
|||||||
downloadQuality = downloadQuality,
|
downloadQuality = downloadQuality,
|
||||||
downloadOverMetered = downloadOverMetered,
|
downloadOverMetered = downloadOverMetered,
|
||||||
scrobbling = scrobbling,
|
scrobbling = scrobbling,
|
||||||
|
offline = offline,
|
||||||
onSelectSource = viewModel::selectSource,
|
onSelectSource = viewModel::selectSource,
|
||||||
onEditServer = onEditServer,
|
onEditServer = onEditServer,
|
||||||
onWifiQualityChange = viewModel::setWifiQuality,
|
onWifiQualityChange = viewModel::setWifiQuality,
|
||||||
@@ -91,6 +93,7 @@ fun SettingsRoute(
|
|||||||
onDownloadQualityChange = viewModel::setDownloadQuality,
|
onDownloadQualityChange = viewModel::setDownloadQuality,
|
||||||
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
||||||
onScrobblingChange = viewModel::setScrobbling,
|
onScrobblingChange = viewModel::setScrobbling,
|
||||||
|
onOfflineChange = viewModel::setOfflineMode,
|
||||||
onAddServer = onAddServer,
|
onAddServer = onAddServer,
|
||||||
onOpenDownloads = onOpenDownloads,
|
onOpenDownloads = onOpenDownloads,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -108,6 +111,7 @@ fun SettingsScreen(
|
|||||||
downloadQuality: StreamQuality,
|
downloadQuality: StreamQuality,
|
||||||
downloadOverMetered: Boolean,
|
downloadOverMetered: Boolean,
|
||||||
scrobbling: Boolean,
|
scrobbling: Boolean,
|
||||||
|
offline: Boolean = false,
|
||||||
onSelectSource: (Long) -> Unit,
|
onSelectSource: (Long) -> Unit,
|
||||||
onEditServer: (Long) -> Unit,
|
onEditServer: (Long) -> Unit,
|
||||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||||
@@ -116,6 +120,7 @@ fun SettingsScreen(
|
|||||||
onDownloadQualityChange: (StreamQuality) -> Unit,
|
onDownloadQualityChange: (StreamQuality) -> Unit,
|
||||||
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
||||||
onScrobblingChange: (Boolean) -> Unit,
|
onScrobblingChange: (Boolean) -> Unit,
|
||||||
|
onOfflineChange: (Boolean) -> Unit = {},
|
||||||
onAddServer: () -> Unit,
|
onAddServer: () -> Unit,
|
||||||
onOpenDownloads: () -> Unit,
|
onOpenDownloads: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@@ -178,6 +183,19 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
item { SectionHeader("Network") }
|
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 {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Stream quality: Wi-Fi") },
|
headlineContent = { Text("Stream quality: Wi-Fi") },
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ class SettingsViewModel(
|
|||||||
.scrobbling()
|
.scrobbling()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
|
||||||
|
|
||||||
|
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||||
|
|
||||||
|
fun setOfflineMode(enabled: Boolean) {
|
||||||
|
sourceRepository.setOfflineMode(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
fun selectSource(id: Long) {
|
fun selectSource(id: Long) {
|
||||||
viewModelScope.launch { sourceRepository.selectSource(id) }
|
viewModelScope.launch { sourceRepository.selectSource(id) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -349,6 +349,44 @@ class LibraryDaoTest {
|
|||||||
assertEquals(3, dao.albumsByName(sourceId, 0, "").page().size)
|
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
|
@Test
|
||||||
fun searchMatchesInfixCaseInsensitivelyAndFallsBackToAScanBelowThreeCharacters() =
|
fun searchMatchesInfixCaseInsensitivelyAndFallsBackToAScanBelowThreeCharacters() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
@@ -244,6 +244,35 @@ class SourceRepositoryTest {
|
|||||||
assertTrue("expected the server url, was ${ref.url}", ref.url.startsWith("http"))
|
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 awaitActiveSourceId(): Long = withTimeout(5_000) { repository.activeSourceId().first { it != null }!! }
|
||||||
|
|
||||||
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
|
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\"/>" +
|
"<song id=\"s1\" title=\"Song One\" albumId=\"al1\" artistId=\"ar1\" track=\"1\"/>" +
|
||||||
"</searchResult3></subsonic-response>"
|
"</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