play an offline context by reference and filter its window

This commit is contained in:
austinried
2026-10-04 00:30:04 +00:00
parent 3e042cb380
commit 554b7ef25d
11 changed files with 110 additions and 116 deletions
@@ -483,29 +483,6 @@ interface LibraryDao {
albumId: String, albumId: String,
): Flow<List<Song>> ): 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") @Query("$PLAYLIST_SONGS_SELECT AND $DOWNLOADED_SONG$PLAYLIST_SONGS_ORDER")
fun playlistSongsDownloaded( fun playlistSongsDownloaded(
sourceId: Long, sourceId: Long,
@@ -208,6 +208,15 @@ class LibraryRepository(
db.libraryDao().songsByAlbum(sourceId, albumId) db.libraryDao().songsByAlbum(sourceId, albumId)
} }
suspend fun albumSongOrdinal(
sourceId: Long,
albumId: String,
songId: String,
): Long {
val index = db.queueDao().albumSongIds(sourceId, albumId).indexOf(songId)
return if (index < 0) 0L else index.toLong()
}
fun playlist( fun playlist(
sourceId: Long, sourceId: Long,
playlistId: String, playlistId: String,
@@ -250,19 +250,6 @@ 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,
@@ -146,7 +146,6 @@ class PlaybackController(
private val offline: Boolean get() = sourceRepository.offline.value private val offline: Boolean get() = sourceRepository.offline.value
private var windowPositions = LongArray(0) private var windowPositions = LongArray(0)
private var windowFiltered = false private var windowFiltered = false
private var contextOverride: QueueContext? = null
init { init {
scope.launch { scope.launch {
@@ -242,48 +241,9 @@ class PlaybackController(
entry: QueueEntry, entry: QueueEntry,
start: Long, start: Long,
disableShuffle: Boolean = false, disableShuffle: Boolean = false,
) { ) = play(listOf(entry), start, disableShuffle)
if (!offline) {
contextOverride = null
play(listOf(entry), start, disableShuffle)
return
}
scope.launch {
val entries = downloadedEntries(entry)
if (entries.isEmpty()) {
showMessage("Nothing downloaded for offline playback")
return@launch
}
contextOverride = QueueContext(entry.kind, entry.sourceId, entry.refId)
play(entries, start, disableShuffle = true)
}
}
private fun shuffleContext(entry: QueueEntry) { private fun shuffleContext(entry: QueueEntry) = shufflePlay(listOf(entry))
if (!offline) {
contextOverride = null
shufflePlay(listOf(entry))
return
}
scope.launch {
val entries = downloadedEntries(entry)
if (entries.isEmpty()) {
showMessage("Nothing downloaded for offline playback")
return@launch
}
contextOverride = QueueContext(entry.kind, entry.sourceId, entry.refId)
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>) { private fun shufflePlay(entries: List<QueueEntry>) {
if (player == null) { if (player == null) {
@@ -1024,7 +984,6 @@ class PlaybackController(
windowEnd = -1 windowEnd = -1
windowPositions = LongArray(0) windowPositions = LongArray(0)
windowFiltered = false windowFiltered = false
contextOverride = null
lastPosition = null lastPosition = null
lastEdit = null lastEdit = null
player?.run { player?.run {
@@ -1393,7 +1352,6 @@ class PlaybackController(
} }
private fun contextAt(position: Long?): QueueContext? { private fun contextAt(position: Long?): QueueContext? {
contextOverride?.let { return it }
val snapshot = snapshot ?: return null val snapshot = snapshot ?: return null
val play = position?.let { snapshot.anchorContextPlay(it) } ?: return null val play = position?.let { snapshot.anchorContextPlay(it) } ?: return null
val entry = snapshot.locateContext(play)?.first ?: return null val entry = snapshot.locateContext(play)?.first ?: return null
@@ -137,10 +137,10 @@ fun AlbumDetailScreen(
artwork: ArtworkColors?, artwork: ArtworkColors?,
downloads: Map<String, SongDownload> = emptyMap(), downloads: Map<String, SongDownload> = emptyMap(),
onBack: () -> Unit, onBack: () -> Unit,
onSongClick: (Int) -> Unit, onSongClick: (String) -> Unit,
onSongLongClick: (MenuTarget) -> Unit = {}, onSongLongClick: (MenuTarget) -> Unit = {},
onShuffle: () -> Unit = {}, onShuffle: () -> Unit = {},
onPlay: () -> Unit = { onSongClick(0) }, onPlay: () -> Unit = {},
downloadStatus: ListDownloadStatus = ListDownloadStatus(), downloadStatus: ListDownloadStatus = ListDownloadStatus(),
onDownloadAction: (BulkDownloadAction) -> Unit = {}, onDownloadAction: (BulkDownloadAction) -> Unit = {},
onMore: () -> Unit = {}, onMore: () -> Unit = {},
@@ -204,7 +204,7 @@ fun AlbumDetailScreen(
}, },
content = { rowModifier -> content = { rowModifier ->
var lastDisc: Long? = null var lastDisc: Long? = null
songs.forEachIndexed { index, song -> songs.forEach { song ->
val disc = song.disc ?: 1L val disc = song.disc ?: 1L
val discLabel = discLabels[disc] val discLabel = discLabels[disc]
if (disc != lastDisc && (multiDisc || discLabel != null)) { if (disc != lastDisc && (multiDisc || discLabel != null)) {
@@ -221,7 +221,7 @@ fun AlbumDetailScreen(
download = downloads[song.id], download = downloads[song.id],
modifier = modifier =
rowModifier.combinedClickable( rowModifier.combinedClickable(
onClick = { onSongClick(index) }, onClick = { onSongClick(song.id) },
onLongClick = { onLongClick = {
onSongLongClick(MenuTarget.Song(song, coverArt(album?.coverArt, true), downloads[song.id])) onSongLongClick(MenuTarget.Song(song, coverArt(album?.coverArt, true), downloads[song.id]))
}, },
@@ -79,9 +79,10 @@ class AlbumDetailViewModel(
} }
} }
fun play(startIndex: Int) { fun play(songId: String) {
viewModelScope.launch { viewModelScope.launch {
playbackController.playAlbum(sourceId.first(), albumId, startIndex.toLong()) val id = sourceId.first()
playbackController.playAlbum(id, albumId, libraryRepository.albumSongOrdinal(id, albumId, songId))
} }
} }
@@ -113,10 +113,10 @@ fun PlaylistDetailScreen(
downloadStatus: ListDownloadStatus = ListDownloadStatus(), downloadStatus: ListDownloadStatus = ListDownloadStatus(),
onDownloadAction: (BulkDownloadAction) -> Unit = {}, onDownloadAction: (BulkDownloadAction) -> Unit = {},
onBack: () -> Unit, onBack: () -> Unit,
onSongClick: (Int) -> Unit, onSongClick: (Long) -> Unit,
onSongLongClick: (MenuTarget) -> Unit = {}, onSongLongClick: (MenuTarget) -> Unit = {},
onShuffle: () -> Unit = {}, onShuffle: () -> Unit = {},
onPlay: () -> Unit = { onSongClick(0) }, onPlay: () -> Unit = {},
onMore: () -> Unit = {}, onMore: () -> Unit = {},
playingSongId: String? = null, playingSongId: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -155,7 +155,7 @@ fun PlaylistDetailScreen(
download = downloads[item.song.id], download = downloads[item.song.id],
modifier = modifier =
rowModifier.combinedClickable( rowModifier.combinedClickable(
onClick = { onSongClick(index) }, onClick = { onSongClick(item.position) },
onLongClick = { onLongClick = {
onSongLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true), downloads[item.song.id])) onSongLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true), downloads[item.song.id]))
}, },
@@ -81,9 +81,9 @@ class PlaylistDetailViewModel(
thumbnail: Boolean, thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun play(startIndex: Int) { fun play(position: Long) {
viewModelScope.launch { viewModelScope.launch {
playbackController.playPlaylist(sourceId.first(), playlistId, startIndex.toLong()) playbackController.playPlaylist(sourceId.first(), playlistId, position)
} }
} }
@@ -375,8 +375,6 @@ class LibraryDaoTest {
db.downloadDao().upsert(SongDownload(sourceId, "s3", DownloadStatus.Completed)) 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.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( assertEquals(
listOf("s1", "s3"), listOf("s1", "s3"),
dao.playlistSongsDownloaded(sourceId, "pl-1").page().map { it.song.id }, dao.playlistSongsDownloaded(sourceId, "pl-1").page().map { it.song.id },
@@ -130,6 +130,22 @@ class LibraryRepositoryTest {
} }
} }
@Test
fun albumSongOrdinalFollowsTheAlbumOrder() =
runBlocking {
val sourceId = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false)
db.libraryDao().upsertSongs(
listOf(
seedSong(sourceId).copy(id = "s3", track = 3),
seedSong(sourceId).copy(id = "s1", track = 1),
seedSong(sourceId).copy(id = "s2", track = 2),
),
)
assertEquals(0L, repository.albumSongOrdinal(sourceId, "al1", "s1"))
assertEquals(2L, repository.albumSongOrdinal(sourceId, "al1", "s3"))
}
private suspend fun withSource(block: suspend (MockWebServer) -> Unit) { private suspend fun withSource(block: suspend (MockWebServer) -> Unit) {
val server = MockWebServer() val server = MockWebServer()
server.start() server.start()
@@ -11,6 +11,8 @@ import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
@@ -2495,6 +2497,68 @@ class PlaybackControllerTest {
) )
} }
@Test
fun offlinePlayAlbumFiltersTheWindowToDownloadedSongs() {
seedAlbum(4, sourceId = 1)
markDownloaded(1, "s1")
markDownloaded(1, "s3")
setOffline()
controller.playAlbum(1, "al1", 0)
await { handle.items.map { it.id } == listOf("s1", "s3") }
assertEquals(listOf("s1", "s3"), handle.items.map { it.id })
assertEquals(listOf(QueueKind.Album), runBlocking { queues.snapshot().entries }.map { it.entry.kind })
assertEquals(1, runBlocking { queues.snapshot().entries }.size)
}
@Test
fun offlinePlaylistPlayKeepsASingleReferenceQueue() {
seedAlbum(3, sourceId = 1)
runBlocking {
db.libraryDao().upsertPlaylists(
listOf(Playlist(1, "pl1", "Playlist", comment = null, coverArt = null, songCount = 3, created = 0)),
)
db.libraryDao().upsertPlaylistSongs(
listOf(
PlaylistSong(1, "pl1", "s1", 0),
PlaylistSong(1, "pl1", "s2", 1),
PlaylistSong(1, "pl1", "s3", 2),
),
)
}
markDownloaded(1, "s1")
markDownloaded(1, "s2")
markDownloaded(1, "s3")
setOffline()
controller.playPlaylist(1, "pl1", 0)
await { handle.items.map { it.id } == listOf("s1", "s2", "s3") }
assertEquals(listOf(QueueKind.Playlist), runBlocking { queues.snapshot().entries }.map { it.entry.kind })
assertEquals(1, runBlocking { queues.snapshot().entries }.size)
}
@Test
fun offlinePlayAlbumStartsAtTheOrdinalItIsGiven() {
seedAlbum(4, sourceId = 1)
markDownloaded(1, "s2")
markDownloaded(1, "s3")
setOffline()
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
assertEquals(
"s3",
controller.state.value.item
?.id,
)
}
@Test @Test
fun offlinePlayAlbumKeepsTheAlbumAsThePlayingContext() { fun offlinePlayAlbumKeepsTheAlbumAsThePlayingContext() {
seedAlbum(4, sourceId = 1) seedAlbum(4, sourceId = 1)
@@ -2558,19 +2622,6 @@ class PlaybackControllerTest {
) )
} }
@Test
fun offlinePlayAlbumBuildsADownloadedOnlyQueue() {
seedAlbum(4, sourceId = 1)
markDownloaded(1, "s1")
markDownloaded(1, "s3")
setOffline()
controller.playAlbum(1, "al1", 0)
await { handle.items.map { it.id } == listOf("s1", "s3") }
assertEquals(listOf("s1", "s3"), handle.items.map { it.id })
}
@Test @Test
fun togglingOfflineKeepsADownloadedCurrentTrack() { fun togglingOfflineKeepsADownloadedCurrentTrack() {
seedAlbum(4, sourceId = 1) seedAlbum(4, sourceId = 1)
@@ -2682,17 +2733,14 @@ class PlaybackControllerTest {
songId: String, songId: String,
) { ) {
val file = File(downloadsDir, "$sourceId/$songId") val file = File(downloadsDir, "$sourceId/$songId")
val deadline = System.nanoTime() + 5_000_000_000L
while (System.nanoTime() < deadline) {
runBlocking { runBlocking {
downloads.reconcile()
db.downloadDao().upsert(SongDownload(sourceId, songId, DownloadStatus.Completed)) db.downloadDao().upsert(SongDownload(sourceId, songId, DownloadStatus.Completed))
file.parentFile?.mkdirs() file.parentFile?.mkdirs()
file.writeBytes(byteArrayOf(1)) file.writeBytes(byteArrayOf(1))
downloads.reconcile()
} }
if (downloads.localUri(songId) != null) return await { downloads.localUri(songId) != null }
Thread.sleep(10)
}
assertTrue("Timed out marking $songId downloaded", downloads.localUri(songId) != null)
} }
private fun setOffline() { private fun setOffline() {