mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
songs queue follows the applied sort and starred filter; clear a tab's search when leaving it
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user