drop the songs tab and the whole-library song queue

This commit is contained in:
austinried
2026-10-04 00:29:59 +00:00
parent 9cf2c99fb5
commit e3af5ed0ac
25 changed files with 36 additions and 927 deletions
@@ -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()
+3 -3
View File
@@ -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
+3 -4
View File
@@ -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.