From e3af5ed0acf788f8dd43855ee5722c9a4033eda8 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 26 Sep 2026 04:24:17 +0000 Subject: [PATCH] drop the songs tab and the whole-library song queue --- .../com/subtracks/data/db/LibraryDao.kt | 360 ------------------ .../kotlin/com/subtracks/data/db/QueueDao.kt | 19 - .../kotlin/com/subtracks/data/model/Models.kt | 4 +- .../subtracks/data/prefs/UserPreferences.kt | 3 - .../subtracks/data/repo/LibraryRepository.kt | 53 --- .../subtracks/data/repo/QueueRepository.kt | 148 +------ .../main/kotlin/com/subtracks/di/AppModule.kt | 2 +- .../subtracks/playback/PlaybackController.kt | 18 - .../com/subtracks/ui/library/LibraryScreen.kt | 40 -- .../subtracks/ui/library/LibraryViewModel.kt | 32 -- .../com/subtracks/ui/library/SongsContent.kt | 77 ---- .../subtracks/ui/playback/NowPlayingScreen.kt | 8 +- .../com/subtracks/data/db/LibraryDaoTest.kt | 45 +-- .../data/prefs/UserPreferencesTest.kt | 8 +- .../data/repo/QueueRepositoryTest.kt | 48 --- .../subtracks/data/sync/SyncServiceTest.kt | 18 +- .../playback/PlaybackControllerTest.kt | 13 - .../com/subtracks/ui/AppScreenshotTest.kt | 13 - .../ui/LibraryOptionsScreenshotTest.kt | 3 - .../com/subtracks/ui/LibraryOptionsTest.kt | 3 - .../com/subtracks/ui/LibraryScreenTest.kt | 24 +- .../ui/LibrarySearchScreenshotTest.kt | 3 - .../ui/playback/QueueViewModelTest.kt | 8 +- docs/architecture.md | 6 +- docs/implementation-plan.md | 7 +- 25 files changed, 36 insertions(+), 927 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index f971de80..69debea9 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -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 - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - @Query(PLAYLIST_SONGS_SQL) fun playlistSongs( sourceId: Long, @@ -640,181 +457,4 @@ interface LibraryDao { sourceId: Long, albumId: String, ): Flow> - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 - - @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 } diff --git a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt index ab4ed0f3..7ddfa76c 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt @@ -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 - @Query("$SONGS_SQL LIMIT :limit OFFSET :offset") - suspend fun songs( - sourceId: Long, - offset: Long, - limit: Int, - ): List - @Query("$SONG_SQL LIMIT :limit OFFSET :offset") suspend fun song( sourceId: Long, @@ -111,15 +101,6 @@ interface QueueDao { albumId: String, ): List - @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 - @Query( "SELECT songs.id FROM playlist_songs " + "JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " + diff --git a/app/src/main/kotlin/com/subtracks/data/model/Models.kt b/app/src/main/kotlin/com/subtracks/data/model/Models.kt index 293897d5..f8f7dc99 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -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 } diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index cad8c80b..68c6de02 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -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 } diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index 0b2cc1e5..c1823fee 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -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> = - 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, diff --git a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt index 4939c6a7..97ff3d42 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -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 { - 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 { - 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, 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 diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index bacbb952..df615e77 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -46,7 +46,7 @@ fun appModule( single { 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()) } diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 7b0ae6af..43a5c901 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -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, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index bf2f6078..5073f681 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -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(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>, artists: Flow>, - songs: Flow>, playlists: Flow>, 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 = 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(), diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt index 1e8ba86d..795b1a74 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt @@ -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> = @@ -86,27 +80,11 @@ class LibraryViewModel( } }.cachedIn(viewModelScope) - val songs: Flow> = - 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 = syncManager.status .map { it == SyncStatus.Running } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) - val playingSongId: StateFlow = - 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 = 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 diff --git a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt index d4744647..98492e55 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt @@ -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, - 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( diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt index f1e44c6d..4e7829ed 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -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 { diff --git a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt index 4ddfa508..47b7f3cc 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -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, diff --git a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt index 6e8bc21b..43042315 100644 --- a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt @@ -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() } diff --git a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt index 7e5cff10..216924c0 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt @@ -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): QueueSnapshot { replace(entries) return snapshot() diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt index e122c0d6..80746a77 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt @@ -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 diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 0f70dc07..77200dfc 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -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) diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index daa4f491..7827f210 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -107,13 +107,11 @@ class AppScreenshotTest { onTabSelected = {}, albums = remember { flowOf(PagingData.from(albums)) }, artists = remember { flowOf(PagingData.empty()) }, - songs = remember { flowOf(PagingData.empty()) }, playlists = remember { flowOf(PagingData.empty()) }, 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)), diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsScreenshotTest.kt index 6662628c..b87ae080 100644 --- a/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsScreenshotTest.kt @@ -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()) }, - songs = remember { flowOf(PagingData.empty()) }, playlists = remember { flowOf(PagingData.empty()) }, coverArt = { _, _ -> null }, onAlbumClick = {}, onArtistClick = {}, onPlaylistClick = {}, - onSongClick = {}, onSync = {}, onOpenSettings = {}, listQuery = ListQuery("Name"), diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt index fde389c5..aa84eaf0 100644 --- a/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt @@ -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()) }, - songs = remember { flowOf(PagingData.empty()) }, playlists = remember { flowOf(PagingData.empty()) }, coverArt = { _, _ -> null }, onAlbumClick = {}, onArtistClick = {}, onPlaylistClick = {}, - onSongClick = {}, onSync = {}, onOpenSettings = {}, listQuery = query, diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt index afa8a370..2e5ed1d5 100644 --- a/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt @@ -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()) }, - songs = remember { flowOf(PagingData.empty()) }, playlists = remember { flowOf(PagingData.empty()) }, coverArt = { _, _ -> null }, onAlbumClick = {}, onArtistClick = {}, onPlaylistClick = {}, - onSongClick = {}, onSync = { synced = true }, onOpenSettings = { settingsOpened = true }, ) @@ -95,13 +92,11 @@ class LibraryScreenTest { onTabSelected = {}, albums = remember { flowOf(PagingData.empty(settledLoadStates)) }, artists = remember { flowOf(PagingData.empty()) }, - songs = remember { flowOf(PagingData.empty()) }, playlists = remember { flowOf(PagingData.empty()) }, coverArt = { _, _ -> null }, onAlbumClick = {}, onArtistClick = {}, onPlaylistClick = {}, - onSongClick = {}, onSync = { synced = true }, onOpenSettings = {}, ) @@ -129,11 +124,6 @@ class LibraryScreenTest { collected += "artists" emit(PagingData.empty()) } - val songs = - flow> { - collected += "songs" - emit(PagingData.empty()) - } val playlists = flow> { 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> { - collected += "songs" - emit(PagingData.empty()) - } val playlists = flow> { 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 } diff --git a/app/src/test/kotlin/com/subtracks/ui/LibrarySearchScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibrarySearchScreenshotTest.kt index 4aed3489..9911eddc 100644 --- a/app/src/test/kotlin/com/subtracks/ui/LibrarySearchScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/LibrarySearchScreenshotTest.kt @@ -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()) }, - songs = remember { flowOf(PagingData.empty()) }, playlists = remember { flowOf(PagingData.empty()) }, coverArt = { _, _ -> null }, onAlbumClick = {}, onArtistClick = {}, onPlaylistClick = {}, - onSongClick = {}, onSync = {}, onOpenSettings = {}, listQuery = ListQuery("Name"), diff --git a/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt index 66c07046..316a0095 100644 --- a/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt @@ -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() diff --git a/docs/architecture.md b/docs/architecture.md index 853bdc66..45823441 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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` 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` 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 diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index f7c34da8..d8236c30 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -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.