mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
play an offline context by reference and filter its window
This commit is contained in:
@@ -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
|
runBlocking {
|
||||||
while (System.nanoTime() < deadline) {
|
downloads.reconcile()
|
||||||
runBlocking {
|
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
|
|
||||||
Thread.sleep(10)
|
|
||||||
}
|
}
|
||||||
assertTrue("Timed out marking $songId downloaded", downloads.localUri(songId) != null)
|
await { downloads.localUri(songId) != null }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setOffline() {
|
private fun setOffline() {
|
||||||
|
|||||||
Reference in New Issue
Block a user