mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
drop the songs tab and the whole-library song queue
This commit is contained in:
@@ -23,27 +23,6 @@ internal const val PLAYLIST_SONGS_SQL =
|
|||||||
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " +
|
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " +
|
||||||
"ORDER BY playlist_songs.position"
|
"ORDER BY playlist_songs.position"
|
||||||
|
|
||||||
internal const val SONGS_SQL =
|
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId " +
|
|
||||||
"ORDER BY albums.albumArtist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, " +
|
|
||||||
"songs.title COLLATE NOCASE, songs.id"
|
|
||||||
|
|
||||||
internal const val SONGS_IDS_SELECT =
|
|
||||||
"SELECT songs.id FROM songs " +
|
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId " +
|
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
|
|
||||||
"(:starredFilter = 2 AND songs.starred IS NULL)) "
|
|
||||||
|
|
||||||
internal const val SONGS_LIST_SELECT =
|
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId " +
|
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
|
|
||||||
"(:starredFilter = 2 AND songs.starred IS NULL)) "
|
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
|
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
|
||||||
interface LibraryDao {
|
interface LibraryDao {
|
||||||
@@ -431,168 +410,6 @@ interface LibraryDao {
|
|||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Playlist>
|
): PagingSource<Int, Playlist>
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId " +
|
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
|
|
||||||
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
|
|
||||||
"AND (:search = '' OR instr(lower(songs.title), lower(:search)) > 0 " +
|
|
||||||
"OR instr(lower(songs.artist), lower(:search)) > 0 OR instr(lower(songs.album), lower(:search)) > 0) " +
|
|
||||||
"ORDER BY albums.albumArtist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, " +
|
|
||||||
"songs.title COLLATE NOCASE, songs.id",
|
|
||||||
)
|
|
||||||
fun songs(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
): PagingSource<Int, SongListItem>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId " +
|
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
|
|
||||||
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
|
|
||||||
"AND (:search = '' OR instr(lower(songs.title), lower(:search)) > 0 " +
|
|
||||||
"OR instr(lower(songs.artist), lower(:search)) > 0 OR instr(lower(songs.album), lower(:search)) > 0) " +
|
|
||||||
"ORDER BY albums.albumArtist COLLATE NOCASE DESC, songs.album COLLATE NOCASE DESC, songs.disc DESC, " +
|
|
||||||
"songs.track DESC, songs.title COLLATE NOCASE DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id DESC",
|
|
||||||
)
|
|
||||||
fun songsByAlbumReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
): PagingSource<Int, SongListItem>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId " +
|
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
|
|
||||||
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
|
|
||||||
"AND (:search = '' OR instr(lower(songs.title), lower(:search)) > 0 " +
|
|
||||||
"OR instr(lower(songs.artist), lower(:search)) > 0 OR instr(lower(songs.album), lower(:search)) > 0) " +
|
|
||||||
"ORDER BY songs.title COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id",
|
|
||||||
)
|
|
||||||
fun songsByTitle(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
): PagingSource<Int, SongListItem>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId " +
|
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
|
|
||||||
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
|
|
||||||
"AND (:search = '' OR instr(lower(songs.title), lower(:search)) > 0 " +
|
|
||||||
"OR instr(lower(songs.artist), lower(:search)) > 0 OR instr(lower(songs.album), lower(:search)) > 0) " +
|
|
||||||
"ORDER BY songs.title COLLATE NOCASE DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id",
|
|
||||||
)
|
|
||||||
fun songsByTitleReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
): PagingSource<Int, SongListItem>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId " +
|
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
|
|
||||||
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
|
|
||||||
"AND (:search = '' OR instr(lower(songs.title), lower(:search)) > 0 " +
|
|
||||||
"OR instr(lower(songs.artist), lower(:search)) > 0 OR instr(lower(songs.album), lower(:search)) > 0) " +
|
|
||||||
"ORDER BY songs.artist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id",
|
|
||||||
)
|
|
||||||
fun songsByArtist(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
): PagingSource<Int, SongListItem>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId " +
|
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
|
|
||||||
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
|
|
||||||
"AND (:search = '' OR instr(lower(songs.title), lower(:search)) > 0 " +
|
|
||||||
"OR instr(lower(songs.artist), lower(:search)) > 0 OR instr(lower(songs.album), lower(:search)) > 0) " +
|
|
||||||
"ORDER BY songs.artist COLLATE NOCASE DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id",
|
|
||||||
)
|
|
||||||
fun songsByArtistReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
): PagingSource<Int, SongListItem>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId " +
|
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
|
|
||||||
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
|
|
||||||
"AND (:search = '' OR instr(lower(songs.title), lower(:search)) > 0 " +
|
|
||||||
"OR instr(lower(songs.artist), lower(:search)) > 0 OR instr(lower(songs.album), lower(:search)) > 0) " +
|
|
||||||
"ORDER BY songs.starred IS NULL, songs.starred DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id",
|
|
||||||
)
|
|
||||||
fun songsByStarred(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
): PagingSource<Int, SongListItem>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId " +
|
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
|
|
||||||
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
|
|
||||||
"AND (:search = '' OR instr(lower(songs.title), lower(:search)) > 0 " +
|
|
||||||
"OR instr(lower(songs.artist), lower(:search)) > 0 OR instr(lower(songs.album), lower(:search)) > 0) " +
|
|
||||||
"ORDER BY songs.starred IS NULL, songs.starred ASC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id",
|
|
||||||
)
|
|
||||||
fun songsByStarredReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
): PagingSource<Int, SongListItem>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId " +
|
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
|
|
||||||
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
|
|
||||||
"AND (:search = '' OR instr(lower(songs.title), lower(:search)) > 0 " +
|
|
||||||
"OR instr(lower(songs.artist), lower(:search)) > 0 OR instr(lower(songs.album), lower(:search)) > 0) " +
|
|
||||||
"ORDER BY songs.created DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id",
|
|
||||||
)
|
|
||||||
fun songsByAdded(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
): PagingSource<Int, SongListItem>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId " +
|
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
|
|
||||||
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
|
|
||||||
"AND (:search = '' OR instr(lower(songs.title), lower(:search)) > 0 " +
|
|
||||||
"OR instr(lower(songs.artist), lower(:search)) > 0 OR instr(lower(songs.album), lower(:search)) > 0) " +
|
|
||||||
"ORDER BY songs.created ASC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id",
|
|
||||||
)
|
|
||||||
fun songsByAddedReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
): PagingSource<Int, SongListItem>
|
|
||||||
|
|
||||||
@Query(PLAYLIST_SONGS_SQL)
|
@Query(PLAYLIST_SONGS_SQL)
|
||||||
fun playlistSongs(
|
fun playlistSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -640,181 +457,4 @@ interface LibraryDao {
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
): Flow<List<Song>>
|
): Flow<List<Song>>
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT COUNT(*) FROM songs WHERE songs.sourceId = :sourceId AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR (:starredFilter = 2 AND songs.starred IS NULL))",
|
|
||||||
)
|
|
||||||
suspend fun songCountForFilter(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
): Long
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"${SONGS_IDS_SELECT}ORDER BY albums.albumArtist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.title COLLATE NOCASE, songs.id",
|
|
||||||
)
|
|
||||||
suspend fun songIdsByAlbum(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
): List<String>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"${SONGS_IDS_SELECT}ORDER BY albums.albumArtist COLLATE NOCASE DESC, songs.album COLLATE NOCASE DESC, " +
|
|
||||||
"songs.disc DESC, songs.track DESC, songs.title COLLATE NOCASE DESC, songs.id DESC",
|
|
||||||
)
|
|
||||||
suspend fun songIdsByAlbumReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
): List<String>
|
|
||||||
|
|
||||||
@Query("${SONGS_IDS_SELECT}ORDER BY songs.title COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id")
|
|
||||||
suspend fun songIdsByTitle(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
): List<String>
|
|
||||||
|
|
||||||
@Query("${SONGS_IDS_SELECT}ORDER BY songs.title COLLATE NOCASE DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id")
|
|
||||||
suspend fun songIdsByTitleReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
): List<String>
|
|
||||||
|
|
||||||
@Query("${SONGS_IDS_SELECT}ORDER BY songs.artist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id")
|
|
||||||
suspend fun songIdsByArtist(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
): List<String>
|
|
||||||
|
|
||||||
@Query("${SONGS_IDS_SELECT}ORDER BY songs.artist COLLATE NOCASE DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id")
|
|
||||||
suspend fun songIdsByArtistReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
): List<String>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"${SONGS_IDS_SELECT}ORDER BY songs.starred IS NULL, songs.starred DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id",
|
|
||||||
)
|
|
||||||
suspend fun songIdsByStarred(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
): List<String>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"${SONGS_IDS_SELECT}ORDER BY songs.starred IS NULL, songs.starred ASC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id",
|
|
||||||
)
|
|
||||||
suspend fun songIdsByStarredReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
): List<String>
|
|
||||||
|
|
||||||
@Query("${SONGS_IDS_SELECT}ORDER BY songs.created DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id")
|
|
||||||
suspend fun songIdsByAdded(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
): List<String>
|
|
||||||
|
|
||||||
@Query("${SONGS_IDS_SELECT}ORDER BY songs.created ASC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id")
|
|
||||||
suspend fun songIdsByAddedReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
): List<String>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"${SONGS_LIST_SELECT}ORDER BY albums.albumArtist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
|
||||||
)
|
|
||||||
suspend fun songsPageByAlbum(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
limit: Int,
|
|
||||||
offset: Long,
|
|
||||||
): List<SongListItem>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"${SONGS_LIST_SELECT}ORDER BY albums.albumArtist COLLATE NOCASE DESC, songs.album COLLATE NOCASE DESC, songs.disc DESC, songs.track DESC, songs.title COLLATE NOCASE DESC, songs.id DESC LIMIT :limit OFFSET :offset",
|
|
||||||
)
|
|
||||||
suspend fun songsPageByAlbumReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
limit: Int,
|
|
||||||
offset: Long,
|
|
||||||
): List<SongListItem>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"${SONGS_LIST_SELECT}ORDER BY songs.title COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id LIMIT :limit OFFSET :offset",
|
|
||||||
)
|
|
||||||
suspend fun songsPageByTitle(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
limit: Int,
|
|
||||||
offset: Long,
|
|
||||||
): List<SongListItem>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"${SONGS_LIST_SELECT}ORDER BY songs.title COLLATE NOCASE DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id LIMIT :limit OFFSET :offset",
|
|
||||||
)
|
|
||||||
suspend fun songsPageByTitleReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
limit: Int,
|
|
||||||
offset: Long,
|
|
||||||
): List<SongListItem>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"${SONGS_LIST_SELECT}ORDER BY songs.artist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id LIMIT :limit OFFSET :offset",
|
|
||||||
)
|
|
||||||
suspend fun songsPageByArtist(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
limit: Int,
|
|
||||||
offset: Long,
|
|
||||||
): List<SongListItem>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"${SONGS_LIST_SELECT}ORDER BY songs.artist COLLATE NOCASE DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id LIMIT :limit OFFSET :offset",
|
|
||||||
)
|
|
||||||
suspend fun songsPageByArtistReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
limit: Int,
|
|
||||||
offset: Long,
|
|
||||||
): List<SongListItem>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"${SONGS_LIST_SELECT}ORDER BY songs.starred IS NULL, songs.starred DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id LIMIT :limit OFFSET :offset",
|
|
||||||
)
|
|
||||||
suspend fun songsPageByStarred(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
limit: Int,
|
|
||||||
offset: Long,
|
|
||||||
): List<SongListItem>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"${SONGS_LIST_SELECT}ORDER BY songs.starred IS NULL, songs.starred ASC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id LIMIT :limit OFFSET :offset",
|
|
||||||
)
|
|
||||||
suspend fun songsPageByStarredReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
limit: Int,
|
|
||||||
offset: Long,
|
|
||||||
): List<SongListItem>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"${SONGS_LIST_SELECT}ORDER BY songs.created DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id LIMIT :limit OFFSET :offset",
|
|
||||||
)
|
|
||||||
suspend fun songsPageByAdded(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
limit: Int,
|
|
||||||
offset: Long,
|
|
||||||
): List<SongListItem>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"${SONGS_LIST_SELECT}ORDER BY songs.created ASC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id LIMIT :limit OFFSET :offset",
|
|
||||||
)
|
|
||||||
suspend fun songsPageByAddedReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
limit: Int,
|
|
||||||
offset: Long,
|
|
||||||
): List<SongListItem>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,9 +62,6 @@ interface QueueDao {
|
|||||||
albumId: String,
|
albumId: String,
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId")
|
|
||||||
suspend fun songsLength(sourceId: Long): Long
|
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId AND id = :songId")
|
@Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId AND id = :songId")
|
||||||
suspend fun songLength(
|
suspend fun songLength(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -87,13 +84,6 @@ interface QueueDao {
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
): List<SongListItem>
|
): List<SongListItem>
|
||||||
|
|
||||||
@Query("$SONGS_SQL LIMIT :limit OFFSET :offset")
|
|
||||||
suspend fun songs(
|
|
||||||
sourceId: Long,
|
|
||||||
offset: Long,
|
|
||||||
limit: Int,
|
|
||||||
): List<SongListItem>
|
|
||||||
|
|
||||||
@Query("$SONG_SQL LIMIT :limit OFFSET :offset")
|
@Query("$SONG_SQL LIMIT :limit OFFSET :offset")
|
||||||
suspend fun song(
|
suspend fun song(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -111,15 +101,6 @@ interface QueueDao {
|
|||||||
albumId: String,
|
albumId: String,
|
||||||
): List<String>
|
): List<String>
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT songs.id FROM songs " +
|
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId " +
|
|
||||||
"ORDER BY albums.albumArtist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, " +
|
|
||||||
"songs.title COLLATE NOCASE, songs.id",
|
|
||||||
)
|
|
||||||
suspend fun songIds(sourceId: Long): List<String>
|
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.id FROM playlist_songs " +
|
"SELECT songs.id FROM playlist_songs " +
|
||||||
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ data class DiscKey(
|
|||||||
val disc: Long,
|
val disc: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class QueueKind { Playlist, Album, Song, Songs }
|
enum class QueueKind { Playlist, Album, Song }
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "queue_entries",
|
tableName = "queue_entries",
|
||||||
@@ -261,5 +261,5 @@ class QueueKindConverter {
|
|||||||
fun fromQueueKind(kind: QueueKind): String = kind.name
|
fun fromQueueKind(kind: QueueKind): String = kind.name
|
||||||
|
|
||||||
@ColumnTypeConverter
|
@ColumnTypeConverter
|
||||||
fun toQueueKind(value: String): QueueKind = QueueKind.valueOf(value)
|
fun toQueueKind(value: String): QueueKind = QueueKind.entries.firstOrNull { it.name == value } ?: QueueKind.Song
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ enum class ArtistSort { Name, AlbumCount, Starred }
|
|||||||
|
|
||||||
enum class PlaylistSort { Name, Added, Updated }
|
enum class PlaylistSort { Name, Added, Updated }
|
||||||
|
|
||||||
enum class SongSort { Album, Title, Artist, Starred, Added }
|
|
||||||
|
|
||||||
enum class LibraryListTab(
|
enum class LibraryListTab(
|
||||||
val key: String,
|
val key: String,
|
||||||
val defaultSort: String,
|
val defaultSort: String,
|
||||||
@@ -26,7 +24,6 @@ enum class LibraryListTab(
|
|||||||
Albums("albums", AlbumSort.Name.name, true),
|
Albums("albums", AlbumSort.Name.name, true),
|
||||||
Artists("artists", ArtistSort.Name.name, true),
|
Artists("artists", ArtistSort.Name.name, true),
|
||||||
Playlists("playlists", PlaylistSort.Name.name, false),
|
Playlists("playlists", PlaylistSort.Name.name, false),
|
||||||
Songs("songs", SongSort.Album.name, true),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class StarredFilter { Any, Starred, NotStarred }
|
enum class StarredFilter { Any, Starred, NotStarred }
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import com.subtracks.data.model.SongListItem
|
|||||||
import com.subtracks.data.prefs.AlbumSort
|
import com.subtracks.data.prefs.AlbumSort
|
||||||
import com.subtracks.data.prefs.ArtistSort
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
import com.subtracks.data.prefs.PlaylistSort
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
import com.subtracks.data.prefs.SongSort
|
|
||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
@@ -155,58 +154,6 @@ class LibraryRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun songs(
|
|
||||||
sourceId: Long,
|
|
||||||
sort: SongSort,
|
|
||||||
descending: Boolean,
|
|
||||||
starred: StarredFilter,
|
|
||||||
search: String,
|
|
||||||
): Flow<PagingData<SongListItem>> =
|
|
||||||
pager(60) {
|
|
||||||
val dao = db.libraryDao()
|
|
||||||
when (sort) {
|
|
||||||
SongSort.Album -> {
|
|
||||||
if (descending) {
|
|
||||||
dao.songsByAlbumReversed(sourceId, starred.ordinal, search)
|
|
||||||
} else {
|
|
||||||
dao.songs(sourceId, starred.ordinal, search)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SongSort.Title -> {
|
|
||||||
if (descending) {
|
|
||||||
dao.songsByTitleReversed(sourceId, starred.ordinal, search)
|
|
||||||
} else {
|
|
||||||
dao.songsByTitle(sourceId, starred.ordinal, search)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SongSort.Artist -> {
|
|
||||||
if (descending) {
|
|
||||||
dao.songsByArtistReversed(sourceId, starred.ordinal, search)
|
|
||||||
} else {
|
|
||||||
dao.songsByArtist(sourceId, starred.ordinal, search)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SongSort.Starred -> {
|
|
||||||
if (descending) {
|
|
||||||
dao.songsByStarredReversed(sourceId, starred.ordinal, search)
|
|
||||||
} else {
|
|
||||||
dao.songsByStarred(sourceId, starred.ordinal, search)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SongSort.Added -> {
|
|
||||||
if (descending) {
|
|
||||||
dao.songsByAddedReversed(sourceId, starred.ordinal, search)
|
|
||||||
} else {
|
|
||||||
dao.songsByAdded(sourceId, starred.ordinal, search)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun album(
|
fun album(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
|
|||||||
@@ -4,15 +4,12 @@ import androidx.room3.deferredTransaction
|
|||||||
import androidx.room3.immediateTransaction
|
import androidx.room3.immediateTransaction
|
||||||
import androidx.room3.useReaderConnection
|
import androidx.room3.useReaderConnection
|
||||||
import androidx.room3.useWriterConnection
|
import androidx.room3.useWriterConnection
|
||||||
import com.subtracks.data.db.LibraryDao
|
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.model.PlaybackCursor
|
import com.subtracks.data.model.PlaybackCursor
|
||||||
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.ShuffleOrder
|
import com.subtracks.data.model.ShuffleOrder
|
||||||
import com.subtracks.data.model.SongListItem
|
import com.subtracks.data.model.SongListItem
|
||||||
import com.subtracks.data.prefs.SongSort
|
|
||||||
import com.subtracks.data.prefs.StarredFilter
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -20,12 +17,6 @@ import java.util.concurrent.atomic.AtomicLong
|
|||||||
|
|
||||||
const val QUEUE_CHUNK = 60
|
const val QUEUE_CHUNK = 60
|
||||||
|
|
||||||
data class QueueSongQuery(
|
|
||||||
val sort: SongSort,
|
|
||||||
val descending: Boolean,
|
|
||||||
val starred: StarredFilter,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ResolvedQueueEntry(
|
data class ResolvedQueueEntry(
|
||||||
val entry: QueueEntry,
|
val entry: QueueEntry,
|
||||||
val length: Long,
|
val length: Long,
|
||||||
@@ -100,26 +91,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)
|
||||||
|
|
||||||
fun songsEntry(sourceId: Long) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Songs, refId = "")
|
|
||||||
|
|
||||||
fun songsEntry(
|
|
||||||
sourceId: Long,
|
|
||||||
query: QueueSongQuery,
|
|
||||||
) = QueueEntry(
|
|
||||||
position = 0,
|
|
||||||
sourceId = sourceId,
|
|
||||||
kind = QueueKind.Songs,
|
|
||||||
refId = "${query.sort.name}|${if (query.descending) 1 else 0}|${query.starred.ordinal}",
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun songsIndexOf(
|
|
||||||
entry: QueueEntry,
|
|
||||||
songId: String,
|
|
||||||
): Long? {
|
|
||||||
val index = songIdsFor(entry).indexOf(songId)
|
|
||||||
return if (index >= 0) index.toLong() else null
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun sourceName(
|
suspend fun sourceName(
|
||||||
kind: QueueKind,
|
kind: QueueKind,
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -143,117 +114,12 @@ class QueueRepository(
|
|||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
QueueKind.Songs, QueueKind.Song -> {
|
QueueKind.Song -> {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun songsQuery(refId: String): QueueSongQuery? {
|
|
||||||
val parts = refId.split("|")
|
|
||||||
if (parts.size != 3) return null
|
|
||||||
val sort = SongSort.entries.firstOrNull { it.name == parts[0] } ?: return null
|
|
||||||
val starred = StarredFilter.entries.getOrElse(parts[2].toIntOrNull() ?: 0) { StarredFilter.Any }
|
|
||||||
return QueueSongQuery(sort, parts[1] == "1", starred)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun songIdsFor(entry: QueueEntry): List<String> {
|
|
||||||
val query = songsQuery(entry.refId) ?: return dao.songIds(entry.sourceId)
|
|
||||||
val library = db.libraryDao()
|
|
||||||
val starred = query.starred.ordinal
|
|
||||||
return when (query.sort) {
|
|
||||||
SongSort.Album -> {
|
|
||||||
if (query.descending) {
|
|
||||||
library.songIdsByAlbumReversed(entry.sourceId, starred)
|
|
||||||
} else {
|
|
||||||
library.songIdsByAlbum(entry.sourceId, starred)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SongSort.Title -> {
|
|
||||||
if (query.descending) {
|
|
||||||
library.songIdsByTitleReversed(entry.sourceId, starred)
|
|
||||||
} else {
|
|
||||||
library.songIdsByTitle(entry.sourceId, starred)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SongSort.Artist -> {
|
|
||||||
if (query.descending) {
|
|
||||||
library.songIdsByArtistReversed(entry.sourceId, starred)
|
|
||||||
} else {
|
|
||||||
library.songIdsByArtist(entry.sourceId, starred)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SongSort.Starred -> {
|
|
||||||
if (query.descending) {
|
|
||||||
library.songIdsByStarredReversed(entry.sourceId, starred)
|
|
||||||
} else {
|
|
||||||
library.songIdsByStarred(entry.sourceId, starred)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SongSort.Added -> {
|
|
||||||
if (query.descending) {
|
|
||||||
library.songIdsByAddedReversed(entry.sourceId, starred)
|
|
||||||
} else {
|
|
||||||
library.songIdsByAdded(entry.sourceId, starred)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun LibraryDao.songsPage(
|
|
||||||
sourceId: Long,
|
|
||||||
query: QueueSongQuery,
|
|
||||||
limit: Int,
|
|
||||||
offset: Long,
|
|
||||||
): List<SongListItem> {
|
|
||||||
val starred = query.starred.ordinal
|
|
||||||
return when (query.sort) {
|
|
||||||
SongSort.Album -> {
|
|
||||||
if (query.descending) {
|
|
||||||
songsPageByAlbumReversed(sourceId, starred, limit, offset)
|
|
||||||
} else {
|
|
||||||
songsPageByAlbum(sourceId, starred, limit, offset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SongSort.Title -> {
|
|
||||||
if (query.descending) {
|
|
||||||
songsPageByTitleReversed(sourceId, starred, limit, offset)
|
|
||||||
} else {
|
|
||||||
songsPageByTitle(sourceId, starred, limit, offset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SongSort.Artist -> {
|
|
||||||
if (query.descending) {
|
|
||||||
songsPageByArtistReversed(sourceId, starred, limit, offset)
|
|
||||||
} else {
|
|
||||||
songsPageByArtist(sourceId, starred, limit, offset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SongSort.Starred -> {
|
|
||||||
if (query.descending) {
|
|
||||||
songsPageByStarredReversed(sourceId, starred, limit, offset)
|
|
||||||
} else {
|
|
||||||
songsPageByStarred(sourceId, starred, limit, offset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SongSort.Added -> {
|
|
||||||
if (query.descending) {
|
|
||||||
songsPageByAddedReversed(sourceId, starred, limit, offset)
|
|
||||||
} else {
|
|
||||||
songsPageByAdded(sourceId, starred, limit, offset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun replace(
|
suspend fun replace(
|
||||||
entries: List<QueueEntry>,
|
entries: List<QueueEntry>,
|
||||||
shuffleOrder: LongArray? = null,
|
shuffleOrder: LongArray? = null,
|
||||||
@@ -476,7 +342,6 @@ class QueueRepository(
|
|||||||
when (entry.kind) {
|
when (entry.kind) {
|
||||||
QueueKind.Song -> listOf(entry.refId)
|
QueueKind.Song -> listOf(entry.refId)
|
||||||
QueueKind.Album -> dao.albumSongIds(entry.sourceId, entry.refId)
|
QueueKind.Album -> dao.albumSongIds(entry.sourceId, entry.refId)
|
||||||
QueueKind.Songs -> songIdsFor(entry)
|
|
||||||
QueueKind.Playlist -> dao.playlistSongIds(entry.sourceId, entry.refId)
|
QueueKind.Playlist -> dao.playlistSongIds(entry.sourceId, entry.refId)
|
||||||
}
|
}
|
||||||
val from = entry.offset.toInt().coerceIn(0, entryIds.size)
|
val from = entry.offset.toInt().coerceIn(0, entryIds.size)
|
||||||
@@ -502,12 +367,6 @@ class QueueRepository(
|
|||||||
dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
|
dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
QueueKind.Songs -> {
|
|
||||||
songsQuery(entry.refId)?.let { query ->
|
|
||||||
db.libraryDao().songsPage(entry.sourceId, query, limit, offset)
|
|
||||||
} ?: dao.songs(entry.sourceId, offset, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
QueueKind.Song -> {
|
QueueKind.Song -> {
|
||||||
dao.song(entry.sourceId, entry.refId, offset, limit)
|
dao.song(entry.sourceId, entry.refId, offset, limit)
|
||||||
}
|
}
|
||||||
@@ -527,11 +386,6 @@ class QueueRepository(
|
|||||||
QueueKind.Album -> {
|
QueueKind.Album -> {
|
||||||
dao.albumLength(sourceId, refId)
|
dao.albumLength(sourceId, refId)
|
||||||
}
|
}
|
||||||
|
|
||||||
QueueKind.Songs -> {
|
|
||||||
songsQuery(refId)?.let { db.libraryDao().songCountForFilter(sourceId, it.starred.ordinal) }
|
|
||||||
?: dao.songsLength(sourceId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val available = (total - offset).coerceAtLeast(0)
|
val available = (total - offset).coerceAtLeast(0)
|
||||||
return count?.coerceAtMost(available) ?: available
|
return count?.coerceAtMost(available) ?: available
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ fun appModule(
|
|||||||
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get()) }
|
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get()) }
|
||||||
single { PlaybackController(get(), get(), get()) }
|
single { PlaybackController(get(), get(), get()) }
|
||||||
viewModel { RootViewModel(get()) }
|
viewModel { RootViewModel(get()) }
|
||||||
viewModel { LibraryViewModel(get(), get(), get(), get(), get()) }
|
viewModel { LibraryViewModel(get(), get(), get(), get()) }
|
||||||
viewModel { SettingsViewModel(get(), get()) }
|
viewModel { SettingsViewModel(get(), get()) }
|
||||||
viewModel { AddSourceViewModel(get(), get()) }
|
viewModel { AddSourceViewModel(get(), get()) }
|
||||||
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) }
|
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) }
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import com.subtracks.data.model.QueueEntry
|
|||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.QueueSnapshot
|
import com.subtracks.data.repo.QueueSnapshot
|
||||||
import com.subtracks.data.repo.QueueSongQuery
|
|
||||||
import com.subtracks.data.repo.QueueWindowItem
|
import com.subtracks.data.repo.QueueWindowItem
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -190,23 +189,6 @@ class PlaybackController(
|
|||||||
playlistId: String,
|
playlistId: String,
|
||||||
) = play(listOf(queueRepository.playlistEntry(sourceId, playlistId)), 0, disableShuffle = true)
|
) = play(listOf(queueRepository.playlistEntry(sourceId, playlistId)), 0, disableShuffle = true)
|
||||||
|
|
||||||
fun playSongs(
|
|
||||||
sourceId: Long,
|
|
||||||
startPosition: Long,
|
|
||||||
) = play(listOf(queueRepository.songsEntry(sourceId)), startPosition)
|
|
||||||
|
|
||||||
fun playSong(
|
|
||||||
sourceId: Long,
|
|
||||||
songId: String,
|
|
||||||
query: QueueSongQuery,
|
|
||||||
) {
|
|
||||||
scope.launch {
|
|
||||||
val entry = queueRepository.songsEntry(sourceId, query)
|
|
||||||
val index = queueRepository.songsIndexOf(entry, songId) ?: return@launch
|
|
||||||
play(listOf(entry), index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun shuffleAlbum(
|
fun shuffleAlbum(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ 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.FilterAltOff
|
import androidx.compose.material.icons.rounded.FilterAltOff
|
||||||
import androidx.compose.material.icons.rounded.MusicNote
|
|
||||||
import androidx.compose.material.icons.rounded.Person
|
import androidx.compose.material.icons.rounded.Person
|
||||||
import androidx.compose.material.icons.rounded.Remove
|
import androidx.compose.material.icons.rounded.Remove
|
||||||
import androidx.compose.material.icons.rounded.Search
|
import androidx.compose.material.icons.rounded.Search
|
||||||
@@ -104,13 +103,11 @@ import com.subtracks.data.model.Album
|
|||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.SongListItem
|
|
||||||
import com.subtracks.data.prefs.AlbumSort
|
import com.subtracks.data.prefs.AlbumSort
|
||||||
import com.subtracks.data.prefs.ArtistSort
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
import com.subtracks.data.prefs.LibraryListTab
|
import com.subtracks.data.prefs.LibraryListTab
|
||||||
import com.subtracks.data.prefs.ListQuery
|
import com.subtracks.data.prefs.ListQuery
|
||||||
import com.subtracks.data.prefs.PlaylistSort
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
import com.subtracks.data.prefs.SongSort
|
|
||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
@@ -135,7 +132,6 @@ enum class LibraryTab(
|
|||||||
Albums("Albums", Icons.Rounded.Album),
|
Albums("Albums", Icons.Rounded.Album),
|
||||||
Artists("Artists", Icons.Rounded.Person),
|
Artists("Artists", Icons.Rounded.Person),
|
||||||
Playlists("Playlists", Icons.AutoMirrored.Rounded.PlaylistPlay),
|
Playlists("Playlists", Icons.AutoMirrored.Rounded.PlaylistPlay),
|
||||||
Songs("Songs", Icons.Rounded.MusicNote),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class SortOption(
|
data class SortOption(
|
||||||
@@ -173,16 +169,6 @@ fun sortOptionsFor(tab: LibraryTab): List<SortOption> =
|
|||||||
SortOption(PlaylistSort.Updated.name, "Updated", descendingByDefault = true),
|
SortOption(PlaylistSort.Updated.name, "Updated", descendingByDefault = true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
LibraryTab.Songs -> {
|
|
||||||
listOf(
|
|
||||||
SortOption(SongSort.Album.name, "Album"),
|
|
||||||
SortOption(SongSort.Title.name, "Title"),
|
|
||||||
SortOption(SongSort.Artist.name, "Artist"),
|
|
||||||
SortOption(SongSort.Starred.name, "Starred", descendingByDefault = true),
|
|
||||||
SortOption(SongSort.Added.name, "Added", descendingByDefault = true),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -203,7 +189,6 @@ fun LibraryRoute(
|
|||||||
var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
|
var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
|
||||||
var previousTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
|
var previousTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
|
||||||
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||||
val playingSongId by viewModel.playingSongId.collectAsStateWithLifecycle()
|
|
||||||
val listTab = selectedTab.listTab()
|
val listTab = selectedTab.listTab()
|
||||||
LaunchedEffect(selectedTab) {
|
LaunchedEffect(selectedTab) {
|
||||||
if (previousTab != selectedTab) {
|
if (previousTab != selectedTab) {
|
||||||
@@ -240,17 +225,14 @@ fun LibraryRoute(
|
|||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
albums = viewModel.albums,
|
albums = viewModel.albums,
|
||||||
artists = viewModel.artists,
|
artists = viewModel.artists,
|
||||||
songs = viewModel.songs,
|
|
||||||
playlists = viewModel.playlists,
|
playlists = viewModel.playlists,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onPlaylistClick = onPlaylistClick,
|
onPlaylistClick = onPlaylistClick,
|
||||||
onSongClick = viewModel::playSong,
|
|
||||||
onSync = viewModel::sync,
|
onSync = viewModel::sync,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onItemLongClick = { contextMenuHost?.show(it, itemActions) },
|
onItemLongClick = { contextMenuHost?.show(it, itemActions) },
|
||||||
playingSongId = playingSongId,
|
|
||||||
listQuery = listQuery,
|
listQuery = listQuery,
|
||||||
resetKeys = resetKeys,
|
resetKeys = resetKeys,
|
||||||
sortOptions = sortOptionsFor(selectedTab),
|
sortOptions = sortOptionsFor(selectedTab),
|
||||||
@@ -282,18 +264,15 @@ fun LibraryScreen(
|
|||||||
onTabSelected: (LibraryTab) -> Unit,
|
onTabSelected: (LibraryTab) -> Unit,
|
||||||
albums: Flow<PagingData<Album>>,
|
albums: Flow<PagingData<Album>>,
|
||||||
artists: Flow<PagingData<Artist>>,
|
artists: Flow<PagingData<Artist>>,
|
||||||
songs: Flow<PagingData<SongListItem>>,
|
|
||||||
playlists: Flow<PagingData<Playlist>>,
|
playlists: Flow<PagingData<Playlist>>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
onAlbumClick: (Album) -> Unit,
|
onAlbumClick: (Album) -> Unit,
|
||||||
onArtistClick: (Artist) -> Unit,
|
onArtistClick: (Artist) -> Unit,
|
||||||
onPlaylistClick: (Playlist) -> Unit,
|
onPlaylistClick: (Playlist) -> Unit,
|
||||||
onSongClick: (String) -> Unit,
|
|
||||||
onSync: () -> Unit,
|
onSync: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onItemLongClick: (MenuTarget) -> Unit = {},
|
onItemLongClick: (MenuTarget) -> Unit = {},
|
||||||
syncing: Boolean = false,
|
syncing: Boolean = false,
|
||||||
playingSongId: String? = null,
|
|
||||||
bottomInset: Dp = 0.dp,
|
bottomInset: Dp = 0.dp,
|
||||||
listQuery: ListQuery = ListQuery(""),
|
listQuery: ListQuery = ListQuery(""),
|
||||||
resetKeys: Map<LibraryTab, Any?> = emptyMap(),
|
resetKeys: Map<LibraryTab, Any?> = emptyMap(),
|
||||||
@@ -411,25 +390,6 @@ fun LibraryScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
LibraryTab.Songs -> {
|
|
||||||
SongsContent(
|
|
||||||
songs.collectAsLazyPagingItems(),
|
|
||||||
coverArt,
|
|
||||||
listBottomInset,
|
|
||||||
{
|
|
||||||
dismissSearch()
|
|
||||||
onSongClick(it)
|
|
||||||
},
|
|
||||||
onLongClick = onItemLongClick,
|
|
||||||
playingSongId,
|
|
||||||
filtered = filtersActive,
|
|
||||||
onClearFilters = onClearFilters,
|
|
||||||
resetKey = resetKey,
|
|
||||||
topInset = listTopInset,
|
|
||||||
onSync = onSync,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LibraryTab.Playlists -> {
|
LibraryTab.Playlists -> {
|
||||||
PlaylistsContent(
|
PlaylistsContent(
|
||||||
playlists.collectAsLazyPagingItems(),
|
playlists.collectAsLazyPagingItems(),
|
||||||
|
|||||||
@@ -8,21 +8,16 @@ import com.subtracks.data.model.Album
|
|||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.QueueKind
|
|
||||||
import com.subtracks.data.model.SongListItem
|
|
||||||
import com.subtracks.data.prefs.AlbumSort
|
import com.subtracks.data.prefs.AlbumSort
|
||||||
import com.subtracks.data.prefs.ArtistSort
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
import com.subtracks.data.prefs.LibraryListTab
|
import com.subtracks.data.prefs.LibraryListTab
|
||||||
import com.subtracks.data.prefs.ListQuery
|
import com.subtracks.data.prefs.ListQuery
|
||||||
import com.subtracks.data.prefs.PlaylistSort
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
import com.subtracks.data.prefs.SongSort
|
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.QueueSongQuery
|
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.sync.SyncManager
|
import com.subtracks.data.sync.SyncManager
|
||||||
import com.subtracks.data.sync.SyncStatus
|
import com.subtracks.data.sync.SyncStatus
|
||||||
import com.subtracks.playback.PlaybackController
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -40,7 +35,6 @@ class LibraryViewModel(
|
|||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val syncManager: SyncManager,
|
private val syncManager: SyncManager,
|
||||||
private val playbackController: PlaybackController,
|
|
||||||
private val userPreferences: UserPreferences,
|
private val userPreferences: UserPreferences,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val listQueries: Map<LibraryListTab, StateFlow<ListQuery>> =
|
private val listQueries: Map<LibraryListTab, StateFlow<ListQuery>> =
|
||||||
@@ -86,27 +80,11 @@ class LibraryViewModel(
|
|||||||
}
|
}
|
||||||
}.cachedIn(viewModelScope)
|
}.cachedIn(viewModelScope)
|
||||||
|
|
||||||
val songs: Flow<PagingData<SongListItem>> =
|
|
||||||
libraryRepository.activeSourceId
|
|
||||||
.filterNotNull()
|
|
||||||
.flatMapLatest { sourceId ->
|
|
||||||
combine(listQueries.getValue(LibraryListTab.Songs), searches.getValue(LibraryListTab.Songs)) { query, search ->
|
|
||||||
query to search
|
|
||||||
}.flatMapLatest { (query, search) ->
|
|
||||||
libraryRepository.songs(sourceId, query.songSort(), query.descending, query.starred, search)
|
|
||||||
}
|
|
||||||
}.cachedIn(viewModelScope)
|
|
||||||
|
|
||||||
val syncing: StateFlow<Boolean> =
|
val syncing: StateFlow<Boolean> =
|
||||||
syncManager.status
|
syncManager.status
|
||||||
.map { it == SyncStatus.Running }
|
.map { it == SyncStatus.Running }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
val playingSongId: StateFlow<String?> =
|
|
||||||
playbackController.state
|
|
||||||
.map { state -> state.item?.id?.takeIf { state.context?.kind == QueueKind.Songs } }
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
|
||||||
|
|
||||||
fun listQuery(tab: LibraryListTab): StateFlow<ListQuery> = listQueries.getValue(tab)
|
fun listQuery(tab: LibraryListTab): StateFlow<ListQuery> = listQueries.getValue(tab)
|
||||||
|
|
||||||
fun setListQuery(
|
fun setListQuery(
|
||||||
@@ -130,14 +108,6 @@ class LibraryViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
fun playSong(songId: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val sourceId = sourceRepository.activeSourceIdOnce() ?: return@launch
|
|
||||||
val query = listQueries.getValue(LibraryListTab.Songs).value
|
|
||||||
playbackController.playSong(sourceId, songId, QueueSongQuery(query.songSort(), query.descending, query.starred))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun playAlbum(albumId: String) {
|
fun playAlbum(albumId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
sourceRepository.activeSourceIdOnce()?.let { playbackController.playAlbum(it, albumId, 0) }
|
sourceRepository.activeSourceIdOnce()?.let { playbackController.playAlbum(it, albumId, 0) }
|
||||||
@@ -170,5 +140,3 @@ private fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { i
|
|||||||
private fun ListQuery.artistSort(): ArtistSort = ArtistSort.entries.firstOrNull { it.name == sort } ?: ArtistSort.Name
|
private fun ListQuery.artistSort(): ArtistSort = ArtistSort.entries.firstOrNull { it.name == sort } ?: ArtistSort.Name
|
||||||
|
|
||||||
private fun ListQuery.playlistSort(): PlaylistSort = PlaylistSort.entries.firstOrNull { it.name == sort } ?: PlaylistSort.Name
|
private fun ListQuery.playlistSort(): PlaylistSort = PlaylistSort.entries.firstOrNull { it.name == sort } ?: PlaylistSort.Name
|
||||||
|
|
||||||
private fun ListQuery.songSort(): SongSort = SongSort.entries.firstOrNull { it.name == sort } ?: SongSort.Album
|
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
package com.subtracks.ui.library
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.PlayArrow
|
import androidx.compose.material.icons.rounded.PlayArrow
|
||||||
@@ -27,80 +20,10 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.paging.LoadState
|
|
||||||
import androidx.paging.compose.LazyPagingItems
|
|
||||||
import androidx.paging.compose.itemKey
|
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.SongListItem
|
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.EmptyState
|
|
||||||
import com.subtracks.ui.components.FilteredEmptyState
|
|
||||||
import com.subtracks.ui.components.LoadingState
|
|
||||||
import com.subtracks.ui.components.MenuTarget
|
|
||||||
import com.subtracks.ui.components.ResetScrollOnChange
|
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SongsContent(
|
|
||||||
items: LazyPagingItems<SongListItem>,
|
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
|
||||||
bottomInset: Dp,
|
|
||||||
onSongClick: (String) -> Unit,
|
|
||||||
onLongClick: (MenuTarget) -> Unit = {},
|
|
||||||
playingSongId: String? = null,
|
|
||||||
filtered: Boolean = false,
|
|
||||||
onClearFilters: () -> Unit = {},
|
|
||||||
resetKey: Any? = null,
|
|
||||||
topInset: Dp = 0.dp,
|
|
||||||
onSync: () -> Unit = {},
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
when {
|
|
||||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
|
||||||
LoadingState(modifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
items.itemCount == 0 -> {
|
|
||||||
if (filtered) {
|
|
||||||
FilteredEmptyState(onClearFilters, modifier)
|
|
||||||
} else {
|
|
||||||
EmptyState("No songs yet.", modifier, actionLabel = "Sync", onAction = onSync)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
|
||||||
val fill = rememberViewportFill(listState)
|
|
||||||
LazyColumn(
|
|
||||||
state = listState,
|
|
||||||
modifier = modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(top = topInset, bottom = bottomInset),
|
|
||||||
) {
|
|
||||||
items(count = items.itemCount, key = items.itemKey { it.song.id }) { index ->
|
|
||||||
val item = items[index]
|
|
||||||
if (item != null) {
|
|
||||||
SongRow(
|
|
||||||
song = item.song,
|
|
||||||
coverArtId = item.coverArt,
|
|
||||||
coverArt = coverArt,
|
|
||||||
isPlaying = item.song.id == playingSongId,
|
|
||||||
modifier =
|
|
||||||
Modifier.combinedClickable(
|
|
||||||
onClick = { onSongClick(item.song.id) },
|
|
||||||
onLongClick = { onLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true))) },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item { Spacer(Modifier.height(fill)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SongRow(
|
fun SongRow(
|
||||||
|
|||||||
@@ -112,12 +112,7 @@ fun NowPlayingRoute(
|
|||||||
val art = controller.coverArt(state.item)
|
val art = controller.coverArt(state.item)
|
||||||
val thumbnail = controller.coverArt(state.item, thumbnail = true)
|
val thumbnail = controller.coverArt(state.item, thumbnail = true)
|
||||||
val queueContext = state.context
|
val queueContext = state.context
|
||||||
val fallbackTitle =
|
val fallbackTitle = state.item?.album?.takeIf { it.isNotBlank() } ?: "Library"
|
||||||
if (queueContext?.kind == QueueKind.Songs) {
|
|
||||||
"Library"
|
|
||||||
} else {
|
|
||||||
state.item?.album?.takeIf { it.isNotBlank() } ?: "Library"
|
|
||||||
}
|
|
||||||
val sourceTitle = remember(queueContext) { mutableStateOf(fallbackTitle) }
|
val sourceTitle = remember(queueContext) { mutableStateOf(fallbackTitle) }
|
||||||
LaunchedEffect(queueContext) {
|
LaunchedEffect(queueContext) {
|
||||||
sourceTitle.value = controller.sourceTitle(queueContext)?.takeIf { it.isNotBlank() } ?: fallbackTitle
|
sourceTitle.value = controller.sourceTitle(queueContext)?.takeIf { it.isNotBlank() } ?: fallbackTitle
|
||||||
@@ -443,7 +438,6 @@ private fun QueueKind.label(): String =
|
|||||||
QueueKind.Album -> "album"
|
QueueKind.Album -> "album"
|
||||||
QueueKind.Playlist -> "playlist"
|
QueueKind.Playlist -> "playlist"
|
||||||
QueueKind.Song -> "song"
|
QueueKind.Song -> "song"
|
||||||
QueueKind.Songs -> "songs"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatTime(milliseconds: Long): String {
|
private fun formatTime(milliseconds: Long): String {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
|||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -63,29 +64,6 @@ class LibraryDaoTest {
|
|||||||
assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, starredFilter = 1, search = "").page().map { it.name })
|
assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, starredFilter = 1, search = "").page().map { it.name })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun songSortsByTitleAndStarredFilter() =
|
|
||||||
runTest {
|
|
||||||
val sourceId = source()
|
|
||||||
val dao = db.libraryDao()
|
|
||||||
dao.upsertSongs(
|
|
||||||
listOf(
|
|
||||||
song(sourceId, "s1", "Zebra", starred = null),
|
|
||||||
song(sourceId, "s2", "Apple", starred = 7),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
listOf("Apple", "Zebra"),
|
|
||||||
dao.songsByTitle(sourceId, starredFilter = 0, search = "").page().map { it.song.title },
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
listOf("Zebra", "Apple"),
|
|
||||||
dao.songsByTitleReversed(sourceId, starredFilter = 0, search = "").page().map { it.song.title },
|
|
||||||
)
|
|
||||||
assertEquals(listOf("Apple"), dao.songsByTitle(sourceId, starredFilter = 1, search = "").page().map { it.song.title })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun albumAddedAndStarredSortsAndSearch() =
|
fun albumAddedAndStarredSortsAndSearch() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -112,24 +90,21 @@ class LibraryDaoTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun songAddedSortOrdersByCreated() =
|
fun albumSongsAreOrderedByDiscAndTrack() =
|
||||||
runTest {
|
runTest {
|
||||||
val sourceId = source()
|
val sourceId = source()
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
dao.upsertSongs(
|
dao.upsertSongs(
|
||||||
listOf(
|
listOf(
|
||||||
song(sourceId, "s1", "Zebra", starred = null, created = 100),
|
song(sourceId, "s2", "Second", starred = null, track = 2),
|
||||||
song(sourceId, "s2", "Apple", starred = null, created = 200),
|
song(sourceId, "s3", "Third", starred = null, track = 1, disc = 2),
|
||||||
|
song(sourceId, "s1", "First", starred = null, track = 1),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Apple", "Zebra"),
|
listOf("s1", "s2", "s3"),
|
||||||
dao.songsByAdded(sourceId, starredFilter = 0, search = "").page().map { it.song.title },
|
dao.songsByAlbum(sourceId, "al-1").first().map { it.id },
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
listOf("Zebra", "Apple"),
|
|
||||||
dao.songsByAddedReversed(sourceId, starredFilter = 0, search = "").page().map { it.song.title },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +145,8 @@ class LibraryDaoTest {
|
|||||||
title: String,
|
title: String,
|
||||||
starred: Long?,
|
starred: Long?,
|
||||||
created: Long = 0,
|
created: Long = 0,
|
||||||
|
track: Long = 1,
|
||||||
|
disc: Long = 1,
|
||||||
) = Song(
|
) = Song(
|
||||||
sourceId = sourceId,
|
sourceId = sourceId,
|
||||||
id = id,
|
id = id,
|
||||||
@@ -179,8 +156,8 @@ class LibraryDaoTest {
|
|||||||
album = "Album",
|
album = "Album",
|
||||||
artist = "Artist",
|
artist = "Artist",
|
||||||
duration = 200,
|
duration = 200,
|
||||||
track = 1,
|
track = track,
|
||||||
disc = 1,
|
disc = disc,
|
||||||
starred = starred,
|
starred = starred,
|
||||||
genre = null,
|
genre = null,
|
||||||
created = created,
|
created = created,
|
||||||
|
|||||||
@@ -21,15 +21,19 @@ class UserPreferencesTest {
|
|||||||
val prefs = UserPreferences(store)
|
val prefs = UserPreferences(store)
|
||||||
|
|
||||||
assertEquals(ListQuery("Name"), prefs.listQuery(LibraryListTab.Albums).first())
|
assertEquals(ListQuery("Name"), prefs.listQuery(LibraryListTab.Albums).first())
|
||||||
assertEquals(ListQuery("Album"), prefs.listQuery(LibraryListTab.Songs).first())
|
assertEquals(ListQuery("Name"), prefs.listQuery(LibraryListTab.Artists).first())
|
||||||
|
|
||||||
prefs.setListQuery(LibraryListTab.Albums, ListQuery("Added", descending = true, starred = StarredFilter.Starred))
|
prefs.setListQuery(LibraryListTab.Albums, ListQuery("Added", descending = true, starred = StarredFilter.Starred))
|
||||||
|
prefs.setListQuery(LibraryListTab.Artists, ListQuery("AlbumCount", descending = true))
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
ListQuery("Added", descending = true, starred = StarredFilter.Starred),
|
ListQuery("Added", descending = true, starred = StarredFilter.Starred),
|
||||||
prefs.listQuery(LibraryListTab.Albums).first(),
|
prefs.listQuery(LibraryListTab.Albums).first(),
|
||||||
)
|
)
|
||||||
assertEquals(ListQuery("Album"), prefs.listQuery(LibraryListTab.Songs).first())
|
assertEquals(
|
||||||
|
ListQuery("AlbumCount", descending = true),
|
||||||
|
prefs.listQuery(LibraryListTab.Artists).first(),
|
||||||
|
)
|
||||||
|
|
||||||
file.delete()
|
file.delete()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ 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
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.prefs.SongSort
|
|
||||||
import com.subtracks.data.prefs.StarredFilter
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -75,19 +73,6 @@ class QueueRepositoryTest {
|
|||||||
assertEquals(listOf("s2", "s3"), resolveAll(snapshot))
|
assertEquals(listOf("s2", "s3"), resolveAll(snapshot))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun theSongsEntryResolvesTheWholeLibraryInListOrder() =
|
|
||||||
runTest {
|
|
||||||
seedLibrary()
|
|
||||||
db.libraryDao().upsertAlbums(listOf(album("al0", "Zeroth Album", "Aardvark")))
|
|
||||||
db.libraryDao().upsertSongs(listOf(song("s0", "al0", track = 1, album = "Zeroth Album")))
|
|
||||||
|
|
||||||
val snapshot = repository.snapshotAfter(listOf(repository.songsEntry(1)))
|
|
||||||
|
|
||||||
assertEquals(6, snapshot.size)
|
|
||||||
assertEquals(listOf("s0", "s1", "s2", "s3", "s4", "s5"), resolveAll(snapshot))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun windowSpansEntryBoundaries() =
|
fun windowSpansEntryBoundaries() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -314,39 +299,6 @@ class QueueRepositoryTest {
|
|||||||
assertEquals(listOf("s2", "s3", "s1"), resolveAll(repository.snapshot()))
|
assertEquals(listOf("s2", "s3", "s1"), resolveAll(repository.snapshot()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun theSongsEntryFollowsItsSortAndStarredFilter() =
|
|
||||||
runTest {
|
|
||||||
seedLibrary()
|
|
||||||
db.libraryDao().upsertSongs(
|
|
||||||
listOf(
|
|
||||||
song("s1", "al1", track = 1, album = "First Album", title = "Charlie", starred = 5),
|
|
||||||
song("s2", "al1", track = 2, album = "First Album", title = "Alpha"),
|
|
||||||
song("s3", "al1", track = 3, album = "First Album", title = "Bravo", starred = 7),
|
|
||||||
song("s4", "al2", track = 1, album = "Second Album", title = "Echo"),
|
|
||||||
song("s5", "al2", track = 2, album = "Second Album", title = "Delta", starred = 3),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
val byTitle =
|
|
||||||
repository.snapshotAfter(
|
|
||||||
listOf(repository.songsEntry(1, QueueSongQuery(SongSort.Title, descending = false, StarredFilter.Any))),
|
|
||||||
)
|
|
||||||
assertEquals(listOf("s2", "s3", "s1", "s5", "s4"), resolveAll(byTitle))
|
|
||||||
|
|
||||||
val starredOnly =
|
|
||||||
repository.snapshotAfter(
|
|
||||||
listOf(repository.songsEntry(1, QueueSongQuery(SongSort.Starred, descending = false, StarredFilter.Starred))),
|
|
||||||
)
|
|
||||||
assertEquals(listOf("s3", "s1", "s5"), resolveAll(starredOnly))
|
|
||||||
|
|
||||||
val unstarred =
|
|
||||||
repository.snapshotAfter(
|
|
||||||
listOf(repository.songsEntry(1, QueueSongQuery(SongSort.Album, descending = false, StarredFilter.NotStarred))),
|
|
||||||
)
|
|
||||||
assertEquals(listOf("s2", "s4"), resolveAll(unstarred))
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun QueueRepository.snapshotAfter(entries: List<QueueEntry>): QueueSnapshot {
|
private suspend fun QueueRepository.snapshotAfter(entries: List<QueueEntry>): QueueSnapshot {
|
||||||
replace(entries)
|
replace(entries)
|
||||||
return snapshot()
|
return snapshot()
|
||||||
|
|||||||
@@ -69,14 +69,7 @@ class SyncServiceTest {
|
|||||||
.allRows()
|
.allRows()
|
||||||
.size,
|
.size,
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(2, db.libraryDao().songIds(1).size)
|
||||||
2,
|
|
||||||
db
|
|
||||||
.libraryDao()
|
|
||||||
.songs(1, 0, "")
|
|
||||||
.allRows()
|
|
||||||
.size,
|
|
||||||
)
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
1,
|
1,
|
||||||
db
|
db
|
||||||
@@ -99,14 +92,7 @@ class SyncServiceTest {
|
|||||||
.allRows()
|
.allRows()
|
||||||
.map { it.id },
|
.map { it.id },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(listOf("s1"), db.libraryDao().songIds(1))
|
||||||
listOf("s1"),
|
|
||||||
db
|
|
||||||
.libraryDao()
|
|
||||||
.songs(1, 0, "")
|
|
||||||
.allRows()
|
|
||||||
.map { it.song.id },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -106,19 +106,6 @@ class PlaybackControllerTest {
|
|||||||
assertEquals("cover-al1", runBlocking { controller.upcomingItem()?.coverArtId })
|
assertEquals("cover-al1", runBlocking { controller.upcomingItem()?.coverArtId })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun playingTheSongsListQueuesTheWholeLibrary() {
|
|
||||||
seedAlbum(60, sourceId = 1)
|
|
||||||
|
|
||||||
controller.playSongs(1, 30)
|
|
||||||
await { handle.operations.contains("play") }
|
|
||||||
|
|
||||||
assertEquals(51, handle.itemCount)
|
|
||||||
assertEquals(QueueContext(QueueKind.Songs, 1, ""), controller.state.value.context)
|
|
||||||
assertTrue(controller.state.value.hasNext)
|
|
||||||
assertTrue(controller.state.value.hasPrevious)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun advancingShiftsTheWindowAndKeepsItBounded() {
|
fun advancingShiftsTheWindowAndKeepsItBounded() {
|
||||||
seedAlbum(100, sourceId = 1)
|
seedAlbum(100, sourceId = 1)
|
||||||
|
|||||||
@@ -107,13 +107,11 @@ class AppScreenshotTest {
|
|||||||
onTabSelected = {},
|
onTabSelected = {},
|
||||||
albums = remember { flowOf(PagingData.from(albums)) },
|
albums = remember { flowOf(PagingData.from(albums)) },
|
||||||
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||||
songs = remember { flowOf(PagingData.empty<SongListItem>()) },
|
|
||||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||||
onAlbumClick = {},
|
onAlbumClick = {},
|
||||||
onArtistClick = {},
|
onArtistClick = {},
|
||||||
onPlaylistClick = {},
|
onPlaylistClick = {},
|
||||||
onSongClick = {},
|
|
||||||
onSync = {},
|
onSync = {},
|
||||||
onOpenSettings = {},
|
onOpenSettings = {},
|
||||||
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
|
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
|
||||||
@@ -133,13 +131,6 @@ class AppScreenshotTest {
|
|||||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/library_artists.png")
|
composeRule.onRoot().captureRoboImage("src/test/screenshots/library_artists.png")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun librarySongs() {
|
|
||||||
setLibraryContent(LibraryTab.Songs)
|
|
||||||
awaitText("Everything In Its Right Place")
|
|
||||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/library_songs.png")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun libraryPlaylists() {
|
fun libraryPlaylists() {
|
||||||
setLibraryContent(LibraryTab.Playlists)
|
setLibraryContent(LibraryTab.Playlists)
|
||||||
@@ -166,13 +157,11 @@ class AppScreenshotTest {
|
|||||||
onTabSelected = {},
|
onTabSelected = {},
|
||||||
albums = remember { flowOf(PagingData.from(Fixtures.albums)) },
|
albums = remember { flowOf(PagingData.from(Fixtures.albums)) },
|
||||||
artists = remember { flowOf(PagingData.from(Fixtures.artists)) },
|
artists = remember { flowOf(PagingData.from(Fixtures.artists)) },
|
||||||
songs = remember { flowOf(PagingData.from(Fixtures.songItems)) },
|
|
||||||
playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) },
|
playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) },
|
||||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||||
onAlbumClick = {},
|
onAlbumClick = {},
|
||||||
onArtistClick = {},
|
onArtistClick = {},
|
||||||
onPlaylistClick = {},
|
onPlaylistClick = {},
|
||||||
onSongClick = {},
|
|
||||||
onSync = {},
|
onSync = {},
|
||||||
onOpenSettings = {},
|
onOpenSettings = {},
|
||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
@@ -473,13 +462,11 @@ class AppScreenshotTest {
|
|||||||
onTabSelected = {},
|
onTabSelected = {},
|
||||||
albums = remember { flowOf(PagingData.from(Fixtures.albums)) },
|
albums = remember { flowOf(PagingData.from(Fixtures.albums)) },
|
||||||
artists = remember { flowOf(PagingData.from(Fixtures.artists)) },
|
artists = remember { flowOf(PagingData.from(Fixtures.artists)) },
|
||||||
songs = remember { flowOf(PagingData.from(Fixtures.songItems)) },
|
|
||||||
playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) },
|
playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) },
|
||||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||||
onAlbumClick = {},
|
onAlbumClick = {},
|
||||||
onArtistClick = {},
|
onArtistClick = {},
|
||||||
onPlaylistClick = {},
|
onPlaylistClick = {},
|
||||||
onSongClick = {},
|
|
||||||
onSync = {},
|
onSync = {},
|
||||||
onOpenSettings = {},
|
onOpenSettings = {},
|
||||||
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
|
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import com.github.takahirom.roborazzi.captureRoboImage
|
|||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.SongListItem
|
|
||||||
import com.subtracks.data.prefs.ListQuery
|
import com.subtracks.data.prefs.ListQuery
|
||||||
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
||||||
import com.subtracks.ui.library.LibraryScreen
|
import com.subtracks.ui.library.LibraryScreen
|
||||||
@@ -44,13 +43,11 @@ class LibraryOptionsScreenshotTest {
|
|||||||
onTabSelected = {},
|
onTabSelected = {},
|
||||||
albums = remember { flowOf(PagingData.from(albums())) },
|
albums = remember { flowOf(PagingData.from(albums())) },
|
||||||
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||||
songs = remember { flowOf(PagingData.empty<SongListItem>()) },
|
|
||||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||||
coverArt = { _, _ -> null },
|
coverArt = { _, _ -> null },
|
||||||
onAlbumClick = {},
|
onAlbumClick = {},
|
||||||
onArtistClick = {},
|
onArtistClick = {},
|
||||||
onPlaylistClick = {},
|
onPlaylistClick = {},
|
||||||
onSongClick = {},
|
|
||||||
onSync = {},
|
onSync = {},
|
||||||
onOpenSettings = {},
|
onOpenSettings = {},
|
||||||
listQuery = ListQuery("Name"),
|
listQuery = ListQuery("Name"),
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
|||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.SongListItem
|
|
||||||
import com.subtracks.data.prefs.ListQuery
|
import com.subtracks.data.prefs.ListQuery
|
||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
||||||
@@ -50,13 +49,11 @@ class LibraryOptionsTest {
|
|||||||
onTabSelected = {},
|
onTabSelected = {},
|
||||||
albums = remember { flowOf(PagingData.from(albums())) },
|
albums = remember { flowOf(PagingData.from(albums())) },
|
||||||
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||||
songs = remember { flowOf(PagingData.empty<SongListItem>()) },
|
|
||||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||||
coverArt = { _, _ -> null },
|
coverArt = { _, _ -> null },
|
||||||
onAlbumClick = {},
|
onAlbumClick = {},
|
||||||
onArtistClick = {},
|
onArtistClick = {},
|
||||||
onPlaylistClick = {},
|
onPlaylistClick = {},
|
||||||
onSongClick = {},
|
|
||||||
onSync = {},
|
onSync = {},
|
||||||
onOpenSettings = {},
|
onOpenSettings = {},
|
||||||
listQuery = query,
|
listQuery = query,
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
|||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.SongListItem
|
|
||||||
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
||||||
import com.subtracks.ui.library.LibraryScreen
|
import com.subtracks.ui.library.LibraryScreen
|
||||||
import com.subtracks.ui.library.LibraryTab
|
import com.subtracks.ui.library.LibraryTab
|
||||||
@@ -60,13 +59,11 @@ class LibraryScreenTest {
|
|||||||
onTabSelected = {},
|
onTabSelected = {},
|
||||||
albums = remember { flowOf(PagingData.from(albums())) },
|
albums = remember { flowOf(PagingData.from(albums())) },
|
||||||
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||||
songs = remember { flowOf(PagingData.empty<SongListItem>()) },
|
|
||||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||||
coverArt = { _, _ -> null },
|
coverArt = { _, _ -> null },
|
||||||
onAlbumClick = {},
|
onAlbumClick = {},
|
||||||
onArtistClick = {},
|
onArtistClick = {},
|
||||||
onPlaylistClick = {},
|
onPlaylistClick = {},
|
||||||
onSongClick = {},
|
|
||||||
onSync = { synced = true },
|
onSync = { synced = true },
|
||||||
onOpenSettings = { settingsOpened = true },
|
onOpenSettings = { settingsOpened = true },
|
||||||
)
|
)
|
||||||
@@ -95,13 +92,11 @@ class LibraryScreenTest {
|
|||||||
onTabSelected = {},
|
onTabSelected = {},
|
||||||
albums = remember { flowOf(PagingData.empty<Album>(settledLoadStates)) },
|
albums = remember { flowOf(PagingData.empty<Album>(settledLoadStates)) },
|
||||||
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||||
songs = remember { flowOf(PagingData.empty<SongListItem>()) },
|
|
||||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||||
coverArt = { _, _ -> null },
|
coverArt = { _, _ -> null },
|
||||||
onAlbumClick = {},
|
onAlbumClick = {},
|
||||||
onArtistClick = {},
|
onArtistClick = {},
|
||||||
onPlaylistClick = {},
|
onPlaylistClick = {},
|
||||||
onSongClick = {},
|
|
||||||
onSync = { synced = true },
|
onSync = { synced = true },
|
||||||
onOpenSettings = {},
|
onOpenSettings = {},
|
||||||
)
|
)
|
||||||
@@ -129,11 +124,6 @@ class LibraryScreenTest {
|
|||||||
collected += "artists"
|
collected += "artists"
|
||||||
emit(PagingData.empty())
|
emit(PagingData.empty())
|
||||||
}
|
}
|
||||||
val songs =
|
|
||||||
flow<PagingData<SongListItem>> {
|
|
||||||
collected += "songs"
|
|
||||||
emit(PagingData.empty())
|
|
||||||
}
|
|
||||||
val playlists =
|
val playlists =
|
||||||
flow<PagingData<Playlist>> {
|
flow<PagingData<Playlist>> {
|
||||||
collected += "playlists"
|
collected += "playlists"
|
||||||
@@ -146,13 +136,11 @@ class LibraryScreenTest {
|
|||||||
onTabSelected = {},
|
onTabSelected = {},
|
||||||
albums = albums,
|
albums = albums,
|
||||||
artists = artists,
|
artists = artists,
|
||||||
songs = songs,
|
|
||||||
playlists = playlists,
|
playlists = playlists,
|
||||||
coverArt = { _, _ -> null },
|
coverArt = { _, _ -> null },
|
||||||
onAlbumClick = {},
|
onAlbumClick = {},
|
||||||
onArtistClick = {},
|
onArtistClick = {},
|
||||||
onPlaylistClick = {},
|
onPlaylistClick = {},
|
||||||
onSongClick = {},
|
|
||||||
onSync = {},
|
onSync = {},
|
||||||
onOpenSettings = {},
|
onOpenSettings = {},
|
||||||
)
|
)
|
||||||
@@ -178,11 +166,6 @@ class LibraryScreenTest {
|
|||||||
collected += "artists"
|
collected += "artists"
|
||||||
emit(PagingData.from(artists()))
|
emit(PagingData.from(artists()))
|
||||||
}
|
}
|
||||||
val songs =
|
|
||||||
flow<PagingData<SongListItem>> {
|
|
||||||
collected += "songs"
|
|
||||||
emit(PagingData.empty())
|
|
||||||
}
|
|
||||||
val playlists =
|
val playlists =
|
||||||
flow<PagingData<Playlist>> {
|
flow<PagingData<Playlist>> {
|
||||||
collected += "playlists"
|
collected += "playlists"
|
||||||
@@ -196,13 +179,11 @@ class LibraryScreenTest {
|
|||||||
onTabSelected = { selectedTab.value = it },
|
onTabSelected = { selectedTab.value = it },
|
||||||
albums = albums,
|
albums = albums,
|
||||||
artists = artists,
|
artists = artists,
|
||||||
songs = songs,
|
|
||||||
playlists = playlists,
|
playlists = playlists,
|
||||||
coverArt = { _, _ -> null },
|
coverArt = { _, _ -> null },
|
||||||
onAlbumClick = {},
|
onAlbumClick = {},
|
||||||
onArtistClick = {},
|
onArtistClick = {},
|
||||||
onPlaylistClick = {},
|
onPlaylistClick = {},
|
||||||
onSongClick = {},
|
|
||||||
onSync = {},
|
onSync = {},
|
||||||
onOpenSettings = {},
|
onOpenSettings = {},
|
||||||
)
|
)
|
||||||
@@ -223,8 +204,7 @@ class LibraryScreenTest {
|
|||||||
|
|
||||||
assertTrue("returning to a tab should render its content", collected.contains("albums"))
|
assertTrue("returning to a tab should render its content", collected.contains("albums"))
|
||||||
assertTrue("the tab that was shown should collect its flow", collected.contains("artists"))
|
assertTrue("the tab that was shown should collect its flow", collected.contains("artists"))
|
||||||
assertFalse("inactive tabs should not collect their flows", collected.contains("songs"))
|
assertFalse("inactive tabs should not collect their flows", collected.contains("playlists"))
|
||||||
assertFalse(collected.contains("playlists"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -247,7 +227,7 @@ class LibraryScreenTest {
|
|||||||
composeRule.runOnIdle { pagerState.requestScrollToPage(1, step * 0.05f) }
|
composeRule.runOnIdle { pagerState.requestScrollToPage(1, step * 0.05f) }
|
||||||
composeRule.waitForIdle()
|
composeRule.waitForIdle()
|
||||||
composeRule
|
composeRule
|
||||||
.onNodeWithContentDescription(LibraryTab.Songs.label)
|
.onNodeWithContentDescription(LibraryTab.Playlists.label)
|
||||||
.fetchSemanticsNode()
|
.fetchSemanticsNode()
|
||||||
.boundsInRoot.left
|
.boundsInRoot.left
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import com.github.takahirom.roborazzi.captureRoboImage
|
|||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.SongListItem
|
|
||||||
import com.subtracks.data.prefs.ListQuery
|
import com.subtracks.data.prefs.ListQuery
|
||||||
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
||||||
import com.subtracks.ui.library.LibraryScreen
|
import com.subtracks.ui.library.LibraryScreen
|
||||||
@@ -45,13 +44,11 @@ class LibrarySearchScreenshotTest {
|
|||||||
onTabSelected = {},
|
onTabSelected = {},
|
||||||
albums = remember { flowOf(PagingData.from(albums())) },
|
albums = remember { flowOf(PagingData.from(albums())) },
|
||||||
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||||
songs = remember { flowOf(PagingData.empty<SongListItem>()) },
|
|
||||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||||
coverArt = { _, _ -> null },
|
coverArt = { _, _ -> null },
|
||||||
onAlbumClick = {},
|
onAlbumClick = {},
|
||||||
onArtistClick = {},
|
onArtistClick = {},
|
||||||
onPlaylistClick = {},
|
onPlaylistClick = {},
|
||||||
onSongClick = {},
|
|
||||||
onSync = {},
|
onSync = {},
|
||||||
onOpenSettings = {},
|
onOpenSettings = {},
|
||||||
listQuery = ListQuery("Name"),
|
listQuery = ListQuery("Name"),
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class QueueViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun openingLoadsAWindowAtTheCursorAndExtendingGrowsIt() {
|
fun openingLoadsAWindowAtTheCursorAndExtendingGrowsIt() {
|
||||||
runBlocking { seedSongs(200) }
|
runBlocking { seedSongs(200) }
|
||||||
controller.playSongs(1, 100)
|
controller.playAlbum(1, "al1", 100)
|
||||||
await { controller.state.value.position == 100L }
|
await { controller.state.value.position == 100L }
|
||||||
|
|
||||||
viewModel.open()
|
viewModel.open()
|
||||||
@@ -87,7 +87,7 @@ class QueueViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun openingAtTheEndLoadsAWindowAndExtendsOlder() {
|
fun openingAtTheEndLoadsAWindowAndExtendsOlder() {
|
||||||
runBlocking { seedSongs(200) }
|
runBlocking { seedSongs(200) }
|
||||||
controller.playSongs(1, 199)
|
controller.playAlbum(1, "al1", 199)
|
||||||
await { controller.state.value.position == 199L }
|
await { controller.state.value.position == 199L }
|
||||||
|
|
||||||
viewModel.open()
|
viewModel.open()
|
||||||
@@ -111,7 +111,7 @@ class QueueViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun reopeningReusesRowIdentitySoComposeDoesNotCrossFadeTwoLists() {
|
fun reopeningReusesRowIdentitySoComposeDoesNotCrossFadeTwoLists() {
|
||||||
runBlocking { seedSongs(200) }
|
runBlocking { seedSongs(200) }
|
||||||
controller.playSongs(1, 100)
|
controller.playAlbum(1, "al1", 100)
|
||||||
await { controller.state.value.position == 100L }
|
await { controller.state.value.position == 100L }
|
||||||
|
|
||||||
viewModel.open()
|
viewModel.open()
|
||||||
@@ -128,7 +128,7 @@ class QueueViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun reorderingMovesTheRowLocally() {
|
fun reorderingMovesTheRowLocally() {
|
||||||
runBlocking { seedSongs(200) }
|
runBlocking { seedSongs(200) }
|
||||||
controller.playSongs(1, 100)
|
controller.playAlbum(1, "al1", 100)
|
||||||
await { controller.state.value.position == 100L }
|
await { controller.state.value.position == 100L }
|
||||||
|
|
||||||
viewModel.open()
|
viewModel.open()
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ Compose UI -> ViewModel -> Repository -> MusicSource (remote, Subsonic)
|
|||||||
|
|
||||||
## UI shell
|
## UI shell
|
||||||
|
|
||||||
The library is one `LibraryScreen` with a pinned top bar: the current section as the title, a custom row of icon buttons (Albums, Artists, Songs, Playlists) on the left, and Sync and Settings icon actions on the right. The selected section is drawn as a filled box in the content colour with the icon inverted, like the Flutter app — this is a hand-rolled row, not Material `TabRow` (no underline indicator). Icons use the Material rounded variants. Tabs switch the content in place; album and playlist rows push a detail screen, and Settings is pushed from the top bar. The sync action doubles as the progress indicator while a sync runs. There is no bottom navigation. A mini player sits above the navigation bar across every screen whenever a track is loaded. It is a permanent part of the app shell (the screens above it consume the navigation-bar inset so nothing is padded twice); the Now Playing screen slides up over the whole shell as an overlay rather than being a nav destination, so the mini player never re-lays out during the transition.
|
The library is one `LibraryScreen` with a pinned top bar: the current section as the title, a custom row of icon buttons (Albums, Artists, Playlists) on the left, and Sync and Settings icon actions on the right. The selected section is drawn as a filled box in the content colour with the icon inverted, like the Flutter app — this is a hand-rolled row, not Material `TabRow` (no underline indicator). Icons use the Material rounded variants. Tabs switch the content in place; album and playlist rows push a detail screen, and Settings is pushed from the top bar. The sync action doubles as the progress indicator while a sync runs. There is no bottom navigation. A mini player sits above the navigation bar across every screen whenever a track is loaded. It is a permanent part of the app shell (the screens above it consume the navigation-bar inset so nothing is padded twice); the Now Playing screen slides up over the whole shell as an overlay rather than being a nav destination, so the mini player never re-lays out during the transition.
|
||||||
|
|
||||||
The Albums tab is a three-column grid of covers only — a later preference will toggle captions, and sorting will hang off a FAB, so Settings only manages servers and neither shows sort nor sync controls.
|
The Albums tab is a three-column grid of covers only — a later preference will toggle captions, and sorting will hang off a FAB, so Settings only manages servers and neither shows sort nor sync controls.
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ UI and playback preferences live in DataStore (`user_prefs`), not in SQLite: the
|
|||||||
|
|
||||||
Every schema version step ships a hand-written `Migration` (`data/db/Migrations.kt`), and there is no destructive fallback, so an upgrade never silently wipes the local mirror. The exported schemas under `app/schemas/` are the source of truth for the migration SQL, and `MigrationsTest` runs each step against a real SQLite connection and asserts the resulting tables, columns and indices.
|
Every schema version step ships a hand-written `Migration` (`data/db/Migrations.kt`), and there is no destructive fallback, so an upgrade never silently wipes the local mirror. The exported schemas under `app/schemas/` are the source of truth for the migration SQL, and `MigrationsTest` runs each step against a real SQLite connection and asserts the resulting tables, columns and indices.
|
||||||
|
|
||||||
Library screens page over Room with `PagingSource<Int, T>` queries, ordering by a sort chosen in preferences (name, artist, year, recently added). Paging is local, but it keeps memory bounded on large libraries and lets a huge song or playlist list render incrementally. Room 3 requires `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)` on the DAO for this.
|
Library screens page over Room with `PagingSource<Int, T>` queries, ordering by a sort chosen in preferences (name, artist, year, recently added). Paging is local, but it keeps memory bounded on large libraries and lets a huge playlist render incrementally. Room 3 requires `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)` on the DAO for this.
|
||||||
|
|
||||||
Song rows show the album's cover art, fetched by joining `songs` to `albums` into a `SongListItem` rather than storing cover art on the song: some servers return a distinct cover ID per track, and keeping those would fragment the image cache for no visual gain. Artists show their image cropped to a circle.
|
Song rows show the album's cover art, fetched by joining `songs` to `albums` into a `SongListItem` rather than storing cover art on the song: some servers return a distinct cover ID per track, and keeping those would fragment the image cache for no visual gain. Artists show their image cropped to a circle.
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ Sync is not atomic. Each batch commits on its own, so a failure leaves the batch
|
|||||||
|
|
||||||
## Search
|
## Search
|
||||||
|
|
||||||
Search is a case-insensitive substring filter on the library queries (`instr(lower(column), lower(:search)) > 0` in `LibraryDao`), scoped to the active tab and with the matching rows ordered by the tab's sort. There is no separate search index; the filter runs directly against the library tables. An FTS5 trigram shadow table was trialled and dropped: it was wired to a separate search screen that went unused, and it indexed only `title`, so it did not cover the per-field substring the inline filter does. The decision is reversible if infix search over a very large library ever needs an index, because `tokenize='trigram'` is the only SQLite built-in that can index an arbitrary substring match. A revival must index every field the filter scans (`title`/`artist`/`album` for songs, `name`/`albumArtist` for albums, `name` for artists and playlists), be kept current incrementally per sync batch rather than cleared and rebuilt, and accept a three-character minimum (shorter queries fall back to the scan); sorting and paging would still come from the normal indexed library queries.
|
Search is a case-insensitive substring filter on the library queries (`instr(lower(column), lower(:search)) > 0` in `LibraryDao`), scoped to the active tab and with the matching rows ordered by the tab's sort. There is no separate search index; the filter runs directly against the library tables. An FTS5 trigram shadow table was trialled and dropped: it was wired to a separate search screen that went unused, and it indexed only `title`, so it did not cover the per-field substring the inline filter does. The decision is reversible if infix search over a very large library ever needs an index, because `tokenize='trigram'` is the only SQLite built-in that can index an arbitrary substring match. A revival must index every field the filter scans (`name`/`albumArtist` for albums, `name` for artists and playlists), be kept current incrementally per sync batch rather than cleared and rebuilt, and accept a three-character minimum (shorter queries fall back to the scan); sorting and paging would still come from the normal indexed library queries.
|
||||||
|
|
||||||
## Networking and auth
|
## Networking and auth
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ Everything needed to mirror a server library locally:
|
|||||||
Goal: a usable app for browsing a server, end to end.
|
Goal: a usable app for browsing a server, end to end.
|
||||||
|
|
||||||
- Koin DI and DataStore-backed preferences (album sort); the unused `app_settings` Room table was removed in favour of DataStore.
|
- Koin DI and DataStore-backed preferences (album sort); the unused `app_settings` Room table was removed in favour of DataStore.
|
||||||
- App shell: a root gate that shows the add-server flow until a server exists, then the tabbed library screen matching the Flutter app (icon-only tabs for albums, artists, songs and playlists, with sync and settings actions), plus album and playlist detail screens and a monochrome theme.
|
- App shell: a root gate that shows the add-server flow until a server exists, then the tabbed library screen matching the Flutter app (icon-only tabs for albums, artists and playlists, with sync and settings actions), plus album and playlist detail screens and a monochrome theme.
|
||||||
- Source setup: name/address/username/password with token-auth toggle, a connection test, save-and-sync, plus server switching and removal in settings.
|
- Source setup: name/address/username/password with token-auth toggle, a connection test, save-and-sync, plus server switching and removal in settings.
|
||||||
- Library browsing: albums (covers-only grid), artists, songs and playlists paged from Room (`PagingSource`) and rendered with `LazyPagingItems`, with Coil cover art served from stable media URLs.
|
- Library browsing: albums (covers-only grid), artists and playlists paged from Room (`PagingSource`) and rendered with `LazyPagingItems`, with Coil cover art served from stable media URLs.
|
||||||
- Artist detail: tapping an artist opens their albums, newest first, and an album can be opened from there.
|
- Artist detail: tapping an artist opens their albums, newest first, and an album can be opened from there.
|
||||||
- Roborazzi screenshots for the new screens, rendered on demand for review (not committed).
|
- Roborazzi screenshots for the new screens, rendered on demand for review (not committed).
|
||||||
|
|
||||||
@@ -34,7 +34,6 @@ Not done yet in this slice (deliberately): cover-art tonal colour extraction, lo
|
|||||||
- (done) Media notifications and headset/Bluetooth controls, from the media session.
|
- (done) Media notifications and headset/Bluetooth controls, from the media session.
|
||||||
- (done) A SQL-backed queue of references (whole playlist / album / song, each with an optional ordinal range) resolved lazily, with only a bounded window in memory and in the player.
|
- (done) A SQL-backed queue of references (whole playlist / album / song, each with an optional ordinal range) resolved lazily, with only a bounded window in memory and in the player.
|
||||||
- (done) Streaming bitrate and preferred format from settings (`maxBitRate`/`format`, so the server transcodes as configured).
|
- (done) Streaming bitrate and preferred format from settings (`maxBitRate`/`format`, so the server transcodes as configured).
|
||||||
- (done) Playing from the Songs tab: the tapped song queues the whole songs list at that position.
|
|
||||||
- (done) A queue view that lists the resolved queue, jumps to a track on tap, removes tracks and reorders them by dragging, with a one-step undo.
|
- (done) A queue view that lists the resolved queue, jumps to a track on tap, removes tracks and reorders them by dragging, with a one-step undo.
|
||||||
- Adding to the queue from the library (play next / add to queue), and gapless format preferences.
|
- Adding to the queue from the library (play next / add to queue), and gapless format preferences.
|
||||||
- Scrobbling and "now playing" back to the server.
|
- Scrobbling and "now playing" back to the server.
|
||||||
@@ -56,7 +55,7 @@ Not done yet in this slice (deliberately): cover-art tonal colour extraction, lo
|
|||||||
|
|
||||||
Follow-up work for very large libraries, playlists and long sessions, grouped so each block can be one PR.
|
Follow-up work for very large libraries, playlists and long sessions, grouped so each block can be one PR.
|
||||||
|
|
||||||
- **Library read path.** Index the normalized sort keys (a denormalized `albumArtist` on `songs` plus `NOCASE` indices on `albums`/`artists`/`playlists`) and replace `LIMIT 1 OFFSET` with keyset/cursor seeks in `QueueRepository`/`QueueDao`; compute the tapped-song ordinal with a SQL rank instead of materializing the whole sorted id list. Shared dependency for the read-side fixes; can split into "indices" then "keyset" if too large for one review.
|
- **Library read path.** Index the sort keys used by album/artist/playlist browsing (a denormalized `albumArtist` on `albums` plus `NOCASE` indices) and replace `LIMIT 1 OFFSET` with keyset/cursor seeks in `QueueRepository`/`QueueDao`. Shared dependency for the read-side fixes; can split into "indices" then "keyset" if too large for one review.
|
||||||
- **Shuffle.** Drop `shuffle_order` and the retained whole-order/whole-id arrays; derive the permutation from `(seed, size)` and resolve positions through the same positional lookup. Recommended after the read-path PR, since it shares `QueueRepository`.
|
- **Shuffle.** Drop `shuffle_order` and the retained whole-order/whole-id arrays; derive the permutation from `(seed, size)` and resolve positions through the same positional lookup. Recommended after the read-path PR, since it shares `QueueRepository`.
|
||||||
- **Sync pipeline.** Prune with a memory-bounded id set, remove `fetchRanks` and the write-only rank columns, fetch playlists/albums with bounded concurrency and skip unchanged ones, and raise/make configurable the page cap with a clear "library too large" failure. Independent of the read path.
|
- **Sync pipeline.** Prune with a memory-bounded id set, remove `fetchRanks` and the write-only rank columns, fetch playlists/albums with bounded concurrency and skip unchanged ones, and raise/make configurable the page cap with a clear "library too large" failure. Independent of the read path.
|
||||||
- **Queue view and edits.** Bound the queue view's loaded rows (or page over the queue ordinal) and scope its ViewModel to the overlay; compact adjacent same-ref ranges after an edit and cache resolved entry lengths. The view half is independent; the edit half shares `QueueRepository` with the read-path PR.
|
- **Queue view and edits.** Bound the queue view's loaded rows (or page over the queue ordinal) and scope its ViewModel to the overlay; compact adjacent same-ref ranges after an edit and cache resolved entry lengths. The view half is independent; the edit half shares `QueueRepository` with the read-path PR.
|
||||||
|
|||||||
Reference in New Issue
Block a user