tristate starred filter, filter-aware empty state, keyboard-safe search bar

This commit is contained in:
austinried
2026-10-04 00:29:55 +00:00
parent a2d42f7957
commit 63f0549181
14 changed files with 401 additions and 127 deletions
@@ -91,193 +91,193 @@ interface LibraryDao {
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId " + "SELECT * FROM albums WHERE sourceId = :sourceId " +
"AND (:starredOnly = 0 OR starred IS NOT NULL) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
"ORDER BY name COLLATE NOCASE", "ORDER BY name COLLATE NOCASE",
) )
fun albumsByName( fun albumsByName(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId " + "SELECT * FROM albums WHERE sourceId = :sourceId " +
"AND (:starredOnly = 0 OR starred IS NOT NULL) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
"ORDER BY name COLLATE NOCASE DESC", "ORDER BY name COLLATE NOCASE DESC",
) )
fun albumsByNameReversed( fun albumsByNameReversed(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId " + "SELECT * FROM albums WHERE sourceId = :sourceId " +
"AND (:starredOnly = 0 OR starred IS NOT NULL) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
"ORDER BY albumArtist COLLATE NOCASE, year, name COLLATE NOCASE", "ORDER BY albumArtist COLLATE NOCASE, year, name COLLATE NOCASE",
) )
fun albumsByArtist( fun albumsByArtist(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId " + "SELECT * FROM albums WHERE sourceId = :sourceId " +
"AND (:starredOnly = 0 OR starred IS NOT NULL) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
"ORDER BY albumArtist COLLATE NOCASE DESC, year DESC, name COLLATE NOCASE DESC", "ORDER BY albumArtist COLLATE NOCASE DESC, year DESC, name COLLATE NOCASE DESC",
) )
fun albumsByArtistReversed( fun albumsByArtistReversed(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId " + "SELECT * FROM albums WHERE sourceId = :sourceId " +
"AND (:starredOnly = 0 OR starred IS NOT NULL) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
"ORDER BY year DESC, name COLLATE NOCASE", "ORDER BY year DESC, name COLLATE NOCASE",
) )
fun albumsByYear( fun albumsByYear(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId " + "SELECT * FROM albums WHERE sourceId = :sourceId " +
"AND (:starredOnly = 0 OR starred IS NOT NULL) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
"ORDER BY year ASC, name COLLATE NOCASE", "ORDER BY year ASC, name COLLATE NOCASE",
) )
fun albumsByYearReversed( fun albumsByYearReversed(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId " + "SELECT * FROM albums WHERE sourceId = :sourceId " +
"AND (:starredOnly = 0 OR starred IS NOT NULL) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
"ORDER BY created DESC, name COLLATE NOCASE", "ORDER BY created DESC, name COLLATE NOCASE",
) )
fun albumsByRecentlyAdded( fun albumsByRecentlyAdded(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId " + "SELECT * FROM albums WHERE sourceId = :sourceId " +
"AND (:starredOnly = 0 OR starred IS NOT NULL) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
"ORDER BY created ASC, name COLLATE NOCASE", "ORDER BY created ASC, name COLLATE NOCASE",
) )
fun albumsByRecentlyAddedReversed( fun albumsByRecentlyAddedReversed(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId " + "SELECT * FROM albums WHERE sourceId = :sourceId " +
"AND (:starredOnly = 0 OR starred IS NOT NULL) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
"ORDER BY starred IS NULL, starred DESC, name COLLATE NOCASE", "ORDER BY starred IS NULL, starred DESC, name COLLATE NOCASE",
) )
fun albumsByStarred( fun albumsByStarred(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId " + "SELECT * FROM albums WHERE sourceId = :sourceId " +
"AND (:starredOnly = 0 OR starred IS NOT NULL) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " + "AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
"ORDER BY starred IS NULL, starred ASC, name COLLATE NOCASE", "ORDER BY starred IS NULL, starred ASC, name COLLATE NOCASE",
) )
fun albumsByStarredReversed( fun albumsByStarredReversed(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query( @Query(
"SELECT * FROM artists WHERE sourceId = :sourceId " + "SELECT * FROM artists WHERE sourceId = :sourceId " +
"AND (:starredOnly = 0 OR starred IS NOT NULL) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR name LIKE '%' || :search || '%') " + "AND (:search = '' OR name LIKE '%' || :search || '%') " +
"ORDER BY name COLLATE NOCASE", "ORDER BY name COLLATE NOCASE",
) )
fun artistsByName( fun artistsByName(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, Artist> ): PagingSource<Int, Artist>
@Query( @Query(
"SELECT * FROM artists WHERE sourceId = :sourceId " + "SELECT * FROM artists WHERE sourceId = :sourceId " +
"AND (:starredOnly = 0 OR starred IS NOT NULL) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR name LIKE '%' || :search || '%') " + "AND (:search = '' OR name LIKE '%' || :search || '%') " +
"ORDER BY name COLLATE NOCASE DESC", "ORDER BY name COLLATE NOCASE DESC",
) )
fun artistsByNameReversed( fun artistsByNameReversed(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, Artist> ): PagingSource<Int, Artist>
@Query( @Query(
"SELECT * FROM artists WHERE sourceId = :sourceId " + "SELECT * FROM artists WHERE sourceId = :sourceId " +
"AND (:starredOnly = 0 OR starred IS NOT NULL) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR name LIKE '%' || :search || '%') " + "AND (:search = '' OR name LIKE '%' || :search || '%') " +
"ORDER BY albumCount DESC, name COLLATE NOCASE", "ORDER BY albumCount DESC, name COLLATE NOCASE",
) )
fun artistsByAlbumCount( fun artistsByAlbumCount(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, Artist> ): PagingSource<Int, Artist>
@Query( @Query(
"SELECT * FROM artists WHERE sourceId = :sourceId " + "SELECT * FROM artists WHERE sourceId = :sourceId " +
"AND (:starredOnly = 0 OR starred IS NOT NULL) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR name LIKE '%' || :search || '%') " + "AND (:search = '' OR name LIKE '%' || :search || '%') " +
"ORDER BY albumCount ASC, name COLLATE NOCASE", "ORDER BY albumCount ASC, name COLLATE NOCASE",
) )
fun artistsByAlbumCountReversed( fun artistsByAlbumCountReversed(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, Artist> ): PagingSource<Int, Artist>
@Query( @Query(
"SELECT * FROM artists WHERE sourceId = :sourceId " + "SELECT * FROM artists WHERE sourceId = :sourceId " +
"AND (:starredOnly = 0 OR starred IS NOT NULL) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR name LIKE '%' || :search || '%') " + "AND (:search = '' OR name LIKE '%' || :search || '%') " +
"ORDER BY starred IS NULL, starred DESC, name COLLATE NOCASE", "ORDER BY starred IS NULL, starred DESC, name COLLATE NOCASE",
) )
fun artistsByStarred( fun artistsByStarred(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, Artist> ): PagingSource<Int, Artist>
@Query( @Query(
"SELECT * FROM artists WHERE sourceId = :sourceId " + "SELECT * FROM artists WHERE sourceId = :sourceId " +
"AND (:starredOnly = 0 OR starred IS NOT NULL) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR name LIKE '%' || :search || '%') " + "AND (:search = '' OR name LIKE '%' || :search || '%') " +
"ORDER BY starred IS NULL, starred ASC, name COLLATE NOCASE", "ORDER BY starred IS NULL, starred ASC, name COLLATE NOCASE",
) )
fun artistsByStarredReversed( fun artistsByStarredReversed(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, Artist> ): PagingSource<Int, Artist>
@@ -344,7 +344,9 @@ interface LibraryDao {
@Query( @Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"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 AND (:starredOnly = 0 OR songs.starred IS NOT NULL) " + "WHERE songs.sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
"AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " +
"OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " +
"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, " +
@@ -352,91 +354,103 @@ interface LibraryDao {
) )
fun songs( fun songs(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, SongListItem> ): PagingSource<Int, SongListItem>
@Query( @Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"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 AND (:starredOnly = 0 OR songs.starred IS NOT NULL) " + "WHERE songs.sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
"AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " +
"OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " +
"ORDER BY songs.title COLLATE NOCASE, songs.id", "ORDER BY songs.title COLLATE NOCASE, songs.id",
) )
fun songsByTitle( fun songsByTitle(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, SongListItem> ): PagingSource<Int, SongListItem>
@Query( @Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"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 AND (:starredOnly = 0 OR songs.starred IS NOT NULL) " + "WHERE songs.sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
"AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " +
"OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " +
"ORDER BY songs.title COLLATE NOCASE DESC, songs.id", "ORDER BY songs.title COLLATE NOCASE DESC, songs.id",
) )
fun songsByTitleReversed( fun songsByTitleReversed(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, SongListItem> ): PagingSource<Int, SongListItem>
@Query( @Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"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 AND (:starredOnly = 0 OR songs.starred IS NOT NULL) " + "WHERE songs.sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
"AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " +
"OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " +
"ORDER BY songs.artist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id", "ORDER BY songs.artist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id",
) )
fun songsByArtist( fun songsByArtist(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, SongListItem> ): PagingSource<Int, SongListItem>
@Query( @Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"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 AND (:starredOnly = 0 OR songs.starred IS NOT NULL) " + "WHERE songs.sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
"AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " +
"OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " +
"ORDER BY songs.artist COLLATE NOCASE DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id", "ORDER BY songs.artist COLLATE NOCASE DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id",
) )
fun songsByArtistReversed( fun songsByArtistReversed(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, SongListItem> ): PagingSource<Int, SongListItem>
@Query( @Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"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 AND (:starredOnly = 0 OR songs.starred IS NOT NULL) " + "WHERE songs.sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
"AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " +
"OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " +
"ORDER BY songs.starred IS NULL, songs.starred DESC, songs.title COLLATE NOCASE, songs.id", "ORDER BY songs.starred IS NULL, songs.starred DESC, songs.title COLLATE NOCASE, songs.id",
) )
fun songsByStarred( fun songsByStarred(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, SongListItem> ): PagingSource<Int, SongListItem>
@Query( @Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"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 AND (:starredOnly = 0 OR songs.starred IS NOT NULL) " + "WHERE songs.sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND songs.starred IS NOT NULL) OR " +
"(:starredFilter = 2 AND songs.starred IS NULL)) " +
"AND (:search = '' OR songs.title LIKE '%' || :search || '%' " + "AND (:search = '' OR songs.title LIKE '%' || :search || '%' " +
"OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " + "OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " +
"ORDER BY songs.starred IS NULL, songs.starred ASC, songs.title COLLATE NOCASE, songs.id", "ORDER BY songs.starred IS NULL, songs.starred ASC, songs.title COLLATE NOCASE, songs.id",
) )
fun songsByStarredReversed( fun songsByStarredReversed(
sourceId: Long, sourceId: Long,
starredOnly: Boolean, starredFilter: Int,
search: String, search: String,
): PagingSource<Int, SongListItem> ): PagingSource<Int, SongListItem>
@@ -29,10 +29,12 @@ enum class LibraryListTab(
Songs("songs", SongSort.Album.name, true), Songs("songs", SongSort.Album.name, true),
} }
enum class StarredFilter { Any, Starred, NotStarred }
data class ListQuery( data class ListQuery(
val sort: String, val sort: String,
val descending: Boolean = false, val descending: Boolean = false,
val starredOnly: Boolean = false, val starred: StarredFilter = StarredFilter.Any,
) )
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs") private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
@@ -65,7 +67,7 @@ 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}|${if (query.starredOnly) 1 else 0}" private fun encode(query: ListQuery) = "${query.sort}|${if (query.descending) 1 else 0}|${query.starred.ordinal}"
private fun decode( private fun decode(
stored: String?, stored: String?,
@@ -76,7 +78,7 @@ class UserPreferences(
return ListQuery( return ListQuery(
sort = parts[0].ifEmpty { defaultSort }, sort = parts[0].ifEmpty { defaultSort },
descending = parts[1] == "1", descending = parts[1] == "1",
starredOnly = parts[2] == "1", starred = StarredFilter.entries.getOrElse(parts[2].toIntOrNull() ?: 0) { StarredFilter.Any },
) )
} }
@@ -14,6 +14,7 @@ import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.PlaylistSort import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.SongSort import com.subtracks.data.prefs.SongSort
import com.subtracks.data.prefs.StarredFilter
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
class LibraryRepository( class LibraryRepository(
@@ -26,7 +27,7 @@ class LibraryRepository(
sourceId: Long, sourceId: Long,
sort: AlbumSort, sort: AlbumSort,
descending: Boolean, descending: Boolean,
starredOnly: Boolean, starred: StarredFilter,
search: String, search: String,
): Flow<PagingData<Album>> = ): Flow<PagingData<Album>> =
pager(40) { pager(40) {
@@ -34,41 +35,41 @@ class LibraryRepository(
when (sort) { when (sort) {
AlbumSort.Name -> { AlbumSort.Name -> {
if (descending) { if (descending) {
dao.albumsByNameReversed(sourceId, starredOnly, search) dao.albumsByNameReversed(sourceId, starred.ordinal, search)
} else { } else {
dao.albumsByName(sourceId, starredOnly, search) dao.albumsByName(sourceId, starred.ordinal, search)
} }
} }
AlbumSort.Artist -> { AlbumSort.Artist -> {
if (descending) { if (descending) {
dao.albumsByArtistReversed(sourceId, starredOnly, search) dao.albumsByArtistReversed(sourceId, starred.ordinal, search)
} else { } else {
dao.albumsByArtist(sourceId, starredOnly, search) dao.albumsByArtist(sourceId, starred.ordinal, search)
} }
} }
AlbumSort.Year -> { AlbumSort.Year -> {
if (descending) { if (descending) {
dao.albumsByYearReversed(sourceId, starredOnly, search) dao.albumsByYearReversed(sourceId, starred.ordinal, search)
} else { } else {
dao.albumsByYear(sourceId, starredOnly, search) dao.albumsByYear(sourceId, starred.ordinal, search)
} }
} }
AlbumSort.Added -> { AlbumSort.Added -> {
if (descending) { if (descending) {
dao.albumsByRecentlyAddedReversed(sourceId, starredOnly, search) dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search)
} else { } else {
dao.albumsByRecentlyAdded(sourceId, starredOnly, search) dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search)
} }
} }
AlbumSort.Starred -> { AlbumSort.Starred -> {
if (descending) { if (descending) {
dao.albumsByStarredReversed(sourceId, starredOnly, search) dao.albumsByStarredReversed(sourceId, starred.ordinal, search)
} else { } else {
dao.albumsByStarred(sourceId, starredOnly, search) dao.albumsByStarred(sourceId, starred.ordinal, search)
} }
} }
} }
@@ -78,7 +79,7 @@ class LibraryRepository(
sourceId: Long, sourceId: Long,
sort: ArtistSort, sort: ArtistSort,
descending: Boolean, descending: Boolean,
starredOnly: Boolean, starred: StarredFilter,
search: String, search: String,
): Flow<PagingData<Artist>> = ): Flow<PagingData<Artist>> =
pager(60) { pager(60) {
@@ -86,25 +87,25 @@ class LibraryRepository(
when (sort) { when (sort) {
ArtistSort.Name -> { ArtistSort.Name -> {
if (descending) { if (descending) {
dao.artistsByNameReversed(sourceId, starredOnly, search) dao.artistsByNameReversed(sourceId, starred.ordinal, search)
} else { } else {
dao.artistsByName(sourceId, starredOnly, search) dao.artistsByName(sourceId, starred.ordinal, search)
} }
} }
ArtistSort.AlbumCount -> { ArtistSort.AlbumCount -> {
if (descending) { if (descending) {
dao.artistsByAlbumCountReversed(sourceId, starredOnly, search) dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search)
} else { } else {
dao.artistsByAlbumCount(sourceId, starredOnly, search) dao.artistsByAlbumCount(sourceId, starred.ordinal, search)
} }
} }
ArtistSort.Starred -> { ArtistSort.Starred -> {
if (descending) { if (descending) {
dao.artistsByStarredReversed(sourceId, starredOnly, search) dao.artistsByStarredReversed(sourceId, starred.ordinal, search)
} else { } else {
dao.artistsByStarred(sourceId, starredOnly, search) dao.artistsByStarred(sourceId, starred.ordinal, search)
} }
} }
} }
@@ -149,37 +150,37 @@ class LibraryRepository(
sourceId: Long, sourceId: Long,
sort: SongSort, sort: SongSort,
descending: Boolean, descending: Boolean,
starredOnly: Boolean, starred: StarredFilter,
search: String, search: String,
): Flow<PagingData<SongListItem>> = ): Flow<PagingData<SongListItem>> =
pager(60) { pager(60) {
val dao = db.libraryDao() val dao = db.libraryDao()
when (sort) { when (sort) {
SongSort.Album -> { SongSort.Album -> {
dao.songs(sourceId, starredOnly, search) dao.songs(sourceId, starred.ordinal, search)
} }
SongSort.Title -> { SongSort.Title -> {
if (descending) { if (descending) {
dao.songsByTitleReversed(sourceId, starredOnly, search) dao.songsByTitleReversed(sourceId, starred.ordinal, search)
} else { } else {
dao.songsByTitle(sourceId, starredOnly, search) dao.songsByTitle(sourceId, starred.ordinal, search)
} }
} }
SongSort.Artist -> { SongSort.Artist -> {
if (descending) { if (descending) {
dao.songsByArtistReversed(sourceId, starredOnly, search) dao.songsByArtistReversed(sourceId, starred.ordinal, search)
} else { } else {
dao.songsByArtist(sourceId, starredOnly, search) dao.songsByArtist(sourceId, starred.ordinal, search)
} }
} }
SongSort.Starred -> { SongSort.Starred -> {
if (descending) { if (descending) {
dao.songsByStarredReversed(sourceId, starredOnly, search) dao.songsByStarredReversed(sourceId, starred.ordinal, search)
} else { } else {
dao.songsByStarred(sourceId, starredOnly, search) dao.songsByStarred(sourceId, starred.ordinal, search)
} }
} }
} }
@@ -3,13 +3,16 @@ package com.subtracks.ui.components
import android.content.Context import android.content.Context
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -182,3 +185,29 @@ fun EmptyState(
) )
} }
} }
@Composable
fun FilteredEmptyState(
onClearFilters: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Filters are hiding everything.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Button(onClick = onClearFilters) {
Text("Clear filters")
}
}
}
}
@@ -27,6 +27,7 @@ import com.subtracks.data.model.Album
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds import com.subtracks.ui.theme.PrefetchArtworkSeeds
@@ -39,6 +40,8 @@ fun AlbumsContent(
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
bottomInset: Dp, bottomInset: Dp,
onAlbumClick: (Album) -> Unit, onAlbumClick: (Album) -> Unit,
filtered: Boolean = false,
onClearFilters: () -> Unit = {},
resetKey: Any? = null, resetKey: Any? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -48,7 +51,11 @@ fun AlbumsContent(
} }
items.itemCount == 0 -> { items.itemCount == 0 -> {
EmptyState("No albums yet.\nSync with your server to fill your library.", modifier) if (filtered) {
FilteredEmptyState(onClearFilters, modifier)
} else {
EmptyState("No albums yet.\nSync with your server to fill your library.", modifier)
}
} }
else -> { else -> {
@@ -27,6 +27,7 @@ import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds import com.subtracks.ui.theme.PrefetchArtworkSeeds
@@ -37,6 +38,8 @@ fun ArtistsContent(
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
bottomInset: Dp, bottomInset: Dp,
onArtistClick: (Artist) -> Unit, onArtistClick: (Artist) -> Unit,
filtered: Boolean = false,
onClearFilters: () -> Unit = {},
resetKey: Any? = null, resetKey: Any? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -46,7 +49,11 @@ fun ArtistsContent(
} }
items.itemCount == 0 -> { items.itemCount == 0 -> {
EmptyState("No artists yet.", modifier) if (filtered) {
FilteredEmptyState(onClearFilters, modifier)
} else {
EmptyState("No artists yet.", modifier)
}
} }
else -> { else -> {
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -27,18 +28,21 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.PlaylistPlay import androidx.compose.material.icons.automirrored.rounded.PlaylistPlay
import androidx.compose.material.icons.filled.Sort import androidx.compose.material.icons.filled.Sort
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Album 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.MusicNote import androidx.compose.material.icons.rounded.MusicNote
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.Search import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Sync import androidx.compose.material.icons.rounded.Sync
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -93,6 +97,7 @@ import com.subtracks.data.prefs.LibraryListTab
import com.subtracks.data.prefs.ListQuery import com.subtracks.data.prefs.ListQuery
import com.subtracks.data.prefs.PlaylistSort import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.SongSort import com.subtracks.data.prefs.SongSort
import com.subtracks.data.prefs.StarredFilter
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -190,8 +195,19 @@ fun LibraryRoute(
starredSupported = listTab.supportsStarred, starredSupported = listTab.supportsStarred,
onSortChange = { viewModel.setListQuery(listTab, listQuery.copy(sort = it)) }, onSortChange = { viewModel.setListQuery(listTab, listQuery.copy(sort = it)) },
onToggleSortDirection = { viewModel.setListQuery(listTab, listQuery.copy(descending = !listQuery.descending)) }, onToggleSortDirection = { viewModel.setListQuery(listTab, listQuery.copy(descending = !listQuery.descending)) },
onToggleStarred = { viewModel.setListQuery(listTab, listQuery.copy(starredOnly = !listQuery.starredOnly)) }, onCycleStarred = {
onClearFilters = { viewModel.setListQuery(listTab, listQuery.copy(starredOnly = false)) }, val next =
when (listQuery.starred) {
StarredFilter.Any -> StarredFilter.Starred
StarredFilter.Starred -> StarredFilter.NotStarred
StarredFilter.NotStarred -> StarredFilter.Any
}
viewModel.setListQuery(listTab, listQuery.copy(starred = next))
},
onClearFilters = {
viewModel.setListQuery(listTab, listQuery.copy(starred = StarredFilter.Any))
viewModel.setSearch(listTab, "")
},
search = search, search = search,
onSearchChange = { viewModel.setSearch(listTab, it) }, onSearchChange = { viewModel.setSearch(listTab, it) },
) )
@@ -221,7 +237,7 @@ fun LibraryScreen(
starredSupported: Boolean = false, starredSupported: Boolean = false,
onSortChange: (String) -> Unit = {}, onSortChange: (String) -> Unit = {},
onToggleSortDirection: () -> Unit = {}, onToggleSortDirection: () -> Unit = {},
onToggleStarred: () -> Unit = {}, onCycleStarred: () -> Unit = {},
onClearFilters: () -> Unit = {}, onClearFilters: () -> Unit = {},
search: String = "", search: String = "",
onSearchChange: (String) -> Unit = {}, onSearchChange: (String) -> Unit = {},
@@ -261,8 +277,9 @@ fun LibraryScreen(
val titleFraction = scrollBehavior.state.collapsedFraction val titleFraction = scrollBehavior.state.collapsedFraction
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() } val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
val listBottomInset = bottomInset + if (searchActive) 0.dp else FAB_CLEARANCE val filtersActive = listQuery.starred != StarredFilter.Any || search.isNotEmpty()
val resetKey = listOf(listQuery.sort, listQuery.descending, listQuery.starredOnly, search) val listBottomInset = bottomInset + if (searchActive) SEARCH_BAR_CLEARANCE else FAB_CLEARANCE
val resetKey = listOf(listQuery.sort, listQuery.descending, listQuery.starred, search)
Box(modifier.fillMaxSize()) { Box(modifier.fillMaxSize()) {
Column( Column(
@@ -312,11 +329,27 @@ fun LibraryScreen(
) { page -> ) { page ->
when (LibraryTab.entries[page]) { when (LibraryTab.entries[page]) {
LibraryTab.Albums -> { LibraryTab.Albums -> {
AlbumsContent(albums, coverArt, listBottomInset, onAlbumClick, resetKey = resetKey) AlbumsContent(
albums,
coverArt,
listBottomInset,
onAlbumClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
} }
LibraryTab.Artists -> { LibraryTab.Artists -> {
ArtistsContent(artists, coverArt, listBottomInset, onArtistClick, resetKey = resetKey) ArtistsContent(
artists,
coverArt,
listBottomInset,
onArtistClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
} }
LibraryTab.Songs -> { LibraryTab.Songs -> {
@@ -326,28 +359,43 @@ fun LibraryScreen(
listBottomInset, listBottomInset,
onSongClick, onSongClick,
playingSongId, playingSongId,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey, resetKey = resetKey,
) )
} }
LibraryTab.Playlists -> { LibraryTab.Playlists -> {
PlaylistsContent(playlists, coverArt, listBottomInset, onPlaylistClick, resetKey = resetKey) PlaylistsContent(
playlists,
coverArt,
listBottomInset,
onPlaylistClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
)
} }
} }
} }
}
if (searchActive) { if (searchActive) {
SearchField( SearchField(
value = search, value = search,
onValueChange = onSearchChange, onValueChange = onSearchChange,
onClose = { onClose = {
searchActive = false searchActive = false
onSearchChange("") onSearchChange("")
}, },
focusRequester = searchFocus, focusRequester = searchFocus,
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), modifier =
) Modifier
} .align(Alignment.BottomCenter)
.fillMaxWidth()
.imePadding()
.padding(horizontal = 12.dp, vertical = 8.dp),
)
} }
if (!searchActive) { if (!searchActive) {
@@ -359,7 +407,7 @@ fun LibraryScreen(
) { ) {
Box { Box {
Icon(Icons.Filled.Sort, contentDescription = "List options") Icon(Icons.Filled.Sort, contentDescription = "List options")
if (listQuery.starredOnly) { if (listQuery.starred != StarredFilter.Any) {
Box( Box(
modifier = modifier =
Modifier Modifier
@@ -383,7 +431,7 @@ fun LibraryScreen(
starredSupported = starredSupported, starredSupported = starredSupported,
onSortChange = onSortChange, onSortChange = onSortChange,
onToggleSortDirection = onToggleSortDirection, onToggleSortDirection = onToggleSortDirection,
onToggleStarred = onToggleStarred, onCycleStarred = onCycleStarred,
onClearFilters = onClearFilters, onClearFilters = onClearFilters,
onSearch = { onSearch = {
showOptions = false showOptions = false
@@ -402,7 +450,7 @@ private fun ListOptionsSheet(
starredSupported: Boolean, starredSupported: Boolean,
onSortChange: (String) -> Unit, onSortChange: (String) -> Unit,
onToggleSortDirection: () -> Unit, onToggleSortDirection: () -> Unit,
onToggleStarred: () -> Unit, onCycleStarred: () -> Unit,
onClearFilters: () -> Unit, onClearFilters: () -> Unit,
onSearch: () -> Unit, onSearch: () -> Unit,
) { ) {
@@ -456,12 +504,38 @@ private fun ListOptionsSheet(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) { ) {
FilterChip( FilterChip(
selected = listQuery.starredOnly, selected = listQuery.starred != StarredFilter.Any,
onClick = onToggleStarred, onClick = onCycleStarred,
label = { Text("Starred") }, label = { Text("Starred") },
leadingIcon =
when (listQuery.starred) {
StarredFilter.Any -> {
null
}
StarredFilter.Starred -> {
{
Icon(
Icons.Rounded.Add,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize),
)
}
}
StarredFilter.NotStarred -> {
{
Icon(
Icons.Rounded.Remove,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize),
)
}
}
},
) )
if (listQuery.starredOnly) { if (listQuery.starred != StarredFilter.Any) {
TextButton(onClick = onClearFilters) { Text("Clear") } TextButton(onClick = onClearFilters) { Text("Clear filters") }
} }
} }
} }
@@ -469,6 +543,7 @@ private fun ListOptionsSheet(
} }
private val FAB_CLEARANCE = 80.dp private val FAB_CLEARANCE = 80.dp
private val SEARCH_BAR_CLEARANCE = 80.dp
@Composable @Composable
private fun SearchField( private fun SearchField(
@@ -59,7 +59,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.starredOnly, search) libraryRepository.albums(sourceId, query.albumSort(), query.descending, query.starred, search)
} }
}.cachedIn(viewModelScope) }.cachedIn(viewModelScope)
@@ -70,7 +70,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.starredOnly, search) libraryRepository.artists(sourceId, query.artistSort(), query.descending, query.starred, search)
} }
}.cachedIn(viewModelScope) }.cachedIn(viewModelScope)
@@ -92,7 +92,7 @@ class LibraryViewModel(
combine(listQueries.getValue(LibraryListTab.Songs), searches.getValue(LibraryListTab.Songs)) { query, search -> combine(listQueries.getValue(LibraryListTab.Songs), searches.getValue(LibraryListTab.Songs)) { query, search ->
query to search query to search
}.flatMapLatest { (query, search) -> }.flatMapLatest { (query, search) ->
libraryRepository.songs(sourceId, query.songSort(), query.descending, query.starredOnly, search) libraryRepository.songs(sourceId, query.songSort(), query.descending, query.starred, search)
} }
}.cachedIn(viewModelScope) }.cachedIn(viewModelScope)
@@ -27,6 +27,7 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
@@ -36,6 +37,8 @@ fun PlaylistsContent(
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
bottomInset: Dp, bottomInset: Dp,
onPlaylistClick: (Playlist) -> Unit, onPlaylistClick: (Playlist) -> Unit,
filtered: Boolean = false,
onClearFilters: () -> Unit = {},
resetKey: Any? = null, resetKey: Any? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -45,7 +48,11 @@ fun PlaylistsContent(
} }
items.itemCount == 0 -> { items.itemCount == 0 -> {
EmptyState("No playlists yet.", modifier) if (filtered) {
FilteredEmptyState(onClearFilters, modifier)
} else {
EmptyState("No playlists yet.", modifier)
}
} }
else -> { else -> {
@@ -38,6 +38,7 @@ import com.subtracks.data.model.Song
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
@@ -48,6 +49,8 @@ fun SongsContent(
bottomInset: Dp, bottomInset: Dp,
onSongClick: (Int) -> Unit, onSongClick: (Int) -> Unit,
playingSongId: String? = null, playingSongId: String? = null,
filtered: Boolean = false,
onClearFilters: () -> Unit = {},
resetKey: Any? = null, resetKey: Any? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -57,7 +60,11 @@ fun SongsContent(
} }
items.itemCount == 0 -> { items.itemCount == 0 -> {
EmptyState("No songs yet.", modifier) if (filtered) {
FilteredEmptyState(onClearFilters, modifier)
} else {
EmptyState("No songs yet.", modifier)
}
} }
else -> { else -> {
@@ -50,17 +50,17 @@ class LibraryDaoTest {
assertEquals( assertEquals(
listOf("Alpha", "Beta", "Gamma"), listOf("Alpha", "Beta", "Gamma"),
dao.albumsByName(sourceId, starredOnly = false, search = "").page().map { it.name }, dao.albumsByName(sourceId, starredFilter = 0, search = "").page().map { it.name },
) )
assertEquals( assertEquals(
listOf("Gamma", "Beta", "Alpha"), listOf("Gamma", "Beta", "Alpha"),
dao.albumsByNameReversed(sourceId, starredOnly = false, search = "").page().map { it.name }, dao.albumsByNameReversed(sourceId, starredFilter = 0, search = "").page().map { it.name },
) )
assertEquals( assertEquals(
listOf("Alpha", "Beta", "Gamma"), listOf("Alpha", "Beta", "Gamma"),
dao.albumsByYear(sourceId, starredOnly = false, search = "").page().map { it.name }, dao.albumsByYear(sourceId, starredFilter = 0, search = "").page().map { it.name },
) )
assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, starredOnly = true, search = "").page().map { it.name }) assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, starredFilter = 1, search = "").page().map { it.name })
} }
@Test @Test
@@ -77,13 +77,13 @@ class LibraryDaoTest {
assertEquals( assertEquals(
listOf("Apple", "Zebra"), listOf("Apple", "Zebra"),
dao.songsByTitle(sourceId, starredOnly = false, search = "").page().map { it.song.title }, dao.songsByTitle(sourceId, starredFilter = 0, search = "").page().map { it.song.title },
) )
assertEquals( assertEquals(
listOf("Zebra", "Apple"), listOf("Zebra", "Apple"),
dao.songsByTitleReversed(sourceId, starredOnly = false, search = "").page().map { it.song.title }, dao.songsByTitleReversed(sourceId, starredFilter = 0, search = "").page().map { it.song.title },
) )
assertEquals(listOf("Apple"), dao.songsByTitle(sourceId, starredOnly = true, search = "").page().map { it.song.title }) assertEquals(listOf("Apple"), dao.songsByTitle(sourceId, starredFilter = 1, search = "").page().map { it.song.title })
} }
@Test @Test
@@ -101,13 +101,14 @@ class LibraryDaoTest {
assertEquals( assertEquals(
listOf("Beta", "Gamma", "Alpha"), listOf("Beta", "Gamma", "Alpha"),
dao.albumsByRecentlyAdded(sourceId, starredOnly = false, search = "").page().map { it.name }, dao.albumsByRecentlyAdded(sourceId, starredFilter = 0, search = "").page().map { it.name },
) )
assertEquals( assertEquals(
listOf("Alpha", "Gamma", "Beta"), listOf("Alpha", "Gamma", "Beta"),
dao.albumsByStarred(sourceId, starredOnly = false, search = "").page().map { it.name }, dao.albumsByStarred(sourceId, starredFilter = 0, search = "").page().map { it.name },
) )
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, starredOnly = false, search = "gam").page().map { it.name }) assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, starredFilter = 0, search = "gam").page().map { it.name })
assertEquals(listOf("Beta"), dao.albumsByName(sourceId, starredFilter = 2, search = "").page().map { it.name })
} }
private suspend fun source(): Long = private suspend fun source(): Long =
@@ -23,9 +23,12 @@ class UserPreferencesTest {
assertEquals(ListQuery("Name"), prefs.listQuery(LibraryListTab.Albums).first()) assertEquals(ListQuery("Name"), prefs.listQuery(LibraryListTab.Albums).first())
assertEquals(ListQuery("Album"), prefs.listQuery(LibraryListTab.Songs).first()) assertEquals(ListQuery("Album"), prefs.listQuery(LibraryListTab.Songs).first())
prefs.setListQuery(LibraryListTab.Albums, ListQuery("Added", descending = true, starredOnly = true)) prefs.setListQuery(LibraryListTab.Albums, ListQuery("Added", descending = true, starred = StarredFilter.Starred))
assertEquals(ListQuery("Added", descending = true, starredOnly = true), prefs.listQuery(LibraryListTab.Albums).first()) assertEquals(
ListQuery("Added", descending = true, starred = StarredFilter.Starred),
prefs.listQuery(LibraryListTab.Albums).first(),
)
assertEquals(ListQuery("Album"), prefs.listQuery(LibraryListTab.Songs).first()) assertEquals(ListQuery("Album"), prefs.listQuery(LibraryListTab.Songs).first())
file.delete() file.delete()
@@ -60,7 +60,7 @@ class SyncServiceTest {
2, 2,
db db
.libraryDao() .libraryDao()
.artistsByName(1, false, "") .artistsByName(1, 0, "")
.allRows() .allRows()
.size, .size,
) )
@@ -68,7 +68,7 @@ class SyncServiceTest {
2, 2,
db db
.libraryDao() .libraryDao()
.songs(1, false, "") .songs(1, 0, "")
.allRows() .allRows()
.size, .size,
) )
@@ -90,7 +90,7 @@ class SyncServiceTest {
listOf("a1"), listOf("a1"),
db db
.libraryDao() .libraryDao()
.artistsByName(1, false, "") .artistsByName(1, 0, "")
.allRows() .allRows()
.map { it.id }, .map { it.id },
) )
@@ -98,7 +98,7 @@ class SyncServiceTest {
listOf("s1"), listOf("s1"),
db db
.libraryDao() .libraryDao()
.songs(1, false, "") .songs(1, 0, "")
.allRows() .allRows()
.map { it.song.id }, .map { it.song.id },
) )
@@ -200,7 +200,7 @@ class SyncServiceTest {
"Old", "Old",
db db
.libraryDao() .libraryDao()
.artistsByName(1, false, "") .artistsByName(1, 0, "")
.allRows() .allRows()
.single() .single()
.name, .name,
@@ -213,7 +213,7 @@ class SyncServiceTest {
"New", "New",
db db
.libraryDao() .libraryDao()
.artistsByName(1, false, "") .artistsByName(1, 0, "")
.allRows() .allRows()
.single() .single()
.name, .name,
@@ -0,0 +1,121 @@
package com.subtracks.ui
import androidx.activity.ComponentActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.SongListItem
import com.subtracks.data.prefs.ListQuery
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.ui.library.ALBUM_COVER_TAG
import com.subtracks.ui.library.LibraryScreen
import com.subtracks.ui.library.LibraryTab
import com.subtracks.ui.library.sortOptionsFor
import com.subtracks.ui.theme.SubtracksTheme
import kotlinx.coroutines.flow.flowOf
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
class LibraryOptionsTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun theOptionsSheetAppliesSortFilterAndClear() {
var query by mutableStateOf(ListQuery("Name"))
composeRule.setContent {
SubtracksTheme {
LibraryScreen(
selectedTab = LibraryTab.Albums,
onTabSelected = {},
albums = remember { flowOf(PagingData.from(albums())) }.collectAsLazyPagingItems(),
artists = remember { flowOf(PagingData.empty<Artist>()) }.collectAsLazyPagingItems(),
songs = remember { flowOf(PagingData.empty<SongListItem>()) }.collectAsLazyPagingItems(),
playlists = remember { flowOf(PagingData.empty<Playlist>()) }.collectAsLazyPagingItems(),
coverArt = { _, _ -> null },
onAlbumClick = {},
onArtistClick = {},
onPlaylistClick = {},
onSongClick = {},
onSync = {},
onOpenSettings = {},
listQuery = query,
sortOptions = sortOptionsFor(LibraryTab.Albums),
starredSupported = true,
onSortChange = { query = query.copy(sort = it) },
onCycleStarred = {
query =
query.copy(
starred =
when (query.starred) {
StarredFilter.Any -> StarredFilter.Starred
StarredFilter.Starred -> StarredFilter.NotStarred
StarredFilter.NotStarred -> StarredFilter.Any
},
)
},
onClearFilters = { query = query.copy(starred = StarredFilter.Any) },
)
}
}
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithTag(ALBUM_COVER_TAG).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithContentDescription("List options").performClick()
composeRule.mainClock.advanceTimeBy(1_000)
composeRule.waitForIdle()
composeRule.onNodeWithText("Added").performClick()
composeRule.waitForIdle()
assertEquals("Added", query.sort)
composeRule.onAllNodesWithText("Starred")[1].performClick()
composeRule.waitForIdle()
assertEquals(StarredFilter.Starred, query.starred)
composeRule.onNodeWithText("Clear filters").performClick()
composeRule.waitForIdle()
assertEquals(StarredFilter.Any, query.starred)
}
private fun albums() =
(1..3).map { index ->
Album(
sourceId = 1,
id = "al-$index",
artistId = "ar-1",
name = "Album $index",
albumArtist = "Artist",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
frequentRank = null,
recentRank = null,
)
}
}