diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index 9c680602..abc3e3cc 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -753,6 +753,31 @@ interface LibraryDao { genre: String, ): List + @Query( + "SELECT COUNT(*) FROM song_genres " + + "JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " + + "WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " + + "AND (songs.title COLLATE NOCASE < :title OR (songs.title COLLATE NOCASE = :title AND songs.id < :id))", + ) + suspend fun genreSongOrdinal( + sourceId: Long, + genre: String, + title: String, + id: String, + ): Long + + @Query( + "SELECT COUNT(*) FROM song_downloads d " + + "JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " + + "WHERE d.sourceId = :sourceId AND d.status = 'Completed' " + + "AND (songs.title COLLATE NOCASE < :title OR (songs.title COLLATE NOCASE = :title AND songs.id < :id))", + ) + suspend fun downloadedSongOrdinal( + sourceId: Long, + title: String, + id: String, + ): Long + @Query( "SELECT * FROM albums WHERE sourceId = :sourceId AND year >= :start AND year < :end " + "ORDER BY year DESC, name COLLATE NOCASE, id", diff --git a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt index 445cc094..2c0792c9 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt @@ -219,4 +219,59 @@ interface QueueDao { sourceId: Long, ids: List, ): List + + @Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre") + suspend fun genreLength( + sourceId: Long, + genre: String, + ): Long + + @Query( + "SELECT songs.*, albums.coverArt AS coverArt FROM song_genres " + + "JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " + + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + + "WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " + + "ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset", + ) + suspend fun genreSongs( + sourceId: Long, + genre: String, + offset: Long, + limit: Int, + ): List + + @Query( + "SELECT songs.id FROM song_genres " + + "JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " + + "WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " + + "ORDER BY songs.title COLLATE NOCASE, songs.id", + ) + suspend fun genreSongIds( + sourceId: Long, + genre: String, + ): List + + @Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'") + suspend fun downloadedLength(sourceId: Long): Long + + @Query( + "SELECT songs.*, albums.coverArt AS coverArt FROM song_downloads d " + + "JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " + + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + + "WHERE d.sourceId = :sourceId AND d.status = 'Completed' " + + "ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset", + ) + suspend fun downloadedSongs( + sourceId: Long, + offset: Long, + limit: Int, + ): List + + @Query( + "SELECT songs.id FROM song_downloads d " + + "JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " + + "WHERE d.sourceId = :sourceId AND d.status = 'Completed' " + + "ORDER BY songs.title COLLATE NOCASE, songs.id", + ) + suspend fun downloadedSongIds(sourceId: Long): List } diff --git a/app/src/main/kotlin/com/subtracks/data/model/Models.kt b/app/src/main/kotlin/com/subtracks/data/model/Models.kt index 43820d7e..7bfe0df0 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -316,7 +316,7 @@ data class DiscKey( val disc: Long, ) -enum class QueueKind { Playlist, Album, Song, Artist } +enum class QueueKind { Playlist, Album, Song, Artist, Genre, Downloaded } @Entity( tableName = "queue_entries", diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index 0b116fb6..686d0bf5 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -106,6 +106,19 @@ class LibraryRepository( genre: String, ): List = db.libraryDao().songsByGenreIds(sourceId, genre) + suspend fun genreSongOrdinal( + sourceId: Long, + genre: String, + title: String, + id: String, + ): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, title, id) + + suspend fun downloadedSongOrdinal( + sourceId: Long, + title: String, + id: String, + ): Long = db.libraryDao().downloadedSongOrdinal(sourceId, title, id) + fun albumsByDecade( sourceId: Long, decade: Long, diff --git a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt index cfe5f30f..21cdf985 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -255,6 +255,13 @@ class QueueRepository( songId: String, ) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Song, refId = songId) + fun genreEntry( + sourceId: Long, + genre: String, + ) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Genre, refId = genre) + + fun downloadedEntry(sourceId: Long) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Downloaded, refId = "") + suspend fun sourceName( kind: QueueKind, sourceId: Long, @@ -295,6 +302,14 @@ class QueueRepository( ?.album ?.takeIf { it.isNotBlank() } } + + QueueKind.Genre -> { + refId.takeIf { it.isNotBlank() } + } + + QueueKind.Downloaded -> { + null + } } } @@ -646,6 +661,8 @@ class QueueRepository( QueueKind.Album -> dao.albumSongIds(ref.sourceId, ref.refId) QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId) QueueKind.Artist -> dao.artistSongIds(ref.sourceId, ref.refId) + QueueKind.Genre -> dao.genreSongIds(ref.sourceId, ref.refId) + QueueKind.Downloaded -> dao.downloadedSongIds(ref.sourceId) } val from = ref.offset.toInt().coerceIn(0, entryIds.size) val to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size) @@ -746,6 +763,8 @@ class QueueRepository( QueueKind.Playlist -> dao.playlistSongs(entry.sourceId, entry.refId, offset, limit) QueueKind.Album -> dao.albumSongs(entry.sourceId, entry.refId, offset, limit) QueueKind.Artist -> dao.artistSongs(entry.sourceId, entry.refId, offset, limit) + QueueKind.Genre -> dao.genreSongs(entry.sourceId, entry.refId, offset, limit) + QueueKind.Downloaded -> dao.downloadedSongs(entry.sourceId, offset, limit) QueueKind.Song -> emptyList() } if (fresh.isNotEmpty()) remember(ref, offset, fresh) @@ -769,6 +788,8 @@ class QueueRepository( QueueKind.Album -> OrderKey(row.song.id, disc = row.song.disc, track = row.song.track) QueueKind.Playlist -> OrderKey(row.song.id, position = row.playlistPosition) QueueKind.Artist -> OrderKey(row.song.id) + QueueKind.Genre -> OrderKey(row.song.id) + QueueKind.Downloaded -> OrderKey(row.song.id) QueueKind.Song -> OrderKey(row.song.id) } @@ -809,6 +830,8 @@ class QueueRepository( QueueKind.Playlist -> dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit) QueueKind.Album -> dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit) QueueKind.Artist -> emptyList() + QueueKind.Genre -> emptyList() + QueueKind.Downloaded -> emptyList() QueueKind.Song -> emptyList() } @@ -822,6 +845,8 @@ class QueueRepository( QueueKind.Playlist -> dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit) QueueKind.Album -> dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit) QueueKind.Artist -> emptyList() + QueueKind.Genre -> emptyList() + QueueKind.Downloaded -> emptyList() QueueKind.Song -> emptyList() } @@ -831,6 +856,8 @@ class QueueRepository( QueueKind.Playlist -> position != null QueueKind.Album -> disc != null && track != null QueueKind.Artist -> false + QueueKind.Genre -> false + QueueKind.Downloaded -> false QueueKind.Song -> false } @@ -874,6 +901,14 @@ class QueueRepository( QueueKind.Artist -> { dao.artistLength(sourceId, refId) } + + QueueKind.Genre -> { + dao.genreLength(sourceId, refId) + } + + QueueKind.Downloaded -> { + dao.downloadedLength(sourceId) + } } val available = (total - offset).coerceAtLeast(0) return count?.coerceAtMost(available) ?: available diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 5e34eb24..ce3811f8 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -226,6 +226,17 @@ class PlaybackController( startPosition: Long, ) = playContext(queueRepository.playlistEntry(sourceId, playlistId), startPosition) + fun playGenre( + sourceId: Long, + genre: String, + startOrdinal: Long, + ) = playContext(queueRepository.genreEntry(sourceId, genre), startOrdinal) + + fun playDownloaded( + sourceId: Long, + startOrdinal: Long, + ) = playContext(queueRepository.downloadedEntry(sourceId), startOrdinal) + fun playAlbumInOrder( sourceId: Long, albumId: String, diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt index 13cef5a9..a7f4fb50 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt @@ -241,8 +241,8 @@ internal fun CoroutineScope.playGenreList( song: Song, ) { launch { - val ids = libraryRepository.genreSongIds(song.sourceId, genre) - playbackController.playSongs(song.sourceId, ids, ids.indexOf(song.id).coerceAtLeast(0)) + val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.title, song.id) + playbackController.playGenre(song.sourceId, genre, ordinal) } } @@ -252,7 +252,7 @@ internal fun CoroutineScope.playDownloadedList( song: Song, ) { launch { - val ids = libraryRepository.downloadedSongIds(song.sourceId) - playbackController.playSongs(song.sourceId, ids, ids.indexOf(song.id).coerceAtLeast(0)) + val ordinal = libraryRepository.downloadedSongOrdinal(song.sourceId, song.title, song.id) + playbackController.playDownloaded(song.sourceId, ordinal) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt index f80ebc38..c72c36f3 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -470,6 +470,8 @@ private fun QueueKind.label(): String = QueueKind.Playlist -> "playlist" QueueKind.Song -> "song" QueueKind.Artist -> "artist" + QueueKind.Genre -> "genre" + QueueKind.Downloaded -> "downloads" } private fun formatTime(milliseconds: Long): String { diff --git a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt index 5e22631a..54a3d6d5 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt @@ -3,16 +3,19 @@ package com.subtracks.data.repo import android.content.Context import androidx.room3.Room import androidx.room3.useReaderConnection +import androidx.room3.useWriterConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.model.Album +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.QueueKind import com.subtracks.data.model.Song +import com.subtracks.data.model.SongDownload import com.subtracks.data.model.Source import kotlinx.coroutines.test.runTest import org.junit.After @@ -55,6 +58,35 @@ class QueueRepositoryTest { assertEquals(listOf("s1", "s2", "s3"), resolveAll(snapshot)) } + @Test + fun wholeGenreEntryResolvesEveryTrackInTitleOrder() = + runTest { + seedLibrary() + insertGenre("s4", "Rock") + insertGenre("s1", "Rock") + insertGenre("s3", "Rock") + insertGenre("s2", "Jazz") + + val snapshot = repository.snapshotAfter(listOf(repository.genreEntry(1, "Rock"))) + + assertEquals(3, snapshot.size) + assertEquals(listOf("s1", "s3", "s4"), resolveAll(snapshot)) + } + + @Test + fun downloadedEntryResolvesOnlyCompletedDownloads() = + runTest { + seedLibrary() + db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed)) + db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Completed)) + db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Running)) + + val snapshot = repository.snapshotAfter(listOf(repository.downloadedEntry(1))) + + assertEquals(2, snapshot.size) + assertEquals(listOf("s1", "s3"), resolveAll(snapshot)) + } + @Test fun rangeLimitsAnEntryToASlice() = runTest { @@ -912,4 +944,15 @@ class QueueRepositoryTest { genre = null, created = created, ) + + private suspend fun insertGenre( + songId: String, + genre: String, + ) { + db.useWriterConnection { connection -> + connection.usePrepared( + "INSERT INTO song_genres (sourceId, songId, position, genre) VALUES (1, '$songId', 0, '$genre')", + ) { it.step() } + } + } }