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 c99b44e0..36fa2f33 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt @@ -88,7 +88,7 @@ class SourceRepository( _quality.value = quality val sourceChanged = config?.id != activeSourceId activeSourceId = config?.id - if (!_offline.value && sourceChanged && config != null && config.useTokenAuth) { + if (sourceChanged && config != null && config.useTokenAuth && !prefs.offlineMode().first()) { scope.launch { runCatching { config.toClient().check("ping") } } } } diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 3e309bee..3b75125e 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -416,7 +416,6 @@ class PlaybackController( if (offline) { queueRepository.setCursor(target) loadWindow(target, autoplay = playing, startPositionMs = 0) - refresh(target) return@withLock } if (snapshot.upNextSize > 0L || updated.upNextSize > 0L || snapshot.shuffled) { @@ -509,7 +508,6 @@ class PlaybackController( if (offline) { queueRepository.setCursor(target) loadWindow(target, autoplay = player.playWhenReady, startPositionMs = player.currentPositionMs) - refresh(target) return@withLock } when { @@ -564,9 +562,8 @@ class PlaybackController( val restoredId = queueRepository.itemAt(restored, target)?.song?.id windowJob?.cancel() if (offline) { - loadWindow(target, autoplay = playing, startPositionMs = 0) queueRepository.setCursor(target) - refresh(target) + loadWindow(target, autoplay = playing, startPositionMs = 0) return@withLock } val canMirror = @@ -1054,6 +1051,10 @@ class PlaybackController( val snapshot = snapshot ?: return@withLock if (snapshot.size == 0L || player.isEnded) return@withLock downloads.awaitLoaded() + if (offline) { + reloadWindowLocked() + return@withLock + } val position = (currentPosition() ?: return@withLock).coerceIn(0, snapshot.size - 1) val window = queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS) val uris = player.itemUris() diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt index aadd1b06..37a0c2bd 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt @@ -252,24 +252,9 @@ class QueueViewModel( fun undo() { viewModelScope.launch { playbackController.undo() - mutex.withLock { reload() } + mutex.withLock { reconcileLocked() } } } - - private suspend fun reload() { - val snapshot = queueRepository.snapshot() - if (snapshot.size == 0L) { - rows.clear() - return - } - val start = (rows.minOfOrNull { it.position } ?: 0L).coerceIn(0, snapshot.size - 1) - val end = (rows.maxOfOrNull { it.position } ?: (start + QUEUE_CHUNK - 1)).coerceIn(start, snapshot.size - 1) - val loaded = queueRepository.range(snapshot, start, end).filter(::visible) - val reused = reusedIds() - val updated = loaded.map { rowFor(it, reused) } - rows.clear() - rows.addAll(updated) - } } @Composable 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 dd828eab..7a46482a 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -419,6 +419,46 @@ class LibraryDaoTest { ) } + @Test + fun downloadedFilterSortsArtistsByTheirDownloadedAlbumCount() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertArtists( + listOf( + artist(sourceId, "ar-1", "One", albumCount = 1), + artist(sourceId, "ar-2", "Two", albumCount = 3), + ), + ) + dao.upsertAlbums( + listOf( + album(sourceId, "a1", "A1", year = null, starred = null), + album(sourceId, "a2", "A2", year = null, starred = null), + album(sourceId, "b1", "B1", year = null, starred = null).copy(artistId = "ar-2"), + ), + ) + dao.upsertSongs( + listOf( + song(sourceId, "s-a1", "A1", starred = null).copy(albumId = "a1"), + song(sourceId, "s-a2", "A2", starred = null).copy(albumId = "a2"), + song(sourceId, "s-b1", "B1", starred = null).copy(albumId = "b1", artistId = "ar-2"), + ), + ) + listOf("s-a1", "s-a2", "s-b1").forEach { + db.downloadDao().upsert(SongDownload(sourceId, it, DownloadStatus.Completed)) + } + + // ar-1 has two downloaded albums, ar-2 one; by synced count ar-2 (3) leads ar-1 (1). + assertEquals( + listOf("ar-1", "ar-2"), + dao.artistsByAlbumCount(sourceId, 0, "", downloadedFilter = 1).page().map { it.id }, + ) + assertEquals( + listOf("ar-2", "ar-1"), + dao.artistsByAlbumCount(sourceId, 0, "").page().map { it.id }, + ) + } + @Test fun downloadedFilterReportsTheDownloadedSongCountForPlaylists() = runTest { diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index e0ee4eac..3653a598 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -2497,6 +2497,74 @@ class PlaybackControllerTest { ) } + @Test + fun offlineRemovingThePlayingTrackPlaysTheNextDownloaded() { + seedAlbum(4, sourceId = 1) + markDownloaded(1, "s1") + markDownloaded(1, "s3") + setOffline() + controller.playAlbum(1, "al1", 0) + await { + controller.state.value.item + ?.id == "s1" + } + + runBlocking { controller.removeAt(0) } + + await { + controller.state.value.item + ?.id == "s3" + } + assertEquals( + "s3", + controller.state.value.item + ?.id, + ) + assertEquals(1L, controller.state.value.position) + } + + @Test + fun offlineDeletingADownloadDropsItFromTheWindow() { + seedAlbum(3, sourceId = 1) + markDownloaded(1, "s1") + markDownloaded(1, "s2") + markDownloaded(1, "s3") + setOffline() + controller.playAlbum(1, "al1", 0) + await { handle.items.map { it.id } == listOf("s1", "s2", "s3") } + + runBlocking { downloads.remove(1, "s2") } + controller.refreshMediaItems() + + await { handle.items.none { it.id == "s2" } } + assertEquals(listOf("s1", "s3"), handle.items.map { it.id }) + } + + @Test + fun offlinePreviousSkipsToThePreviousDownloaded() { + seedAlbum(5, sourceId = 1) + markDownloaded(1, "s1") + markDownloaded(1, "s4") + setOffline() + controller.playAlbum(1, "al1", 3) + await { + controller.state.value.item + ?.id == "s4" + } + + controller.previous() + + await { + controller.state.value.item + ?.id == "s1" + } + assertEquals( + "s1", + controller.state.value.item + ?.id, + ) + } + @Test fun offlinePlayStartsAtTheNearestDownloadedWhenTheTargetIsNotDownloaded() { seedAlbum(60, sourceId = 1) @@ -2764,14 +2832,18 @@ class PlaybackControllerTest { songId: String, ) { val file = File(downloadsDir, "$sourceId/$songId") - runBlocking { - downloads.reconcile() - db.downloadDao().upsert(SongDownload(sourceId, songId, DownloadStatus.Completed)) - file.parentFile?.mkdirs() - file.writeBytes(byteArrayOf(1)) - downloads.reconcile() + val deadline = System.nanoTime() + 15_000_000_000L + while (System.nanoTime() < deadline) { + runBlocking { + db.downloadDao().upsert(SongDownload(sourceId, songId, DownloadStatus.Completed)) + file.parentFile?.mkdirs() + file.writeBytes(byteArrayOf(1)) + downloads.reconcile() + } + if (downloads.localUri(songId) != null) return + Thread.sleep(10) } - await { downloads.localUri(songId) != null } + assertTrue("Timed out marking $songId downloaded", downloads.localUri(songId) != null) } private fun setOffline() {