songs queue follows the applied sort and starred filter; clear a tab's search when leaving it

This commit is contained in:
austinried
2026-10-04 00:29:56 +00:00
parent d55406c85e
commit 605bd1a6c6
6 changed files with 260 additions and 73 deletions
@@ -26,7 +26,14 @@ internal const val SONGS_SQL =
"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.album COLLATE NOCASE, songs.disc, songs.track, songs.id"
"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)) "
@Dao
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
@@ -538,4 +545,81 @@ 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>
}
@@ -10,12 +10,20 @@ 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.sync.Mutex
import kotlinx.coroutines.sync.withLock
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,
@@ -92,15 +100,78 @@ class QueueRepository(
fun songsEntry(sourceId: Long) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Songs, refId = "")
suspend fun songsIndexOf(
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? =
dao
.songIds(sourceId)
.indexOf(songId)
.takeIf { it >= 0 }
?.toLong()
): Long? {
val index = songIdsFor(entry).indexOf(songId)
return if (index >= 0) index.toLong() else 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)
}
}
}
}
suspend fun replace(
entries: List<QueueEntry>,
@@ -176,18 +247,14 @@ class QueueRepository(
snapshot: QueueSnapshot,
position: Long,
): SongListItem? {
if (snapshot.shuffled) {
val sourceId =
snapshot.entries
.firstOrNull()
?.entry
?.sourceId ?: return null
val flat = snapshot.flatPosition(position) ?: return null
val id = flatIds(snapshot).getOrNull(flat.toInt()) ?: return null
return dao.songsByIds(sourceId, listOf(id)).firstOrNull()
}
val (entry, offset) = snapshot.locate(position) ?: return null
return rows(entry, entry.offset + offset, 1).firstOrNull()
val flat = snapshot.flatPosition(position) ?: return null
val id = flatIds(snapshot).getOrNull(flat.toInt()) ?: return null
val sourceId =
snapshot.entries
.firstOrNull()
?.entry
?.sourceId ?: return null
return dao.songsByIds(sourceId, listOf(id)).firstOrNull()
}
suspend fun flatIndexOf(
@@ -204,37 +271,22 @@ class QueueRepository(
first: Long,
last: Long,
): List<QueueWindowItem> {
if (snapshot.shuffled) {
val sourceId =
snapshot.entries
.firstOrNull()
?.entry
?.sourceId ?: return emptyList()
val ids = flatIds(snapshot)
val requested = mutableListOf<Pair<Long, String>>()
for (position in first..last) {
val flat = snapshot.flatPosition(position) ?: continue
val id = ids.getOrNull(flat.toInt()) ?: continue
requested += position to id
}
if (requested.isEmpty()) return emptyList()
val byId = dao.songsByIds(sourceId, requested.map { it.second }.distinct()).associateBy { it.song.id }
return requested.mapNotNull { (position, id) -> byId[id]?.let { QueueWindowItem(position, it) } }
if (snapshot.size == 0L) return emptyList()
val ids = flatIds(snapshot)
val requested = mutableListOf<Pair<Long, String>>()
for (position in first..last) {
val flat = snapshot.flatPosition(position) ?: continue
val id = ids.getOrNull(flat.toInt()) ?: continue
requested += position to id
}
val items = mutableListOf<QueueWindowItem>()
var start = 0L
for (resolved in snapshot.entries) {
val end = start + resolved.length - 1
val entryStart = start
start += resolved.length
if (resolved.length == 0L || end < first || entryStart > last) continue
val from = maxOf(first, entryStart)
val to = minOf(last, end)
val offset = resolved.entry.offset + (from - entryStart)
rows(resolved.entry, offset, (to - from + 1).toInt())
.forEachIndexed { index, item -> items += QueueWindowItem(from + index, item) }
}
return items
if (requested.isEmpty()) return emptyList()
val sourceId =
snapshot.entries
.firstOrNull()
?.entry
?.sourceId ?: return emptyList()
val byId = dao.songsByIds(sourceId, requested.map { it.second }.distinct()).associateBy { it.song.id }
return requested.mapNotNull { (position, id) -> byId[id]?.let { QueueWindowItem(position, it) } }
}
suspend fun window(
@@ -322,7 +374,7 @@ class QueueRepository(
when (entry.kind) {
QueueKind.Song -> listOf(entry.refId)
QueueKind.Album -> dao.albumSongIds(entry.sourceId, entry.refId)
QueueKind.Songs -> dao.songIds(entry.sourceId)
QueueKind.Songs -> songIdsFor(entry)
QueueKind.Playlist -> dao.playlistSongIds(entry.sourceId, entry.refId)
}
val from = entry.offset.toInt().coerceIn(0, entryIds.size)
@@ -334,25 +386,25 @@ class QueueRepository(
return ids
}
private suspend fun rows(
entry: QueueEntry,
offset: Long,
limit: Int,
): List<SongListItem> =
when (entry.kind) {
QueueKind.Playlist -> dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
QueueKind.Album -> dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
QueueKind.Songs -> dao.songs(entry.sourceId, offset, limit)
QueueKind.Song -> dao.song(entry.sourceId, entry.refId, offset, limit)
}
private suspend fun QueueEntry.resolvedLength(): Long {
val total =
when (kind) {
QueueKind.Song -> dao.songLength(sourceId, refId)
QueueKind.Playlist -> dao.playlistLength(sourceId, refId)
QueueKind.Album -> dao.albumLength(sourceId, refId)
QueueKind.Songs -> dao.songsLength(sourceId)
QueueKind.Song -> {
dao.songLength(sourceId, refId)
}
QueueKind.Playlist -> {
dao.playlistLength(sourceId, refId)
}
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
@@ -5,6 +5,7 @@ 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
@@ -195,10 +196,12 @@ class PlaybackController(
fun playSong(
sourceId: Long,
songId: String,
query: QueueSongQuery,
) {
scope.launch {
val index = queueRepository.songsIndexOf(sourceId, songId) ?: return@launch
play(listOf(queueRepository.songsEntry(sourceId)), index)
val entry = queueRepository.songsEntry(sourceId, query)
val index = queueRepository.songsIndexOf(entry, songId) ?: return@launch
play(listOf(entry), index)
}
}
@@ -169,9 +169,16 @@ fun LibraryRoute(
viewModel: LibraryViewModel = koinViewModel(),
) {
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) {
viewModel.setSearch(previousTab.listTab(), "")
previousTab = selectedTab
}
}
val listQuery by viewModel.listQuery(listTab).collectAsStateWithLifecycle()
val search by viewModel.search(listTab).collectAsStateWithLifecycle()
val resetKeys =
@@ -271,7 +278,7 @@ fun LibraryScreen(
if (pagerState.currentPage != selectedTab.ordinal) {
pagerState.animateScrollToPage(selectedTab.ordinal)
}
searchActive = search.isNotEmpty()
searchActive = false
}
val density = LocalDensity.current
@@ -18,6 +18,7 @@ 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
@@ -132,7 +133,8 @@ class LibraryViewModel(
fun playSong(songId: String) {
viewModelScope.launch {
val sourceId = sourceRepository.activeSourceIdOnce() ?: return@launch
playbackController.playSong(sourceId, songId)
val query = listQueries.getValue(LibraryListTab.Songs).value
playbackController.playSong(sourceId, songId, QueueSongQuery(query.songSort(), query.descending, query.starred))
}
}
@@ -12,6 +12,8 @@ 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
@@ -312,6 +314,39 @@ 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()
@@ -379,18 +414,22 @@ class QueueRepositoryTest {
albumId: String,
track: Long,
album: String = "Album",
title: String = "Song $id",
starred: Long? = null,
created: Long = 0,
) = Song(
sourceId = 1,
id = id,
albumId = albumId,
artistId = "ar1",
title = "Song $id",
title = title,
album = album,
artist = "Artist",
duration = 100,
track = track,
disc = 1,
starred = null,
starred = starred,
genre = null,
created = created,
)
}