add the Downloaded filter to the library tabs

One toggle per tab, persisted with the rest of the list query, narrowing to
entities that have at least one completed download. It rides the existing filter
SQL: every album, artist and playlist paging query takes a flag and the shared
filter constant adds an EXISTS over song_downloads, so it composes with the
sort, search and starred filter rather than being a separate mode.

The filters sheet now renders on every tab, since playlists support Downloaded
but not Starred.
This commit is contained in:
austinried
2026-10-04 00:30:03 +00:00
parent 7db6d1365f
commit 7d04125f58
6 changed files with 162 additions and 49 deletions
@@ -29,6 +29,9 @@ internal const val PLAYLIST_SONGS_SQL = PLAYLIST_SONGS_SELECT + PLAYLIST_SONGS_O
internal const val ALBUMS_FILTER = internal const val ALBUMS_FILTER =
"FROM albums WHERE sourceId = :sourceId " + "FROM albums WHERE sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')) " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " "AND (:search = '' OR instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) "
internal const val ALBUM_ORDER_BY_NAME = "name COLLATE NOCASE, id" internal const val ALBUM_ORDER_BY_NAME = "name COLLATE NOCASE, id"
@@ -48,6 +51,10 @@ internal const val ALBUM_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, na
internal const val ARTISTS_FILTER = internal const val ARTISTS_FILTER =
"FROM artists WHERE sourceId = :sourceId " + "FROM artists WHERE sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM songs " +
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = artists.sourceId AND dl.artistId = artists.id AND sd.status = 'Completed')) " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " "AND (:search = '' OR instr(lower(name), lower(:search)) > 0) "
internal const val ARTIST_ORDER_BY_NAME = "name COLLATE NOCASE, id" internal const val ARTIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
@@ -59,6 +66,9 @@ internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, n
internal const val PLAYLISTS_FILTER = internal const val PLAYLISTS_FILTER =
"FROM playlists WHERE sourceId = :sourceId " + "FROM playlists WHERE sourceId = :sourceId " +
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM playlist_songs ps " +
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId " +
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id AND sd.status = 'Completed')) " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " "AND (:search = '' OR instr(lower(name), lower(:search)) > 0) "
internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id" internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
@@ -205,6 +215,7 @@ interface LibraryDao {
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_REVERSED") @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_REVERSED")
@@ -212,6 +223,7 @@ interface LibraryDao {
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST") @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST")
@@ -219,6 +231,7 @@ interface LibraryDao {
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_REVERSED") @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_REVERSED")
@@ -226,6 +239,7 @@ interface LibraryDao {
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR") @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
@@ -233,6 +247,7 @@ interface LibraryDao {
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR_REVERSED") @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR_REVERSED")
@@ -240,6 +255,7 @@ interface LibraryDao {
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED") @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED")
@@ -247,6 +263,7 @@ interface LibraryDao {
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED_REVERSED") @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED_REVERSED")
@@ -254,6 +271,7 @@ interface LibraryDao {
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED") @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED")
@@ -261,6 +279,7 @@ interface LibraryDao {
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED_REVERSED") @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED_REVERSED")
@@ -268,6 +287,7 @@ interface LibraryDao {
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME") @Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME")
@@ -275,6 +295,7 @@ interface LibraryDao {
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist> ): PagingSource<Int, Artist>
@Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_REVERSED") @Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_REVERSED")
@@ -282,6 +303,7 @@ interface LibraryDao {
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist> ): PagingSource<Int, Artist>
@Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT") @Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT")
@@ -289,6 +311,7 @@ interface LibraryDao {
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist> ): PagingSource<Int, Artist>
@Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED") @Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED")
@@ -296,6 +319,7 @@ interface LibraryDao {
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist> ): PagingSource<Int, Artist>
@Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED") @Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED")
@@ -303,6 +327,7 @@ interface LibraryDao {
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist> ): PagingSource<Int, Artist>
@Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED_REVERSED") @Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED_REVERSED")
@@ -310,42 +335,49 @@ interface LibraryDao {
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist> ): PagingSource<Int, Artist>
@Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME") @Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME")
fun playlistsByName( fun playlistsByName(
sourceId: Long, sourceId: Long,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist> ): PagingSource<Int, Playlist>
@Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME_REVERSED") @Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME_REVERSED")
fun playlistsByNameReversed( fun playlistsByNameReversed(
sourceId: Long, sourceId: Long,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist> ): PagingSource<Int, Playlist>
@Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED") @Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED")
fun playlistsByAdded( fun playlistsByAdded(
sourceId: Long, sourceId: Long,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist> ): PagingSource<Int, Playlist>
@Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED_REVERSED") @Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED_REVERSED")
fun playlistsByAddedReversed( fun playlistsByAddedReversed(
sourceId: Long, sourceId: Long,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist> ): PagingSource<Int, Playlist>
@Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED") @Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED")
fun playlistsByUpdated( fun playlistsByUpdated(
sourceId: Long, sourceId: Long,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist> ): PagingSource<Int, Playlist>
@Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED_REVERSED") @Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED_REVERSED")
fun playlistsByUpdatedReversed( fun playlistsByUpdatedReversed(
sourceId: Long, sourceId: Long,
search: String, search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist> ): PagingSource<Int, Playlist>
@Query(PLAYLIST_SONGS_SQL) @Query(PLAYLIST_SONGS_SQL)
@@ -35,6 +35,7 @@ data class ListQuery(
val sort: String, val sort: String,
val descending: Boolean = false, val descending: Boolean = false,
val starred: StarredFilter = StarredFilter.Any, val starred: StarredFilter = StarredFilter.Any,
val downloaded: Boolean = false,
) )
data class StreamQuality( data class StreamQuality(
@@ -107,7 +108,8 @@ class UserPreferences(
private fun listQueryKey(tab: LibraryListTab) = stringPreferencesKey("list_query_${tab.key}") private fun listQueryKey(tab: LibraryListTab) = stringPreferencesKey("list_query_${tab.key}")
private fun encode(query: ListQuery) = "${query.sort}|${if (query.descending) 1 else 0}|${query.starred.ordinal}" private fun encode(query: ListQuery) =
"${query.sort}|${if (query.descending) 1 else 0}|${query.starred.ordinal}|${if (query.downloaded) 1 else 0}"
private fun encode(quality: StreamQuality) = "${quality.maxBitrate}|${quality.format.orEmpty()}" private fun encode(quality: StreamQuality) = "${quality.maxBitrate}|${quality.format.orEmpty()}"
@@ -130,11 +132,12 @@ class UserPreferences(
defaultSort: String, defaultSort: String,
): ListQuery { ): ListQuery {
val parts = stored?.split("|") val parts = stored?.split("|")
if (parts == null || parts.size != 3) return ListQuery(defaultSort) if (parts == null || parts.size < 3) return ListQuery(defaultSort)
return ListQuery( return ListQuery(
sort = parts[0].ifEmpty { defaultSort }, sort = parts[0].ifEmpty { defaultSort },
descending = parts[1] == "1", descending = parts[1] == "1",
starred = StarredFilter.entries.getOrElse(parts[2].toIntOrNull() ?: 0) { StarredFilter.Any }, starred = StarredFilter.entries.getOrElse(parts[2].toIntOrNull() ?: 0) { StarredFilter.Any },
downloaded = parts.getOrNull(3) == "1",
) )
} }
} }
@@ -41,47 +41,49 @@ class LibraryRepository(
descending: Boolean, descending: Boolean,
starred: StarredFilter, starred: StarredFilter,
search: String, search: String,
downloaded: Boolean,
): Flow<PagingData<Album>> = ): Flow<PagingData<Album>> =
pager(40) { pager(40) {
val dao = db.libraryDao() val dao = db.libraryDao()
val downloadedFilter = if (downloaded) 1 else 0
when (sort) { when (sort) {
AlbumSort.Name -> { AlbumSort.Name -> {
if (descending) { if (descending) {
dao.albumsByNameReversed(sourceId, starred.ordinal, search) dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.albumsByName(sourceId, starred.ordinal, search) dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter)
} }
} }
AlbumSort.Artist -> { AlbumSort.Artist -> {
if (descending) { if (descending) {
dao.albumsByArtistReversed(sourceId, starred.ordinal, search) dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.albumsByArtist(sourceId, starred.ordinal, search) dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter)
} }
} }
AlbumSort.Year -> { AlbumSort.Year -> {
if (descending) { if (descending) {
dao.albumsByYearReversed(sourceId, starred.ordinal, search) dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.albumsByYear(sourceId, starred.ordinal, search) dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter)
} }
} }
AlbumSort.Added -> { AlbumSort.Added -> {
if (descending) { if (descending) {
dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search) dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search) dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter)
} }
} }
AlbumSort.Starred -> { AlbumSort.Starred -> {
if (descending) { if (descending) {
dao.albumsByStarredReversed(sourceId, starred.ordinal, search) dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.albumsByStarred(sourceId, starred.ordinal, search) dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
} }
} }
} }
@@ -93,31 +95,33 @@ class LibraryRepository(
descending: Boolean, descending: Boolean,
starred: StarredFilter, starred: StarredFilter,
search: String, search: String,
downloaded: Boolean,
): Flow<PagingData<Artist>> = ): Flow<PagingData<Artist>> =
pager(60) { pager(60) {
val dao = db.libraryDao() val dao = db.libraryDao()
val downloadedFilter = if (downloaded) 1 else 0
when (sort) { when (sort) {
ArtistSort.Name -> { ArtistSort.Name -> {
if (descending) { if (descending) {
dao.artistsByNameReversed(sourceId, starred.ordinal, search) dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.artistsByName(sourceId, starred.ordinal, search) dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter)
} }
} }
ArtistSort.AlbumCount -> { ArtistSort.AlbumCount -> {
if (descending) { if (descending) {
dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search) dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.artistsByAlbumCount(sourceId, starred.ordinal, search) dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter)
} }
} }
ArtistSort.Starred -> { ArtistSort.Starred -> {
if (descending) { if (descending) {
dao.artistsByStarredReversed(sourceId, starred.ordinal, search) dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.artistsByStarred(sourceId, starred.ordinal, search) dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
} }
} }
} }
@@ -128,31 +132,33 @@ class LibraryRepository(
sort: PlaylistSort, sort: PlaylistSort,
descending: Boolean, descending: Boolean,
search: String, search: String,
downloaded: Boolean,
): Flow<PagingData<Playlist>> = ): Flow<PagingData<Playlist>> =
pager(40) { pager(40) {
val dao = db.libraryDao() val dao = db.libraryDao()
val downloadedFilter = if (downloaded) 1 else 0
when (sort) { when (sort) {
PlaylistSort.Name -> { PlaylistSort.Name -> {
if (descending) { if (descending) {
dao.playlistsByNameReversed(sourceId, search) dao.playlistsByNameReversed(sourceId, search, downloadedFilter)
} else { } else {
dao.playlistsByName(sourceId, search) dao.playlistsByName(sourceId, search, downloadedFilter)
} }
} }
PlaylistSort.Added -> { PlaylistSort.Added -> {
if (descending) { if (descending) {
dao.playlistsByAddedReversed(sourceId, search) dao.playlistsByAddedReversed(sourceId, search, downloadedFilter)
} else { } else {
dao.playlistsByAdded(sourceId, search) dao.playlistsByAdded(sourceId, search, downloadedFilter)
} }
} }
PlaylistSort.Updated -> { PlaylistSort.Updated -> {
if (descending) { if (descending) {
dao.playlistsByUpdatedReversed(sourceId, search) dao.playlistsByUpdatedReversed(sourceId, search, downloadedFilter)
} else { } else {
dao.playlistsByUpdated(sourceId, search) dao.playlistsByUpdated(sourceId, search, downloadedFilter)
} }
} }
} }
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.rounded.Album
import androidx.compose.material.icons.rounded.ArrowDownward import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward import androidx.compose.material.icons.rounded.ArrowUpward
import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.DownloadDone
import androidx.compose.material.icons.rounded.FilterAltOff import androidx.compose.material.icons.rounded.FilterAltOff
import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Remove import androidx.compose.material.icons.rounded.Remove
@@ -212,7 +213,7 @@ fun LibraryRoute(
LibraryTab.entries.associateWith { tab -> LibraryTab.entries.associateWith { tab ->
val query by viewModel.listQuery(tab.listTab()).collectAsStateWithLifecycle() val query by viewModel.listQuery(tab.listTab()).collectAsStateWithLifecycle()
val term by viewModel.search(tab.listTab()).collectAsStateWithLifecycle() val term by viewModel.search(tab.listTab()).collectAsStateWithLifecycle()
"${query.sort}|${query.descending}|${query.starred}|$term" "${query.sort}|${query.descending}|${query.starred}|${query.downloaded}|$term"
} }
val playback by playbackController.state.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle()
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true)) val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true))
@@ -271,11 +272,12 @@ fun LibraryRoute(
viewModel.setListQuery(listTab, listQuery.copy(starred = next)) viewModel.setListQuery(listTab, listQuery.copy(starred = next))
}, },
onClearFilters = { onClearFilters = {
viewModel.setListQuery(listTab, listQuery.copy(starred = StarredFilter.Any)) viewModel.setListQuery(listTab, listQuery.copy(starred = StarredFilter.Any, downloaded = false))
viewModel.setSearch(listTab, "") viewModel.setSearch(listTab, "")
}, },
search = search, search = search,
onSearchChange = { viewModel.setSearch(listTab, it) }, onSearchChange = { viewModel.setSearch(listTab, it) },
onToggleDownloaded = { viewModel.setListQuery(listTab, listQuery.copy(downloaded = !listQuery.downloaded)) },
albumDownloads = downloads.takeIf { listTab == LibraryListTab.Albums }.orEmpty(), albumDownloads = downloads.takeIf { listTab == LibraryListTab.Albums }.orEmpty(),
artistDownloads = downloads.takeIf { listTab == LibraryListTab.Artists }.orEmpty(), artistDownloads = downloads.takeIf { listTab == LibraryListTab.Artists }.orEmpty(),
playlistDownloads = downloads.takeIf { listTab == LibraryListTab.Playlists }.orEmpty(), playlistDownloads = downloads.takeIf { listTab == LibraryListTab.Playlists }.orEmpty(),
@@ -315,6 +317,7 @@ fun LibraryScreen(
onSortChange: (String) -> Unit = {}, onSortChange: (String) -> Unit = {},
onToggleSortDirection: () -> Unit = {}, onToggleSortDirection: () -> Unit = {},
onCycleStarred: () -> Unit = {}, onCycleStarred: () -> Unit = {},
onToggleDownloaded: () -> Unit = {},
onClearFilters: () -> Unit = {}, onClearFilters: () -> Unit = {},
search: String = "", search: String = "",
onSearchChange: (String) -> Unit = {}, onSearchChange: (String) -> Unit = {},
@@ -355,7 +358,7 @@ fun LibraryScreen(
val density = LocalDensity.current val density = LocalDensity.current
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() } val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
val filtersActive = listQuery.starred != StarredFilter.Any || search.isNotEmpty() val filtersActive = listQuery.starred != StarredFilter.Any || listQuery.downloaded || search.isNotEmpty()
var tabBarHeightPx by remember { mutableFloatStateOf(with(density) { (TAB_BAR_CONTENT_HEIGHT + bottomInset).toPx() }) } var tabBarHeightPx by remember { mutableFloatStateOf(with(density) { (TAB_BAR_CONTENT_HEIGHT + bottomInset).toPx() }) }
val tabBarHeight = with(density) { tabBarHeightPx.toDp() } val tabBarHeight = with(density) { tabBarHeightPx.toDp() }
val listTopInset = statusBarTop val listTopInset = statusBarTop
@@ -540,6 +543,7 @@ fun LibraryScreen(
onSortChange = onSortChange, onSortChange = onSortChange,
onToggleSortDirection = onToggleSortDirection, onToggleSortDirection = onToggleSortDirection,
onCycleStarred = onCycleStarred, onCycleStarred = onCycleStarred,
onToggleDownloaded = onToggleDownloaded,
onClearFilters = onClearFilters, onClearFilters = onClearFilters,
onSearch = { onSearch = {
showOptions = false showOptions = false
@@ -560,6 +564,7 @@ private fun ListOptionsSheet(
onSortChange: (String) -> Unit, onSortChange: (String) -> Unit,
onToggleSortDirection: () -> Unit, onToggleSortDirection: () -> Unit,
onCycleStarred: () -> Unit, onCycleStarred: () -> Unit,
onToggleDownloaded: () -> Unit,
onClearFilters: () -> Unit, onClearFilters: () -> Unit,
onSearch: () -> Unit, onSearch: () -> Unit,
) { ) {
@@ -615,7 +620,6 @@ private fun ListOptionsSheet(
} }
} }
if (starredSupported) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 16.dp), modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 16.dp),
@@ -636,6 +640,24 @@ private fun ListOptionsSheet(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) { ) {
FilterChip(
selected = listQuery.downloaded,
onClick = onToggleDownloaded,
label = { Text("Downloaded") },
leadingIcon =
if (listQuery.downloaded) {
{
Icon(
Icons.Rounded.DownloadDone,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize),
)
}
} else {
null
},
)
if (starredSupported) {
FilterChip( FilterChip(
selected = listQuery.starred != StarredFilter.Any, selected = listQuery.starred != StarredFilter.Any,
onClick = onCycleStarred, onClick = onCycleStarred,
@@ -62,7 +62,7 @@ class LibraryViewModel(
combine(listQueries.getValue(LibraryListTab.Albums), searches.getValue(LibraryListTab.Albums)) { query, search -> combine(listQueries.getValue(LibraryListTab.Albums), searches.getValue(LibraryListTab.Albums)) { query, search ->
query to search query to search
}.flatMapLatest { (query, search) -> }.flatMapLatest { (query, search) ->
libraryRepository.albums(sourceId, query.albumSort(), query.descending, query.starred, search) libraryRepository.albums(sourceId, query.albumSort(), query.descending, query.starred, search, query.downloaded)
} }
}.cachedIn(viewModelScope) }.cachedIn(viewModelScope)
@@ -73,7 +73,7 @@ class LibraryViewModel(
combine(listQueries.getValue(LibraryListTab.Artists), searches.getValue(LibraryListTab.Artists)) { query, search -> combine(listQueries.getValue(LibraryListTab.Artists), searches.getValue(LibraryListTab.Artists)) { query, search ->
query to search query to search
}.flatMapLatest { (query, search) -> }.flatMapLatest { (query, search) ->
libraryRepository.artists(sourceId, query.artistSort(), query.descending, query.starred, search) libraryRepository.artists(sourceId, query.artistSort(), query.descending, query.starred, search, query.downloaded)
} }
}.cachedIn(viewModelScope) }.cachedIn(viewModelScope)
@@ -84,7 +84,7 @@ class LibraryViewModel(
combine(listQueries.getValue(LibraryListTab.Playlists), searches.getValue(LibraryListTab.Playlists)) { query, search -> combine(listQueries.getValue(LibraryListTab.Playlists), searches.getValue(LibraryListTab.Playlists)) { query, search ->
query to search query to search
}.flatMapLatest { (query, search) -> }.flatMapLatest { (query, search) ->
libraryRepository.playlists(sourceId, query.playlistSort(), query.descending, search) libraryRepository.playlists(sourceId, query.playlistSort(), query.descending, search, query.downloaded)
} }
}.cachedIn(viewModelScope) }.cachedIn(viewModelScope)
@@ -9,8 +9,11 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -290,6 +293,53 @@ class LibraryDaoTest {
assertTrue(plan, plan.contains("TEMP B-TREE")) assertTrue(plan, plan.contains("TEMP B-TREE"))
} }
@Test
fun theDownloadedFilterKeepsOnlyEntitiesWithACompletedDownload() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "Downloaded", year = 2000, starred = null),
album(sourceId, "al-2", "Empty", year = 2001, starred = null),
),
)
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 2)))
dao.upsertSongs(
listOf(
song(sourceId, "s1", "One", starred = null),
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2"),
),
)
dao.upsertPlaylists(
listOf(
playlist(sourceId, "pl-1", "Has download", created = 0, changed = 0),
playlist(sourceId, "pl-2", "No download", created = 1, changed = 1),
),
)
dao.upsertPlaylistSongs(
listOf(
PlaylistSong(sourceId, "pl-1", "s1", 0),
PlaylistSong(sourceId, "pl-2", "s2", 0),
),
)
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
assertEquals(
listOf("Downloaded"),
dao.albumsByName(sourceId, 0, "", downloadedFilter = 1).page().map { it.name },
)
assertEquals(
listOf("ar-1"),
dao.artistsByName(sourceId, 0, "", downloadedFilter = 1).page().map { it.id },
)
assertEquals(
listOf("pl-1"),
dao.playlistsByName(sourceId, "", downloadedFilter = 1).page().map { it.id },
)
assertEquals(2, dao.albumsByName(sourceId, 0, "").page().size)
}
private suspend fun plan(sql: String): String = private suspend fun plan(sql: String): String =
db.useReaderConnection { connection -> db.useReaderConnection { connection ->
connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement -> connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement ->