mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +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 " +
|
||||
"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
|
||||
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
|
||||
interface LibraryDao {
|
||||
@@ -431,168 +410,6 @@ interface LibraryDao {
|
||||
search: String,
|
||||
): 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)
|
||||
fun playlistSongs(
|
||||
sourceId: Long,
|
||||
@@ -640,181 +457,4 @@ interface LibraryDao {
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
): 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,
|
||||
): 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")
|
||||
suspend fun songLength(
|
||||
sourceId: Long,
|
||||
@@ -87,13 +84,6 @@ interface QueueDao {
|
||||
limit: Int,
|
||||
): 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")
|
||||
suspend fun song(
|
||||
sourceId: Long,
|
||||
@@ -111,15 +101,6 @@ interface QueueDao {
|
||||
albumId: 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(
|
||||
"SELECT songs.id FROM playlist_songs " +
|
||||
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||
|
||||
@@ -191,7 +191,7 @@ data class DiscKey(
|
||||
val disc: Long,
|
||||
)
|
||||
|
||||
enum class QueueKind { Playlist, Album, Song, Songs }
|
||||
enum class QueueKind { Playlist, Album, Song }
|
||||
|
||||
@Entity(
|
||||
tableName = "queue_entries",
|
||||
@@ -261,5 +261,5 @@ class QueueKindConverter {
|
||||
fun fromQueueKind(kind: QueueKind): String = kind.name
|
||||
|
||||
@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 SongSort { Album, Title, Artist, Starred, Added }
|
||||
|
||||
enum class LibraryListTab(
|
||||
val key: String,
|
||||
val defaultSort: String,
|
||||
@@ -26,7 +24,6 @@ enum class LibraryListTab(
|
||||
Albums("albums", AlbumSort.Name.name, true),
|
||||
Artists("artists", ArtistSort.Name.name, true),
|
||||
Playlists("playlists", PlaylistSort.Name.name, false),
|
||||
Songs("songs", SongSort.Album.name, true),
|
||||
}
|
||||
|
||||
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.ArtistSort
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.SongSort
|
||||
import com.subtracks.data.prefs.StarredFilter
|
||||
import com.subtracks.data.source.StarType
|
||||
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(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
|
||||
@@ -4,15 +4,12 @@ import androidx.room3.deferredTransaction
|
||||
import androidx.room3.immediateTransaction
|
||||
import androidx.room3.useReaderConnection
|
||||
import androidx.room3.useWriterConnection
|
||||
import com.subtracks.data.db.LibraryDao
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.model.PlaybackCursor
|
||||
import com.subtracks.data.model.QueueEntry
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.ShuffleOrder
|
||||
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.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -20,12 +17,6 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
const val QUEUE_CHUNK = 60
|
||||
|
||||
data class QueueSongQuery(
|
||||
val sort: SongSort,
|
||||
val descending: Boolean,
|
||||
val starred: StarredFilter,
|
||||
)
|
||||
|
||||
data class ResolvedQueueEntry(
|
||||
val entry: QueueEntry,
|
||||
val length: Long,
|
||||
@@ -100,26 +91,6 @@ class QueueRepository(
|
||||
songId: String,
|
||||
) = 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(
|
||||
kind: QueueKind,
|
||||
sourceId: Long,
|
||||
@@ -143,117 +114,12 @@ class QueueRepository(
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
QueueKind.Songs, QueueKind.Song -> {
|
||||
QueueKind.Song -> {
|
||||
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(
|
||||
entries: List<QueueEntry>,
|
||||
shuffleOrder: LongArray? = null,
|
||||
@@ -476,7 +342,6 @@ class QueueRepository(
|
||||
when (entry.kind) {
|
||||
QueueKind.Song -> listOf(entry.refId)
|
||||
QueueKind.Album -> dao.albumSongIds(entry.sourceId, entry.refId)
|
||||
QueueKind.Songs -> songIdsFor(entry)
|
||||
QueueKind.Playlist -> dao.playlistSongIds(entry.sourceId, entry.refId)
|
||||
}
|
||||
val from = entry.offset.toInt().coerceIn(0, entryIds.size)
|
||||
@@ -502,12 +367,6 @@ class QueueRepository(
|
||||
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 -> {
|
||||
dao.song(entry.sourceId, entry.refId, offset, limit)
|
||||
}
|
||||
@@ -527,11 +386,6 @@ class QueueRepository(
|
||||
QueueKind.Album -> {
|
||||
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)
|
||||
return count?.coerceAtMost(available) ?: available
|
||||
|
||||
@@ -46,7 +46,7 @@ fun appModule(
|
||||
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get()) }
|
||||
single { PlaybackController(get(), get(), get()) }
|
||||
viewModel { RootViewModel(get()) }
|
||||
viewModel { LibraryViewModel(get(), get(), get(), get(), get()) }
|
||||
viewModel { LibraryViewModel(get(), get(), get(), get()) }
|
||||
viewModel { SettingsViewModel(get(), get()) }
|
||||
viewModel { AddSourceViewModel(get(), 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.repo.QueueRepository
|
||||
import com.subtracks.data.repo.QueueSnapshot
|
||||
import com.subtracks.data.repo.QueueSongQuery
|
||||
import com.subtracks.data.repo.QueueWindowItem
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -190,23 +189,6 @@ class PlaybackController(
|
||||
playlistId: String,
|
||||
) = 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(
|
||||
sourceId: Long,
|
||||
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.Close
|
||||
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.Remove
|
||||
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.CoverArtRef
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.SongListItem
|
||||
import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.ArtistSort
|
||||
import com.subtracks.data.prefs.LibraryListTab
|
||||
import com.subtracks.data.prefs.ListQuery
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.SongSort
|
||||
import com.subtracks.data.prefs.StarredFilter
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
@@ -135,7 +132,6 @@ enum class LibraryTab(
|
||||
Albums("Albums", Icons.Rounded.Album),
|
||||
Artists("Artists", Icons.Rounded.Person),
|
||||
Playlists("Playlists", Icons.AutoMirrored.Rounded.PlaylistPlay),
|
||||
Songs("Songs", Icons.Rounded.MusicNote),
|
||||
}
|
||||
|
||||
data class SortOption(
|
||||
@@ -173,16 +169,6 @@ fun sortOptionsFor(tab: LibraryTab): List<SortOption> =
|
||||
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)
|
||||
@@ -203,7 +189,6 @@ fun LibraryRoute(
|
||||
var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
|
||||
var previousTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
|
||||
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||
val playingSongId by viewModel.playingSongId.collectAsStateWithLifecycle()
|
||||
val listTab = selectedTab.listTab()
|
||||
LaunchedEffect(selectedTab) {
|
||||
if (previousTab != selectedTab) {
|
||||
@@ -240,17 +225,14 @@ fun LibraryRoute(
|
||||
artwork = artwork,
|
||||
albums = viewModel.albums,
|
||||
artists = viewModel.artists,
|
||||
songs = viewModel.songs,
|
||||
playlists = viewModel.playlists,
|
||||
coverArt = viewModel::coverArt,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
onSongClick = viewModel::playSong,
|
||||
onSync = viewModel::sync,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onItemLongClick = { contextMenuHost?.show(it, itemActions) },
|
||||
playingSongId = playingSongId,
|
||||
listQuery = listQuery,
|
||||
resetKeys = resetKeys,
|
||||
sortOptions = sortOptionsFor(selectedTab),
|
||||
@@ -282,18 +264,15 @@ fun LibraryScreen(
|
||||
onTabSelected: (LibraryTab) -> Unit,
|
||||
albums: Flow<PagingData<Album>>,
|
||||
artists: Flow<PagingData<Artist>>,
|
||||
songs: Flow<PagingData<SongListItem>>,
|
||||
playlists: Flow<PagingData<Playlist>>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onPlaylistClick: (Playlist) -> Unit,
|
||||
onSongClick: (String) -> Unit,
|
||||
onSync: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onItemLongClick: (MenuTarget) -> Unit = {},
|
||||
syncing: Boolean = false,
|
||||
playingSongId: String? = null,
|
||||
bottomInset: Dp = 0.dp,
|
||||
listQuery: ListQuery = ListQuery(""),
|
||||
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 -> {
|
||||
PlaylistsContent(
|
||||
playlists.collectAsLazyPagingItems(),
|
||||
|
||||
@@ -8,21 +8,16 @@ import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
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.ArtistSort
|
||||
import com.subtracks.data.prefs.LibraryListTab
|
||||
import com.subtracks.data.prefs.ListQuery
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.SongSort
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.QueueSongQuery
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.sync.SyncManager
|
||||
import com.subtracks.data.sync.SyncStatus
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -40,7 +35,6 @@ class LibraryViewModel(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val syncManager: SyncManager,
|
||||
private val playbackController: PlaybackController,
|
||||
private val userPreferences: UserPreferences,
|
||||
) : ViewModel() {
|
||||
private val listQueries: Map<LibraryListTab, StateFlow<ListQuery>> =
|
||||
@@ -86,27 +80,11 @@ class LibraryViewModel(
|
||||
}
|
||||
}.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> =
|
||||
syncManager.status
|
||||
.map { it == SyncStatus.Running }
|
||||
.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 setListQuery(
|
||||
@@ -130,14 +108,6 @@ class LibraryViewModel(
|
||||
thumbnail: Boolean,
|
||||
): 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) {
|
||||
viewModelScope.launch {
|
||||
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.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
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.Song
|
||||
import com.subtracks.data.model.SongListItem
|
||||
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
|
||||
fun SongRow(
|
||||
|
||||
@@ -112,12 +112,7 @@ fun NowPlayingRoute(
|
||||
val art = controller.coverArt(state.item)
|
||||
val thumbnail = controller.coverArt(state.item, thumbnail = true)
|
||||
val queueContext = state.context
|
||||
val fallbackTitle =
|
||||
if (queueContext?.kind == QueueKind.Songs) {
|
||||
"Library"
|
||||
} else {
|
||||
state.item?.album?.takeIf { it.isNotBlank() } ?: "Library"
|
||||
}
|
||||
val fallbackTitle = state.item?.album?.takeIf { it.isNotBlank() } ?: "Library"
|
||||
val sourceTitle = remember(queueContext) { mutableStateOf(fallbackTitle) }
|
||||
LaunchedEffect(queueContext) {
|
||||
sourceTitle.value = controller.sourceTitle(queueContext)?.takeIf { it.isNotBlank() } ?: fallbackTitle
|
||||
@@ -443,7 +438,6 @@ private fun QueueKind.label(): String =
|
||||
QueueKind.Album -> "album"
|
||||
QueueKind.Playlist -> "playlist"
|
||||
QueueKind.Song -> "song"
|
||||
QueueKind.Songs -> "songs"
|
||||
}
|
||||
|
||||
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.Song
|
||||
import com.subtracks.data.model.Source
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -63,29 +64,6 @@ class LibraryDaoTest {
|
||||
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
|
||||
fun albumAddedAndStarredSortsAndSearch() =
|
||||
runTest {
|
||||
@@ -112,24 +90,21 @@ class LibraryDaoTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun songAddedSortOrdersByCreated() =
|
||||
fun albumSongsAreOrderedByDiscAndTrack() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "Zebra", starred = null, created = 100),
|
||||
song(sourceId, "s2", "Apple", starred = null, created = 200),
|
||||
song(sourceId, "s2", "Second", starred = null, track = 2),
|
||||
song(sourceId, "s3", "Third", starred = null, track = 1, disc = 2),
|
||||
song(sourceId, "s1", "First", starred = null, track = 1),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("Apple", "Zebra"),
|
||||
dao.songsByAdded(sourceId, starredFilter = 0, search = "").page().map { it.song.title },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Zebra", "Apple"),
|
||||
dao.songsByAddedReversed(sourceId, starredFilter = 0, search = "").page().map { it.song.title },
|
||||
listOf("s1", "s2", "s3"),
|
||||
dao.songsByAlbum(sourceId, "al-1").first().map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -170,6 +145,8 @@ class LibraryDaoTest {
|
||||
title: String,
|
||||
starred: Long?,
|
||||
created: Long = 0,
|
||||
track: Long = 1,
|
||||
disc: Long = 1,
|
||||
) = Song(
|
||||
sourceId = sourceId,
|
||||
id = id,
|
||||
@@ -179,8 +156,8 @@ class LibraryDaoTest {
|
||||
album = "Album",
|
||||
artist = "Artist",
|
||||
duration = 200,
|
||||
track = 1,
|
||||
disc = 1,
|
||||
track = track,
|
||||
disc = disc,
|
||||
starred = starred,
|
||||
genre = null,
|
||||
created = created,
|
||||
|
||||
@@ -21,15 +21,19 @@ class UserPreferencesTest {
|
||||
val prefs = UserPreferences(store)
|
||||
|
||||
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.Artists, ListQuery("AlbumCount", descending = true))
|
||||
|
||||
assertEquals(
|
||||
ListQuery("Added", descending = true, starred = StarredFilter.Starred),
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ import com.subtracks.data.model.QueueEntry
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.prefs.SongSort
|
||||
import com.subtracks.data.prefs.StarredFilter
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -75,19 +73,6 @@ class QueueRepositoryTest {
|
||||
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
|
||||
fun windowSpansEntryBoundaries() =
|
||||
runTest {
|
||||
@@ -314,39 +299,6 @@ class QueueRepositoryTest {
|
||||
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 {
|
||||
replace(entries)
|
||||
return snapshot()
|
||||
|
||||
@@ -69,14 +69,7 @@ class SyncServiceTest {
|
||||
.allRows()
|
||||
.size,
|
||||
)
|
||||
assertEquals(
|
||||
2,
|
||||
db
|
||||
.libraryDao()
|
||||
.songs(1, 0, "")
|
||||
.allRows()
|
||||
.size,
|
||||
)
|
||||
assertEquals(2, db.libraryDao().songIds(1).size)
|
||||
assertEquals(
|
||||
1,
|
||||
db
|
||||
@@ -99,14 +92,7 @@ class SyncServiceTest {
|
||||
.allRows()
|
||||
.map { it.id },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("s1"),
|
||||
db
|
||||
.libraryDao()
|
||||
.songs(1, 0, "")
|
||||
.allRows()
|
||||
.map { it.song.id },
|
||||
)
|
||||
assertEquals(listOf("s1"), db.libraryDao().songIds(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -106,19 +106,6 @@ class PlaybackControllerTest {
|
||||
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
|
||||
fun advancingShiftsTheWindowAndKeepsItBounded() {
|
||||
seedAlbum(100, sourceId = 1)
|
||||
|
||||
@@ -107,13 +107,11 @@ class AppScreenshotTest {
|
||||
onTabSelected = {},
|
||||
albums = remember { flowOf(PagingData.from(albums)) },
|
||||
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||
songs = remember { flowOf(PagingData.empty<SongListItem>()) },
|
||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSongClick = {},
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
|
||||
@@ -133,13 +131,6 @@ class AppScreenshotTest {
|
||||
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
|
||||
fun libraryPlaylists() {
|
||||
setLibraryContent(LibraryTab.Playlists)
|
||||
@@ -166,13 +157,11 @@ class AppScreenshotTest {
|
||||
onTabSelected = {},
|
||||
albums = remember { flowOf(PagingData.from(Fixtures.albums)) },
|
||||
artists = remember { flowOf(PagingData.from(Fixtures.artists)) },
|
||||
songs = remember { flowOf(PagingData.from(Fixtures.songItems)) },
|
||||
playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) },
|
||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSongClick = {},
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
artwork = artwork,
|
||||
@@ -473,13 +462,11 @@ class AppScreenshotTest {
|
||||
onTabSelected = {},
|
||||
albums = remember { flowOf(PagingData.from(Fixtures.albums)) },
|
||||
artists = remember { flowOf(PagingData.from(Fixtures.artists)) },
|
||||
songs = remember { flowOf(PagingData.from(Fixtures.songItems)) },
|
||||
playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) },
|
||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSongClick = {},
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
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.Artist
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.SongListItem
|
||||
import com.subtracks.data.prefs.ListQuery
|
||||
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
||||
import com.subtracks.ui.library.LibraryScreen
|
||||
@@ -44,13 +43,11 @@ class LibraryOptionsScreenshotTest {
|
||||
onTabSelected = {},
|
||||
albums = remember { flowOf(PagingData.from(albums())) },
|
||||
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||
songs = remember { flowOf(PagingData.empty<SongListItem>()) },
|
||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||
coverArt = { _, _ -> null },
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSongClick = {},
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
listQuery = ListQuery("Name"),
|
||||
|
||||
@@ -17,7 +17,6 @@ import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.SongListItem
|
||||
import com.subtracks.data.prefs.ListQuery
|
||||
import com.subtracks.data.prefs.StarredFilter
|
||||
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
||||
@@ -50,13 +49,11 @@ class LibraryOptionsTest {
|
||||
onTabSelected = {},
|
||||
albums = remember { flowOf(PagingData.from(albums())) },
|
||||
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||
songs = remember { flowOf(PagingData.empty<SongListItem>()) },
|
||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||
coverArt = { _, _ -> null },
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSongClick = {},
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
listQuery = query,
|
||||
|
||||
@@ -23,7 +23,6 @@ import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
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.LibraryScreen
|
||||
import com.subtracks.ui.library.LibraryTab
|
||||
@@ -60,13 +59,11 @@ class LibraryScreenTest {
|
||||
onTabSelected = {},
|
||||
albums = remember { flowOf(PagingData.from(albums())) },
|
||||
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||
songs = remember { flowOf(PagingData.empty<SongListItem>()) },
|
||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||
coverArt = { _, _ -> null },
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSongClick = {},
|
||||
onSync = { synced = true },
|
||||
onOpenSettings = { settingsOpened = true },
|
||||
)
|
||||
@@ -95,13 +92,11 @@ class LibraryScreenTest {
|
||||
onTabSelected = {},
|
||||
albums = remember { flowOf(PagingData.empty<Album>(settledLoadStates)) },
|
||||
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||
songs = remember { flowOf(PagingData.empty<SongListItem>()) },
|
||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||
coverArt = { _, _ -> null },
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSongClick = {},
|
||||
onSync = { synced = true },
|
||||
onOpenSettings = {},
|
||||
)
|
||||
@@ -129,11 +124,6 @@ class LibraryScreenTest {
|
||||
collected += "artists"
|
||||
emit(PagingData.empty())
|
||||
}
|
||||
val songs =
|
||||
flow<PagingData<SongListItem>> {
|
||||
collected += "songs"
|
||||
emit(PagingData.empty())
|
||||
}
|
||||
val playlists =
|
||||
flow<PagingData<Playlist>> {
|
||||
collected += "playlists"
|
||||
@@ -146,13 +136,11 @@ class LibraryScreenTest {
|
||||
onTabSelected = {},
|
||||
albums = albums,
|
||||
artists = artists,
|
||||
songs = songs,
|
||||
playlists = playlists,
|
||||
coverArt = { _, _ -> null },
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSongClick = {},
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
)
|
||||
@@ -178,11 +166,6 @@ class LibraryScreenTest {
|
||||
collected += "artists"
|
||||
emit(PagingData.from(artists()))
|
||||
}
|
||||
val songs =
|
||||
flow<PagingData<SongListItem>> {
|
||||
collected += "songs"
|
||||
emit(PagingData.empty())
|
||||
}
|
||||
val playlists =
|
||||
flow<PagingData<Playlist>> {
|
||||
collected += "playlists"
|
||||
@@ -196,13 +179,11 @@ class LibraryScreenTest {
|
||||
onTabSelected = { selectedTab.value = it },
|
||||
albums = albums,
|
||||
artists = artists,
|
||||
songs = songs,
|
||||
playlists = playlists,
|
||||
coverArt = { _, _ -> null },
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSongClick = {},
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
)
|
||||
@@ -223,8 +204,7 @@ class LibraryScreenTest {
|
||||
|
||||
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"))
|
||||
assertFalse("inactive tabs should not collect their flows", collected.contains("songs"))
|
||||
assertFalse(collected.contains("playlists"))
|
||||
assertFalse("inactive tabs should not collect their flows", collected.contains("playlists"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -247,7 +227,7 @@ class LibraryScreenTest {
|
||||
composeRule.runOnIdle { pagerState.requestScrollToPage(1, step * 0.05f) }
|
||||
composeRule.waitForIdle()
|
||||
composeRule
|
||||
.onNodeWithContentDescription(LibraryTab.Songs.label)
|
||||
.onNodeWithContentDescription(LibraryTab.Playlists.label)
|
||||
.fetchSemanticsNode()
|
||||
.boundsInRoot.left
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.SongListItem
|
||||
import com.subtracks.data.prefs.ListQuery
|
||||
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
||||
import com.subtracks.ui.library.LibraryScreen
|
||||
@@ -45,13 +44,11 @@ class LibrarySearchScreenshotTest {
|
||||
onTabSelected = {},
|
||||
albums = remember { flowOf(PagingData.from(albums())) },
|
||||
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||
songs = remember { flowOf(PagingData.empty<SongListItem>()) },
|
||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||
coverArt = { _, _ -> null },
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSongClick = {},
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
listQuery = ListQuery("Name"),
|
||||
|
||||
@@ -66,7 +66,7 @@ class QueueViewModelTest {
|
||||
@Test
|
||||
fun openingLoadsAWindowAtTheCursorAndExtendingGrowsIt() {
|
||||
runBlocking { seedSongs(200) }
|
||||
controller.playSongs(1, 100)
|
||||
controller.playAlbum(1, "al1", 100)
|
||||
await { controller.state.value.position == 100L }
|
||||
|
||||
viewModel.open()
|
||||
@@ -87,7 +87,7 @@ class QueueViewModelTest {
|
||||
@Test
|
||||
fun openingAtTheEndLoadsAWindowAndExtendsOlder() {
|
||||
runBlocking { seedSongs(200) }
|
||||
controller.playSongs(1, 199)
|
||||
controller.playAlbum(1, "al1", 199)
|
||||
await { controller.state.value.position == 199L }
|
||||
|
||||
viewModel.open()
|
||||
@@ -111,7 +111,7 @@ class QueueViewModelTest {
|
||||
@Test
|
||||
fun reopeningReusesRowIdentitySoComposeDoesNotCrossFadeTwoLists() {
|
||||
runBlocking { seedSongs(200) }
|
||||
controller.playSongs(1, 100)
|
||||
controller.playAlbum(1, "al1", 100)
|
||||
await { controller.state.value.position == 100L }
|
||||
|
||||
viewModel.open()
|
||||
@@ -128,7 +128,7 @@ class QueueViewModelTest {
|
||||
@Test
|
||||
fun reorderingMovesTheRowLocally() {
|
||||
runBlocking { seedSongs(200) }
|
||||
controller.playSongs(1, 100)
|
||||
controller.playAlbum(1, "al1", 100)
|
||||
await { controller.state.value.position == 100L }
|
||||
|
||||
viewModel.open()
|
||||
|
||||
@@ -70,7 +70,7 @@ Compose UI -> ViewModel -> Repository -> MusicSource (remote, Subsonic)
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -118,7 +118,7 @@ Sync is not atomic. Each batch commits on its own, so a failure leaves the batch
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ Everything needed to mirror a server library locally:
|
||||
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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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) 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) 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.
|
||||
- Adding to the queue from the library (play next / add to queue), and gapless format preferences.
|
||||
- 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.
|
||||
|
||||
- **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`.
|
||||
- **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.
|
||||
|
||||
Reference in New Issue
Block a user