diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index c56148b1..71bf01bb 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -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> + + @Query( + "SELECT COUNT(*) FROM songs WHERE songs.sourceId = :sourceId AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR (:starredFilter = 2 AND songs.starred IS NULL))", + ) + suspend fun songCountForFilter( + sourceId: Long, + starredFilter: Int, + ): Long + + @Query( + "${SONGS_IDS_SELECT}ORDER BY albums.albumArtist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.title COLLATE NOCASE, songs.id", + ) + suspend fun songIdsByAlbum( + sourceId: Long, + starredFilter: Int, + ): List + + @Query( + "${SONGS_IDS_SELECT}ORDER BY albums.albumArtist COLLATE NOCASE DESC, songs.album COLLATE NOCASE DESC, " + + "songs.disc DESC, songs.track DESC, songs.title COLLATE NOCASE DESC, songs.id DESC", + ) + suspend fun songIdsByAlbumReversed( + sourceId: Long, + starredFilter: Int, + ): List + + @Query("${SONGS_IDS_SELECT}ORDER BY songs.title COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id") + suspend fun songIdsByTitle( + sourceId: Long, + starredFilter: Int, + ): List + + @Query("${SONGS_IDS_SELECT}ORDER BY songs.title COLLATE NOCASE DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id") + suspend fun songIdsByTitleReversed( + sourceId: Long, + starredFilter: Int, + ): List + + @Query("${SONGS_IDS_SELECT}ORDER BY songs.artist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id") + suspend fun songIdsByArtist( + sourceId: Long, + starredFilter: Int, + ): List + + @Query("${SONGS_IDS_SELECT}ORDER BY songs.artist COLLATE NOCASE DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id") + suspend fun songIdsByArtistReversed( + sourceId: Long, + starredFilter: Int, + ): List + + @Query( + "${SONGS_IDS_SELECT}ORDER BY songs.starred IS NULL, songs.starred DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id", + ) + suspend fun songIdsByStarred( + sourceId: Long, + starredFilter: Int, + ): List + + @Query( + "${SONGS_IDS_SELECT}ORDER BY songs.starred IS NULL, songs.starred ASC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id", + ) + suspend fun songIdsByStarredReversed( + sourceId: Long, + starredFilter: Int, + ): List + + @Query("${SONGS_IDS_SELECT}ORDER BY songs.created DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id") + suspend fun songIdsByAdded( + sourceId: Long, + starredFilter: Int, + ): List + + @Query("${SONGS_IDS_SELECT}ORDER BY songs.created ASC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id") + suspend fun songIdsByAddedReversed( + sourceId: Long, + starredFilter: Int, + ): List } diff --git a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt index 6ef24b34..0c5d9c2b 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -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 { + 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, @@ -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 { - if (snapshot.shuffled) { - val sourceId = - snapshot.entries - .firstOrNull() - ?.entry - ?.sourceId ?: return emptyList() - val ids = flatIds(snapshot) - val requested = mutableListOf>() - 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>() + 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() - 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 = - 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 diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 9cb61867..7a910357 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -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) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index b62b58e7..365d1bb9 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -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 diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt index 37a45730..98b4acfa 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt @@ -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)) } } diff --git a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt index e8c93ac2..f9cf231e 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt @@ -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): 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, ) }