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 " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId " + "WHERE songs.sourceId = :sourceId " +
"ORDER BY albums.albumArtist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, " + "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 @Dao
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class) @DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
@@ -538,4 +545,81 @@ interface LibraryDao {
sourceId: Long, sourceId: Long,
albumId: String, albumId: String,
): Flow<List<Song>> ): Flow<List<Song>>
@Query(
"SELECT COUNT(*) FROM songs WHERE songs.sourceId = :sourceId AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR (:starredFilter = 2 AND songs.starred IS NULL))",
)
suspend fun songCountForFilter(
sourceId: Long,
starredFilter: Int,
): Long
@Query(
"${SONGS_IDS_SELECT}ORDER BY albums.albumArtist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.title COLLATE NOCASE, songs.id",
)
suspend fun songIdsByAlbum(
sourceId: Long,
starredFilter: Int,
): List<String>
@Query(
"${SONGS_IDS_SELECT}ORDER BY albums.albumArtist COLLATE NOCASE DESC, songs.album COLLATE NOCASE DESC, " +
"songs.disc DESC, songs.track DESC, songs.title COLLATE NOCASE DESC, songs.id DESC",
)
suspend fun songIdsByAlbumReversed(
sourceId: Long,
starredFilter: Int,
): List<String>
@Query("${SONGS_IDS_SELECT}ORDER BY songs.title COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id")
suspend fun songIdsByTitle(
sourceId: Long,
starredFilter: Int,
): List<String>
@Query("${SONGS_IDS_SELECT}ORDER BY songs.title COLLATE NOCASE DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id")
suspend fun songIdsByTitleReversed(
sourceId: Long,
starredFilter: Int,
): List<String>
@Query("${SONGS_IDS_SELECT}ORDER BY songs.artist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id")
suspend fun songIdsByArtist(
sourceId: Long,
starredFilter: Int,
): List<String>
@Query("${SONGS_IDS_SELECT}ORDER BY songs.artist COLLATE NOCASE DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id")
suspend fun songIdsByArtistReversed(
sourceId: Long,
starredFilter: Int,
): List<String>
@Query(
"${SONGS_IDS_SELECT}ORDER BY songs.starred IS NULL, songs.starred DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id",
)
suspend fun songIdsByStarred(
sourceId: Long,
starredFilter: Int,
): List<String>
@Query(
"${SONGS_IDS_SELECT}ORDER BY songs.starred IS NULL, songs.starred ASC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id",
)
suspend fun songIdsByStarredReversed(
sourceId: Long,
starredFilter: Int,
): List<String>
@Query("${SONGS_IDS_SELECT}ORDER BY songs.created DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id")
suspend fun songIdsByAdded(
sourceId: Long,
starredFilter: Int,
): List<String>
@Query("${SONGS_IDS_SELECT}ORDER BY songs.created ASC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id")
suspend fun songIdsByAddedReversed(
sourceId: Long,
starredFilter: Int,
): List<String>
} }
@@ -10,12 +10,20 @@ import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.ShuffleOrder import com.subtracks.data.model.ShuffleOrder
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
import com.subtracks.data.prefs.SongSort
import com.subtracks.data.prefs.StarredFilter
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
const val QUEUE_CHUNK = 60 const val QUEUE_CHUNK = 60
data class QueueSongQuery(
val sort: SongSort,
val descending: Boolean,
val starred: StarredFilter,
)
data class ResolvedQueueEntry( data class ResolvedQueueEntry(
val entry: QueueEntry, val entry: QueueEntry,
val length: Long, val length: Long,
@@ -92,15 +100,78 @@ class QueueRepository(
fun songsEntry(sourceId: Long) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Songs, refId = "") fun songsEntry(sourceId: Long) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Songs, refId = "")
suspend fun songsIndexOf( fun songsEntry(
sourceId: Long, 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, songId: String,
): Long? = ): Long? {
dao val index = songIdsFor(entry).indexOf(songId)
.songIds(sourceId) return if (index >= 0) index.toLong() else null
.indexOf(songId) }
.takeIf { it >= 0 }
?.toLong() 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( suspend fun replace(
entries: List<QueueEntry>, entries: List<QueueEntry>,
@@ -176,19 +247,15 @@ class QueueRepository(
snapshot: QueueSnapshot, snapshot: QueueSnapshot,
position: Long, position: Long,
): SongListItem? { ): SongListItem? {
if (snapshot.shuffled) { val flat = snapshot.flatPosition(position) ?: return null
val id = flatIds(snapshot).getOrNull(flat.toInt()) ?: return null
val sourceId = val sourceId =
snapshot.entries snapshot.entries
.firstOrNull() .firstOrNull()
?.entry ?.entry
?.sourceId ?: return null ?.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() return dao.songsByIds(sourceId, listOf(id)).firstOrNull()
} }
val (entry, offset) = snapshot.locate(position) ?: return null
return rows(entry, entry.offset + offset, 1).firstOrNull()
}
suspend fun flatIndexOf( suspend fun flatIndexOf(
snapshot: QueueSnapshot, snapshot: QueueSnapshot,
@@ -204,12 +271,7 @@ class QueueRepository(
first: Long, first: Long,
last: Long, last: Long,
): List<QueueWindowItem> { ): List<QueueWindowItem> {
if (snapshot.shuffled) { if (snapshot.size == 0L) return emptyList()
val sourceId =
snapshot.entries
.firstOrNull()
?.entry
?.sourceId ?: return emptyList()
val ids = flatIds(snapshot) val ids = flatIds(snapshot)
val requested = mutableListOf<Pair<Long, String>>() val requested = mutableListOf<Pair<Long, String>>()
for (position in first..last) { for (position in first..last) {
@@ -218,24 +280,14 @@ class QueueRepository(
requested += position to id requested += position to id
} }
if (requested.isEmpty()) return emptyList() 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 } 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) } } return requested.mapNotNull { (position, id) -> byId[id]?.let { QueueWindowItem(position, it) } }
} }
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
}
suspend fun window( suspend fun window(
snapshot: QueueSnapshot, snapshot: QueueSnapshot,
@@ -322,7 +374,7 @@ class QueueRepository(
when (entry.kind) { when (entry.kind) {
QueueKind.Song -> listOf(entry.refId) QueueKind.Song -> listOf(entry.refId)
QueueKind.Album -> dao.albumSongIds(entry.sourceId, entry.refId) QueueKind.Album -> dao.albumSongIds(entry.sourceId, entry.refId)
QueueKind.Songs -> dao.songIds(entry.sourceId) QueueKind.Songs -> songIdsFor(entry)
QueueKind.Playlist -> dao.playlistSongIds(entry.sourceId, entry.refId) QueueKind.Playlist -> dao.playlistSongIds(entry.sourceId, entry.refId)
} }
val from = entry.offset.toInt().coerceIn(0, entryIds.size) val from = entry.offset.toInt().coerceIn(0, entryIds.size)
@@ -334,25 +386,25 @@ class QueueRepository(
return ids 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 { private suspend fun QueueEntry.resolvedLength(): Long {
val total = val total =
when (kind) { when (kind) {
QueueKind.Song -> dao.songLength(sourceId, refId) QueueKind.Song -> {
QueueKind.Playlist -> dao.playlistLength(sourceId, refId) dao.songLength(sourceId, refId)
QueueKind.Album -> dao.albumLength(sourceId, refId) }
QueueKind.Songs -> dao.songsLength(sourceId)
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) val available = (total - offset).coerceAtLeast(0)
return count?.coerceAtMost(available) ?: available 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.model.QueueKind
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.QueueSnapshot import com.subtracks.data.repo.QueueSnapshot
import com.subtracks.data.repo.QueueSongQuery
import com.subtracks.data.repo.QueueWindowItem import com.subtracks.data.repo.QueueWindowItem
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -195,10 +196,12 @@ class PlaybackController(
fun playSong( fun playSong(
sourceId: Long, sourceId: Long,
songId: String, songId: String,
query: QueueSongQuery,
) { ) {
scope.launch { scope.launch {
val index = queueRepository.songsIndexOf(sourceId, songId) ?: return@launch val entry = queueRepository.songsEntry(sourceId, query)
play(listOf(queueRepository.songsEntry(sourceId)), index) val index = queueRepository.songsIndexOf(entry, songId) ?: return@launch
play(listOf(entry), index)
} }
} }
@@ -169,9 +169,16 @@ fun LibraryRoute(
viewModel: LibraryViewModel = koinViewModel(), viewModel: LibraryViewModel = koinViewModel(),
) { ) {
var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) } var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
var previousTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
val syncing by viewModel.syncing.collectAsStateWithLifecycle() val syncing by viewModel.syncing.collectAsStateWithLifecycle()
val playingSongId by viewModel.playingSongId.collectAsStateWithLifecycle() val playingSongId by viewModel.playingSongId.collectAsStateWithLifecycle()
val listTab = selectedTab.listTab() val listTab = selectedTab.listTab()
LaunchedEffect(selectedTab) {
if (previousTab != selectedTab) {
viewModel.setSearch(previousTab.listTab(), "")
previousTab = selectedTab
}
}
val listQuery by viewModel.listQuery(listTab).collectAsStateWithLifecycle() val listQuery by viewModel.listQuery(listTab).collectAsStateWithLifecycle()
val search by viewModel.search(listTab).collectAsStateWithLifecycle() val search by viewModel.search(listTab).collectAsStateWithLifecycle()
val resetKeys = val resetKeys =
@@ -271,7 +278,7 @@ fun LibraryScreen(
if (pagerState.currentPage != selectedTab.ordinal) { if (pagerState.currentPage != selectedTab.ordinal) {
pagerState.animateScrollToPage(selectedTab.ordinal) pagerState.animateScrollToPage(selectedTab.ordinal)
} }
searchActive = search.isNotEmpty() searchActive = false
} }
val density = LocalDensity.current 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.SongSort
import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.QueueSongQuery
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager import com.subtracks.data.sync.SyncManager
import com.subtracks.data.sync.SyncStatus import com.subtracks.data.sync.SyncStatus
@@ -132,7 +133,8 @@ class LibraryViewModel(
fun playSong(songId: String) { fun playSong(songId: String) {
viewModelScope.launch { viewModelScope.launch {
val sourceId = sourceRepository.activeSourceIdOnce() ?: return@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.QueueKind
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.prefs.SongSort
import com.subtracks.data.prefs.StarredFilter
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -312,6 +314,39 @@ class QueueRepositoryTest {
assertEquals(listOf("s2", "s3", "s1"), resolveAll(repository.snapshot())) assertEquals(listOf("s2", "s3", "s1"), resolveAll(repository.snapshot()))
} }
@Test
fun theSongsEntryFollowsItsSortAndStarredFilter() =
runTest {
seedLibrary()
db.libraryDao().upsertSongs(
listOf(
song("s1", "al1", track = 1, album = "First Album", title = "Charlie", starred = 5),
song("s2", "al1", track = 2, album = "First Album", title = "Alpha"),
song("s3", "al1", track = 3, album = "First Album", title = "Bravo", starred = 7),
song("s4", "al2", track = 1, album = "Second Album", title = "Echo"),
song("s5", "al2", track = 2, album = "Second Album", title = "Delta", starred = 3),
),
)
val byTitle =
repository.snapshotAfter(
listOf(repository.songsEntry(1, QueueSongQuery(SongSort.Title, descending = false, StarredFilter.Any))),
)
assertEquals(listOf("s2", "s3", "s1", "s5", "s4"), resolveAll(byTitle))
val starredOnly =
repository.snapshotAfter(
listOf(repository.songsEntry(1, QueueSongQuery(SongSort.Starred, descending = false, StarredFilter.Starred))),
)
assertEquals(listOf("s3", "s1", "s5"), resolveAll(starredOnly))
val unstarred =
repository.snapshotAfter(
listOf(repository.songsEntry(1, QueueSongQuery(SongSort.Album, descending = false, StarredFilter.NotStarred))),
)
assertEquals(listOf("s2", "s4"), resolveAll(unstarred))
}
private suspend fun QueueRepository.snapshotAfter(entries: List<QueueEntry>): QueueSnapshot { private suspend fun QueueRepository.snapshotAfter(entries: List<QueueEntry>): QueueSnapshot {
replace(entries) replace(entries)
return snapshot() return snapshot()
@@ -379,18 +414,22 @@ class QueueRepositoryTest {
albumId: String, albumId: String,
track: Long, track: Long,
album: String = "Album", album: String = "Album",
title: String = "Song $id",
starred: Long? = null,
created: Long = 0,
) = Song( ) = Song(
sourceId = 1, sourceId = 1,
id = id, id = id,
albumId = albumId, albumId = albumId,
artistId = "ar1", artistId = "ar1",
title = "Song $id", title = title,
album = album, album = album,
artist = "Artist", artist = "Artist",
duration = 100, duration = 100,
track = track, track = track,
disc = 1, disc = 1,
starred = null, starred = starred,
genre = null, genre = null,
created = created,
) )
} }