add sort, filters and search to the library

This commit is contained in:
austinried
2026-10-04 00:29:55 +00:00
parent 120d180220
commit 9a99977342
13 changed files with 1127 additions and 96 deletions
@@ -89,26 +89,181 @@ interface LibraryDao {
position: Long, position: Long,
) )
@Query("SELECT * FROM artists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE") @Query(
fun artists(sourceId: Long): PagingSource<Int, Artist> "SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
"ORDER BY name COLLATE NOCASE",
)
fun albumsByName(
sourceId: Long,
starredOnly: Boolean,
): PagingSource<Int, Album>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE") @Query(
fun albumsByName(sourceId: Long): PagingSource<Int, Album> "SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
"ORDER BY name COLLATE NOCASE DESC",
)
fun albumsByNameReversed(
sourceId: Long,
starredOnly: Boolean,
): PagingSource<Int, Album>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY albumArtist COLLATE NOCASE, year, name COLLATE NOCASE") @Query(
fun albumsByArtist(sourceId: Long): PagingSource<Int, Album> "SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
"ORDER BY albumArtist COLLATE NOCASE, year, name COLLATE NOCASE",
)
fun albumsByArtist(
sourceId: Long,
starredOnly: Boolean,
): PagingSource<Int, Album>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY year DESC, name COLLATE NOCASE") @Query(
fun albumsByYear(sourceId: Long): PagingSource<Int, Album> "SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
"ORDER BY albumArtist COLLATE NOCASE DESC, year DESC, name COLLATE NOCASE DESC",
)
fun albumsByArtistReversed(
sourceId: Long,
starredOnly: Boolean,
): PagingSource<Int, Album>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE") @Query(
fun albumsByRecentlyAdded(sourceId: Long): PagingSource<Int, Album> "SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
"ORDER BY year DESC, name COLLATE NOCASE",
)
fun albumsByYear(
sourceId: Long,
starredOnly: Boolean,
): PagingSource<Int, Album>
@Query(SONGS_SQL) @Query(
fun songs(sourceId: Long): PagingSource<Int, SongListItem> "SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
"ORDER BY year ASC, name COLLATE NOCASE",
)
fun albumsByYearReversed(
sourceId: Long,
starredOnly: Boolean,
): PagingSource<Int, Album>
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
"ORDER BY created DESC, name COLLATE NOCASE",
)
fun albumsByRecentlyAdded(
sourceId: Long,
starredOnly: Boolean,
): PagingSource<Int, Album>
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
"ORDER BY created ASC, name COLLATE NOCASE",
)
fun albumsByRecentlyAddedReversed(
sourceId: Long,
starredOnly: Boolean,
): PagingSource<Int, Album>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
"ORDER BY name COLLATE NOCASE",
)
fun artistsByName(
sourceId: Long,
starredOnly: Boolean,
): PagingSource<Int, Artist>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
"ORDER BY name COLLATE NOCASE DESC",
)
fun artistsByNameReversed(
sourceId: Long,
starredOnly: Boolean,
): PagingSource<Int, Artist>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
"ORDER BY albumCount DESC, name COLLATE NOCASE",
)
fun artistsByAlbumCount(
sourceId: Long,
starredOnly: Boolean,
): PagingSource<Int, Artist>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
"ORDER BY albumCount ASC, name COLLATE NOCASE",
)
fun artistsByAlbumCountReversed(
sourceId: Long,
starredOnly: Boolean,
): PagingSource<Int, Artist>
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE") @Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE")
fun playlists(sourceId: Long): PagingSource<Int, Playlist> fun playlistsByName(sourceId: Long): PagingSource<Int, Playlist>
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE DESC")
fun playlistsByNameReversed(sourceId: Long): PagingSource<Int, Playlist>
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE")
fun playlistsByRecentlyAdded(sourceId: Long): PagingSource<Int, Playlist>
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY created ASC, name COLLATE NOCASE")
fun playlistsByRecentlyAddedReversed(sourceId: Long): PagingSource<Int, Playlist>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"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) " +
"ORDER BY albums.albumArtist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, " +
"songs.title COLLATE NOCASE, songs.id",
)
fun songs(
sourceId: Long,
starredOnly: Boolean,
): PagingSource<Int, SongListItem>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"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) " +
"ORDER BY songs.title COLLATE NOCASE, songs.id",
)
fun songsByTitle(
sourceId: Long,
starredOnly: Boolean,
): PagingSource<Int, SongListItem>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"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) " +
"ORDER BY songs.title COLLATE NOCASE DESC, songs.id",
)
fun songsByTitleReversed(
sourceId: Long,
starredOnly: Boolean,
): PagingSource<Int, SongListItem>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"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) " +
"ORDER BY songs.artist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id",
)
fun songsByArtist(
sourceId: Long,
starredOnly: Boolean,
): PagingSource<Int, SongListItem>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"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) " +
"ORDER BY songs.artist COLLATE NOCASE DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id",
)
fun songsByArtistReversed(
sourceId: Long,
starredOnly: Boolean,
): PagingSource<Int, SongListItem>
@Query(PLAYLIST_SONGS_SQL) @Query(PLAYLIST_SONGS_SQL)
fun playlistSongs( fun playlistSongs(
@@ -12,18 +12,41 @@ import kotlinx.coroutines.flow.map
enum class AlbumSort { Name, Artist, Year, RecentlyAdded } enum class AlbumSort { Name, Artist, Year, RecentlyAdded }
enum class ArtistSort { Name, AlbumCount }
enum class PlaylistSort { Name, RecentlyAdded }
enum class SongSort { Album, Title, Artist }
enum class LibraryListTab(
val key: String,
val defaultSort: String,
val supportsStarred: Boolean,
) {
Albums("albums", AlbumSort.Name.name, true),
Artists("artists", ArtistSort.Name.name, true),
Playlists("playlists", PlaylistSort.Name.name, false),
Songs("songs", SongSort.Album.name, true),
}
data class ListQuery(
val sort: String,
val descending: Boolean = false,
val starredOnly: Boolean = false,
)
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs") private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
class UserPreferences( class UserPreferences(
private val store: DataStore<Preferences>, private val store: DataStore<Preferences>,
) { ) {
val albumSort: Flow<AlbumSort> = fun listQuery(tab: LibraryListTab): Flow<ListQuery> = store.data.map { prefs -> decode(prefs[listQueryKey(tab)], tab.defaultSort) }
store.data.map { prefs ->
prefs[ALBUM_SORT]?.let { stored -> AlbumSort.entries.firstOrNull { it.name == stored } } ?: AlbumSort.Name
}
suspend fun setAlbumSort(sort: AlbumSort) { suspend fun setListQuery(
store.edit { prefs -> prefs[ALBUM_SORT] = sort.name } tab: LibraryListTab,
query: ListQuery,
) {
store.edit { prefs -> prefs[listQueryKey(tab)] = encode(query) }
} }
val maxBitrate: Flow<Int> = store.data.map { prefs -> prefs[MAX_BITRATE] ?: 0 } val maxBitrate: Flow<Int> = store.data.map { prefs -> prefs[MAX_BITRATE] ?: 0 }
@@ -40,8 +63,24 @@ class UserPreferences(
} }
} }
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 decode(
stored: String?,
defaultSort: String,
): ListQuery {
val parts = stored?.split("|")
if (parts == null || parts.size != 3) return ListQuery(defaultSort)
return ListQuery(
sort = parts[0].ifEmpty { defaultSort },
descending = parts[1] == "1",
starredOnly = parts[2] == "1",
)
}
private companion object { private companion object {
val ALBUM_SORT = stringPreferencesKey("album_sort")
val MAX_BITRATE = intPreferencesKey("max_bitrate") val MAX_BITRATE = intPreferencesKey("max_bitrate")
val STREAM_FORMAT = stringPreferencesKey("stream_format") val STREAM_FORMAT = stringPreferencesKey("stream_format")
} }
@@ -3,6 +3,7 @@ package com.subtracks.data.repo
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.PagingSource
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
@@ -10,8 +11,19 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
import com.subtracks.data.prefs.AlbumSort import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.SongSort
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
data class SearchHit(
val type: String,
val itemId: String,
val title: String,
) {
val isSong: Boolean get() = type == "song"
}
class LibraryRepository( class LibraryRepository(
private val db: SubtracksDatabase, private val db: SubtracksDatabase,
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
@@ -21,30 +33,107 @@ class LibraryRepository(
fun albums( fun albums(
sourceId: Long, sourceId: Long,
sort: AlbumSort, sort: AlbumSort,
descending: Boolean,
starredOnly: Boolean,
): Flow<PagingData<Album>> = ): Flow<PagingData<Album>> =
Pager(PagingConfig(pageSize = 40, enablePlaceholders = false)) { pager(40) {
val dao = db.libraryDao()
when (sort) { when (sort) {
AlbumSort.Name -> db.libraryDao().albumsByName(sourceId) AlbumSort.Name -> {
AlbumSort.Artist -> db.libraryDao().albumsByArtist(sourceId) if (descending) dao.albumsByNameReversed(sourceId, starredOnly) else dao.albumsByName(sourceId, starredOnly)
AlbumSort.Year -> db.libraryDao().albumsByYear(sourceId) }
AlbumSort.RecentlyAdded -> db.libraryDao().albumsByRecentlyAdded(sourceId)
AlbumSort.Artist -> {
if (descending) dao.albumsByArtistReversed(sourceId, starredOnly) else dao.albumsByArtist(sourceId, starredOnly)
}
AlbumSort.Year -> {
if (descending) dao.albumsByYearReversed(sourceId, starredOnly) else dao.albumsByYear(sourceId, starredOnly)
}
AlbumSort.RecentlyAdded -> {
if (descending) {
dao.albumsByRecentlyAddedReversed(sourceId, starredOnly)
} else {
dao.albumsByRecentlyAdded(sourceId, starredOnly)
}
}
} }
}.flow }
fun artists(sourceId: Long): Flow<PagingData<Artist>> = fun artists(
Pager(PagingConfig(pageSize = 60, enablePlaceholders = false)) { sourceId: Long,
db.libraryDao().artists(sourceId) sort: ArtistSort,
}.flow descending: Boolean,
starredOnly: Boolean,
): Flow<PagingData<Artist>> =
pager(60) {
val dao = db.libraryDao()
when (sort) {
ArtistSort.Name -> {
if (descending) dao.artistsByNameReversed(sourceId, starredOnly) else dao.artistsByName(sourceId, starredOnly)
}
fun songs(sourceId: Long): Flow<PagingData<SongListItem>> = ArtistSort.AlbumCount -> {
Pager(PagingConfig(pageSize = 60, enablePlaceholders = false)) { if (descending) {
db.libraryDao().songs(sourceId) dao.artistsByAlbumCountReversed(sourceId, starredOnly)
}.flow } else {
dao.artistsByAlbumCount(sourceId, starredOnly)
}
}
}
}
fun playlists(sourceId: Long): Flow<PagingData<Playlist>> = fun playlists(
Pager(PagingConfig(pageSize = 40, enablePlaceholders = false)) { sourceId: Long,
db.libraryDao().playlists(sourceId) sort: PlaylistSort,
}.flow descending: Boolean,
): Flow<PagingData<Playlist>> =
pager(40) {
val dao = db.libraryDao()
when (sort) {
PlaylistSort.Name -> {
if (descending) dao.playlistsByNameReversed(sourceId) else dao.playlistsByName(sourceId)
}
PlaylistSort.RecentlyAdded -> {
if (descending) dao.playlistsByRecentlyAddedReversed(sourceId) else dao.playlistsByRecentlyAdded(sourceId)
}
}
}
fun songs(
sourceId: Long,
sort: SongSort,
descending: Boolean,
starredOnly: Boolean,
): Flow<PagingData<SongListItem>> =
pager(60) {
val dao = db.libraryDao()
when (sort) {
SongSort.Album -> {
dao.songs(sourceId, starredOnly)
}
SongSort.Title -> {
if (descending) dao.songsByTitleReversed(sourceId, starredOnly) else dao.songsByTitle(sourceId, starredOnly)
}
SongSort.Artist -> {
if (descending) dao.songsByArtistReversed(sourceId, starredOnly) else dao.songsByArtist(sourceId, starredOnly)
}
}
}
suspend fun search(
sourceId: Long,
query: String,
limit: Int = 50,
): List<SearchHit> =
db
.searchDao()
.search(sourceId.toString(), query, limit)
.map { SearchHit(it.type, it.itemId, it.title) }
fun album( fun album(
sourceId: Long, sourceId: Long,
@@ -74,8 +163,14 @@ class LibraryRepository(
fun playlistSongs( fun playlistSongs(
sourceId: Long, sourceId: Long,
playlistId: String, playlistId: String,
): Flow<PagingData<SongListItem>> = ): Flow<PagingData<SongListItem>> = pager(60) { db.libraryDao().playlistSongs(sourceId, playlistId) }
Pager(PagingConfig(pageSize = 60, enablePlaceholders = false)) {
db.libraryDao().playlistSongs(sourceId, playlistId) private fun <T : Any> pager(
}.flow pageSize: Int,
source: () -> PagingSource<Int, T>,
): Flow<PagingData<T>> =
Pager(
config = PagingConfig(pageSize = pageSize, enablePlaceholders = false),
pagingSourceFactory = source,
).flow
} }
@@ -17,6 +17,7 @@ import com.subtracks.ui.library.AlbumDetailViewModel
import com.subtracks.ui.library.ArtistDetailViewModel import com.subtracks.ui.library.ArtistDetailViewModel
import com.subtracks.ui.library.LibraryViewModel import com.subtracks.ui.library.LibraryViewModel
import com.subtracks.ui.library.PlaylistDetailViewModel import com.subtracks.ui.library.PlaylistDetailViewModel
import com.subtracks.ui.library.SearchViewModel
import com.subtracks.ui.playback.QueueViewModel import com.subtracks.ui.playback.QueueViewModel
import com.subtracks.ui.settings.AddSourceViewModel import com.subtracks.ui.settings.AddSourceViewModel
import com.subtracks.ui.settings.SettingsViewModel import com.subtracks.ui.settings.SettingsViewModel
@@ -40,6 +41,7 @@ fun appModule(
single { PlaybackController(get(), get(), get()) } single { PlaybackController(get(), get(), get()) }
viewModel { RootViewModel(get()) } viewModel { RootViewModel(get()) }
viewModel { LibraryViewModel(get(), get(), get(), get(), get()) } viewModel { LibraryViewModel(get(), get(), get(), get(), get()) }
viewModel { SearchViewModel(get(), get(), get()) }
viewModel { SettingsViewModel(get(), get()) } viewModel { SettingsViewModel(get(), get()) }
viewModel { AddSourceViewModel(get(), get()) } viewModel { AddSourceViewModel(get(), get()) }
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) } viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) }
@@ -192,6 +192,11 @@ class PlaybackController(
startPosition: Long, startPosition: Long,
) = play(listOf(queueRepository.songsEntry(sourceId)), startPosition) ) = play(listOf(queueRepository.songsEntry(sourceId)), startPosition)
fun playSong(
sourceId: Long,
songId: String,
) = play(listOf(queueRepository.songEntry(sourceId, songId)), 0)
fun shuffleAlbum( fun shuffleAlbum(
sourceId: Long, sourceId: Long,
albumId: String, albumId: String,
@@ -50,6 +50,7 @@ import com.subtracks.ui.library.AlbumDetailRoute
import com.subtracks.ui.library.ArtistDetailRoute import com.subtracks.ui.library.ArtistDetailRoute
import com.subtracks.ui.library.LibraryRoute import com.subtracks.ui.library.LibraryRoute
import com.subtracks.ui.library.PlaylistDetailRoute import com.subtracks.ui.library.PlaylistDetailRoute
import com.subtracks.ui.library.SearchRoute
import com.subtracks.ui.playback.MiniPlayer import com.subtracks.ui.playback.MiniPlayer
import com.subtracks.ui.playback.NowPlayingRoute import com.subtracks.ui.playback.NowPlayingRoute
import com.subtracks.ui.playback.QueueRoute import com.subtracks.ui.playback.QueueRoute
@@ -81,6 +82,7 @@ private const val FLING_VELOCITY = 1000f
private object Routes { private object Routes {
const val LIBRARY = "library" const val LIBRARY = "library"
const val SEARCH = "search"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val ADD_SERVER = "add-server" const val ADD_SERVER = "add-server"
const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}" const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}"
@@ -184,9 +186,18 @@ private fun MainNavigation() {
onArtistClick = { artist -> navController.navigate(Routes.artist(artist.id, artist.coverArt)) }, onArtistClick = { artist -> navController.navigate(Routes.artist(artist.id, artist.coverArt)) },
onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) }, onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) },
onOpenSettings = { navController.navigate(Routes.SETTINGS) }, onOpenSettings = { navController.navigate(Routes.SETTINGS) },
onSearch = { navController.navigate(Routes.SEARCH) },
bottomInset = if (playerVisible) 0.dp else navBarInset, bottomInset = if (playerVisible) 0.dp else navBarInset,
) )
} }
composable(Routes.SEARCH) {
SearchRoute(
onBack = { navController.popBackStack() },
onAlbumClick = { navController.navigate(Routes.album(it)) },
onArtistClick = { navController.navigate(Routes.artist(it)) },
onPlaylistClick = { navController.navigate(Routes.playlist(it)) },
)
}
composable(Routes.SETTINGS) { composable(Routes.SETTINGS) {
SettingsRoute( SettingsRoute(
onAddServer = { navController.navigate(Routes.ADD_SERVER) }, onAddServer = { navController.navigate(Routes.ADD_SERVER) },
@@ -5,6 +5,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
@@ -21,20 +22,31 @@ import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.rounded.Album import androidx.compose.material.icons.rounded.Album
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.Search
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Sort
import androidx.compose.material.icons.rounded.SwapVert
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.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -51,6 +63,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
@@ -68,6 +81,12 @@ import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.LibraryListTab
import com.subtracks.data.prefs.ListQuery
import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.SongSort
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
@@ -82,6 +101,47 @@ enum class LibraryTab(
Songs("Songs", Icons.Rounded.MusicNote), Songs("Songs", Icons.Rounded.MusicNote),
} }
data class SortOption(
val value: String,
val label: String,
)
fun LibraryTab.listTab(): LibraryListTab = LibraryListTab.valueOf(name)
fun sortOptionsFor(tab: LibraryTab): List<SortOption> =
when (tab) {
LibraryTab.Albums -> {
listOf(
SortOption(AlbumSort.Name.name, "Name"),
SortOption(AlbumSort.Artist.name, "Artist"),
SortOption(AlbumSort.Year.name, "Year"),
SortOption(AlbumSort.RecentlyAdded.name, "Recently added"),
)
}
LibraryTab.Artists -> {
listOf(
SortOption(ArtistSort.Name.name, "Name"),
SortOption(ArtistSort.AlbumCount.name, "Albums"),
)
}
LibraryTab.Playlists -> {
listOf(
SortOption(PlaylistSort.Name.name, "Name"),
SortOption(PlaylistSort.RecentlyAdded.name, "Recently added"),
)
}
LibraryTab.Songs -> {
listOf(
SortOption(SongSort.Album.name, "Album"),
SortOption(SongSort.Title.name, "Title"),
SortOption(SongSort.Artist.name, "Artist"),
)
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LibraryRoute( fun LibraryRoute(
@@ -89,12 +149,15 @@ fun LibraryRoute(
onArtistClick: (Artist) -> Unit, onArtistClick: (Artist) -> Unit,
onPlaylistClick: (Playlist) -> Unit, onPlaylistClick: (Playlist) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onSearch: () -> Unit,
bottomInset: Dp, bottomInset: Dp,
viewModel: LibraryViewModel = koinViewModel(), viewModel: LibraryViewModel = koinViewModel(),
) { ) {
var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) } var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
val syncing by viewModel.syncing.collectAsStateWithLifecycle() val syncing by viewModel.syncing.collectAsStateWithLifecycle()
val playingSongId by viewModel.playingSongId.collectAsStateWithLifecycle() val playingSongId by viewModel.playingSongId.collectAsStateWithLifecycle()
val listQuery by viewModel.listQuery(selectedTab.listTab()).collectAsStateWithLifecycle()
val listTab = selectedTab.listTab()
LibraryScreen( LibraryScreen(
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = { selectedTab = it }, onTabSelected = { selectedTab = it },
@@ -112,6 +175,14 @@ fun LibraryRoute(
onSync = viewModel::sync, onSync = viewModel::sync,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
playingSongId = playingSongId, playingSongId = playingSongId,
listQuery = listQuery,
sortOptions = sortOptionsFor(selectedTab),
starredSupported = listTab.supportsStarred,
onSortChange = { viewModel.setListQuery(listTab, listQuery.copy(sort = it)) },
onToggleSortDirection = { viewModel.setListQuery(listTab, listQuery.copy(descending = !listQuery.descending)) },
onToggleStarred = { viewModel.setListQuery(listTab, listQuery.copy(starredOnly = !listQuery.starredOnly)) },
onClearFilters = { viewModel.setListQuery(listTab, listQuery.copy(starredOnly = false)) },
onSearch = onSearch,
) )
} }
@@ -134,6 +205,14 @@ fun LibraryScreen(
syncing: Boolean = false, syncing: Boolean = false,
playingSongId: String? = null, playingSongId: String? = null,
bottomInset: Dp = 0.dp, bottomInset: Dp = 0.dp,
listQuery: ListQuery = ListQuery(""),
sortOptions: List<SortOption> = emptyList(),
starredSupported: Boolean = false,
onSortChange: (String) -> Unit = {},
onToggleSortDirection: () -> Unit = {},
onToggleStarred: () -> Unit = {},
onClearFilters: () -> Unit = {},
onSearch: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val pagerState = val pagerState =
@@ -141,6 +220,7 @@ fun LibraryScreen(
initialPage = selectedTab.ordinal, initialPage = selectedTab.ordinal,
pageCount = { LibraryTab.entries.size }, pageCount = { LibraryTab.entries.size },
) )
var showOptions by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(pagerState) { LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page -> snapshotFlow { pagerState.currentPage }.collect { page ->
@@ -167,62 +247,176 @@ 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 + FAB_CLEARANCE
Column( Box(modifier.fillMaxSize()) {
modifier =
modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.nestedScroll(scrollBehavior.nestedScrollConnection),
) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxSize()
.padding(top = statusBarTop), .background(MaterialTheme.colorScheme.background)
.nestedScroll(scrollBehavior.nestedScrollConnection),
) { ) {
Box( Column(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(titleHeight * (1f - titleFraction)) .padding(top = statusBarTop),
.clipToBounds(),
) { ) {
Text( Box(
text = selectedTab.label,
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
modifier = modifier =
Modifier Modifier
.offset(y = -(titleHeight * titleFraction)) .fillMaxWidth()
.wrapContentHeight(unbounded = true) .height(titleHeight * (1f - titleFraction))
.padding(start = 12.dp, end = 16.dp, top = titleTop), .clipToBounds(),
) {
Text(
text = selectedTab.label,
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
modifier =
Modifier
.offset(y = -(titleHeight * titleFraction))
.wrapContentHeight(unbounded = true)
.padding(start = 12.dp, end = 16.dp, top = titleTop),
)
}
LibraryTabs(
pagerState = pagerState,
onTabSelected = onTabSelected,
syncing = syncing,
onSync = onSync,
onOpenSettings = onOpenSettings,
) )
} }
LibraryTabs(
pagerState = pagerState, HorizontalPager(
onTabSelected = onTabSelected, state = pagerState,
syncing = syncing, modifier = Modifier.fillMaxWidth().weight(1f),
onSync = onSync, ) { page ->
onOpenSettings = onOpenSettings, when (LibraryTab.entries[page]) {
) LibraryTab.Albums -> AlbumsContent(albums, coverArt, listBottomInset, onAlbumClick)
LibraryTab.Artists -> ArtistsContent(artists, coverArt, listBottomInset, onArtistClick)
LibraryTab.Songs -> SongsContent(songs, coverArt, listBottomInset, onSongClick, playingSongId)
LibraryTab.Playlists -> PlaylistsContent(playlists, coverArt, listBottomInset, onPlaylistClick)
}
}
} }
HorizontalPager( FloatingActionButton(
state = pagerState, onClick = { showOptions = true },
modifier = Modifier.fillMaxWidth().weight(1f), modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
) { page -> ) {
when (LibraryTab.entries[page]) { Box {
LibraryTab.Albums -> AlbumsContent(albums, coverArt, bottomInset, onAlbumClick) Icon(Icons.Rounded.Sort, contentDescription = "List options")
LibraryTab.Artists -> ArtistsContent(artists, coverArt, bottomInset, onArtistClick) if (listQuery.starredOnly) {
LibraryTab.Songs -> SongsContent(songs, coverArt, bottomInset, onSongClick, playingSongId) Box(
LibraryTab.Playlists -> PlaylistsContent(playlists, coverArt, bottomInset, onPlaylistClick) modifier =
Modifier
.align(Alignment.TopEnd)
.offset(x = 4.dp, y = (-4).dp)
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
}
}
}
if (showOptions) {
ModalBottomSheet(onDismissRequest = { showOptions = false }) {
ListOptionsSheet(
listQuery = listQuery,
sortOptions = sortOptions,
starredSupported = starredSupported,
onSortChange = onSortChange,
onToggleSortDirection = onToggleSortDirection,
onToggleStarred = onToggleStarred,
onClearFilters = onClearFilters,
onSearch = {
showOptions = false
onSearch()
},
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ListOptionsSheet(
listQuery: ListQuery,
sortOptions: List<SortOption>,
starredSupported: Boolean,
onSortChange: (String) -> Unit,
onToggleSortDirection: () -> Unit,
onToggleStarred: () -> Unit,
onClearFilters: () -> Unit,
onSearch: () -> Unit,
) {
Column(Modifier.fillMaxWidth().padding(bottom = 24.dp)) {
ListItem(
headlineContent = { Text("Search your library") },
leadingContent = { Icon(Icons.Rounded.Search, contentDescription = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable(onClick = onSearch),
)
HorizontalDivider()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp),
) {
Text(
text = "Sort by",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onToggleSortDirection) {
Icon(Icons.Rounded.SwapVert, contentDescription = "Reverse sort order")
}
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
sortOptions.forEach { option ->
FilterChip(
selected = listQuery.sort == option.value,
onClick = { onSortChange(option.value) },
label = { Text(option.label) },
)
}
}
if (starredSupported) {
Text(
text = "Filters",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
FilterChip(
selected = listQuery.starredOnly,
onClick = onToggleStarred,
label = { Text("Starred only") },
)
if (listQuery.starredOnly) {
TextButton(onClick = onClearFilters) { Text("Clear") }
}
} }
} }
} }
} }
private val FAB_CLEARANCE = 80.dp
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun LibraryTabs( private fun LibraryTabs(
@@ -10,6 +10,12 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.LibraryListTab
import com.subtracks.data.prefs.ListQuery
import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.SongSort
import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
@@ -32,32 +38,50 @@ class LibraryViewModel(
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
private val syncManager: SyncManager, private val syncManager: SyncManager,
private val playbackController: PlaybackController, private val playbackController: PlaybackController,
userPreferences: UserPreferences, private val userPreferences: UserPreferences,
) : ViewModel() { ) : ViewModel() {
private val listQueries: Map<LibraryListTab, StateFlow<ListQuery>> =
LibraryListTab.entries.associateWith { tab ->
userPreferences
.listQuery(tab)
.stateIn(viewModelScope, SharingStarted.Eagerly, ListQuery(tab.defaultSort))
}
val albums: Flow<PagingData<Album>> = val albums: Flow<PagingData<Album>> =
libraryRepository.activeSourceId libraryRepository.activeSourceId
.filterNotNull() .filterNotNull()
.flatMapLatest { sourceId -> .flatMapLatest { sourceId ->
userPreferences.albumSort.flatMapLatest { sort -> libraryRepository.albums(sourceId, sort) } listQueries.getValue(LibraryListTab.Albums).flatMapLatest { query ->
libraryRepository.albums(sourceId, query.albumSort(), query.descending, query.starredOnly)
}
}.cachedIn(viewModelScope) }.cachedIn(viewModelScope)
val artists: Flow<PagingData<Artist>> = val artists: Flow<PagingData<Artist>> =
libraryRepository.activeSourceId libraryRepository.activeSourceId
.filterNotNull() .filterNotNull()
.flatMapLatest { libraryRepository.artists(it) } .flatMapLatest { sourceId ->
.cachedIn(viewModelScope) listQueries.getValue(LibraryListTab.Artists).flatMapLatest { query ->
libraryRepository.artists(sourceId, query.artistSort(), query.descending, query.starredOnly)
val songs: Flow<PagingData<SongListItem>> = }
libraryRepository.activeSourceId }.cachedIn(viewModelScope)
.filterNotNull()
.flatMapLatest { libraryRepository.songs(it) }
.cachedIn(viewModelScope)
val playlists: Flow<PagingData<Playlist>> = val playlists: Flow<PagingData<Playlist>> =
libraryRepository.activeSourceId libraryRepository.activeSourceId
.filterNotNull() .filterNotNull()
.flatMapLatest { libraryRepository.playlists(it) } .flatMapLatest { sourceId ->
.cachedIn(viewModelScope) listQueries.getValue(LibraryListTab.Playlists).flatMapLatest { query ->
libraryRepository.playlists(sourceId, query.playlistSort(), query.descending)
}
}.cachedIn(viewModelScope)
val songs: Flow<PagingData<SongListItem>> =
libraryRepository.activeSourceId
.filterNotNull()
.flatMapLatest { sourceId ->
listQueries.getValue(LibraryListTab.Songs).flatMapLatest { query ->
libraryRepository.songs(sourceId, query.songSort(), query.descending, query.starredOnly)
}
}.cachedIn(viewModelScope)
val syncing: StateFlow<Boolean> = val syncing: StateFlow<Boolean> =
syncManager.status syncManager.status
@@ -69,6 +93,15 @@ class LibraryViewModel(
.map { state -> state.item?.id?.takeIf { state.context?.kind == QueueKind.Songs } } .map { state -> state.item?.id?.takeIf { state.context?.kind == QueueKind.Songs } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
fun listQuery(tab: LibraryListTab): StateFlow<ListQuery> = listQueries.getValue(tab)
fun setListQuery(
tab: LibraryListTab,
query: ListQuery,
) {
viewModelScope.launch { userPreferences.setListQuery(tab, query) }
}
fun coverArt( fun coverArt(
coverArt: String?, coverArt: String?,
thumbnail: Boolean, thumbnail: Boolean,
@@ -83,3 +116,11 @@ class LibraryViewModel(
fun sync() = syncManager.requestSync() fun sync() = syncManager.requestSync()
} }
private fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name
private fun ListQuery.artistSort(): ArtistSort = ArtistSort.entries.firstOrNull { it.name == sort } ?: ArtistSort.Name
private fun ListQuery.playlistSort(): PlaylistSort = PlaylistSort.entries.firstOrNull { it.name == sort } ?: PlaylistSort.Name
private fun ListQuery.songSort(): SongSort = SongSort.entries.firstOrNull { it.name == sort } ?: SongSort.Album
@@ -0,0 +1,189 @@
package com.subtracks.ui.library
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.PlaylistPlay
import androidx.compose.material.icons.rounded.Album
import androidx.compose.material.icons.rounded.MusicNote
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.repo.SearchHit
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun SearchRoute(
onBack: () -> Unit,
onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit,
onPlaylistClick: (String) -> Unit,
viewModel: SearchViewModel = koinViewModel(),
) {
val results by viewModel.results.collectAsStateWithLifecycle()
var query by rememberSaveable { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
LaunchedEffect(query) { viewModel.onQueryChange(query) }
SearchScreen(
query = query,
results = results,
onQueryChange = { query = it },
onBack = onBack,
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlaylistClick = onPlaylistClick,
onSongClick = viewModel::playSong,
fieldModifier = Modifier.focusRequester(focusRequester),
)
}
@Composable
fun SearchScreen(
query: String,
results: List<SearchHit>,
onQueryChange: (String) -> Unit,
onBack: () -> Unit,
onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit,
onPlaylistClick: (String) -> Unit,
onSongClick: (String) -> Unit,
fieldModifier: Modifier = Modifier,
modifier: Modifier = Modifier,
) {
Column(
modifier =
modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.statusBars),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
}
TextField(
value = query,
onValueChange = onQueryChange,
singleLine = true,
placeholder = { Text("Search your library") },
colors =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
modifier = Modifier.weight(1f).then(fieldModifier),
)
}
LazyColumn(Modifier.fillMaxSize()) {
section("Songs", results.filter { it.type == "song" }, onSongClick)
section("Albums", results.filter { it.type == "album" }, onAlbumClick)
section("Artists", results.filter { it.type == "artist" }, onArtistClick)
section("Playlists", results.filter { it.type == "playlist" }, onPlaylistClick)
if (query.trim().length >= 3 && results.isEmpty()) {
item {
Text(
text = "No matches.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(24.dp),
)
}
}
}
}
}
private fun LazyListScope.section(
title: String,
hits: List<SearchHit>,
onItemClick: (String) -> Unit,
) {
if (hits.isEmpty()) return
item(key = "header:$title") {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
)
}
items(hits, key = { "${it.type}:${it.itemId}" }) { hit ->
SearchRow(hit = hit, onClick = { onItemClick(hit.itemId) })
}
}
@Composable
private fun SearchRow(
hit: SearchHit,
onClick: () -> Unit,
) {
ListItem(
headlineContent = {
Text(
text = hit.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
leadingContent = {
Icon(
imageVector = iconFor(hit.type),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable(onClick = onClick),
)
}
private fun iconFor(type: String): ImageVector =
when (type) {
"song" -> Icons.Rounded.MusicNote
"album" -> Icons.Rounded.Album
"artist" -> Icons.Rounded.Person
"playlist" -> Icons.AutoMirrored.Rounded.PlaylistPlay
else -> Icons.Rounded.Search
}
@@ -0,0 +1,53 @@
package com.subtracks.ui.library
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SearchHit
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
class SearchViewModel(
private val libraryRepository: LibraryRepository,
private val sourceRepository: SourceRepository,
private val playbackController: PlaybackController,
) : ViewModel() {
private val query = MutableStateFlow("")
val results: StateFlow<List<SearchHit>> =
query
.debounce(SEARCH_DEBOUNCE_MS)
.flatMapLatest { text ->
sourceRepository
.activeSourceId()
.filterNotNull()
.flatMapLatest { sourceId -> flow { emit(libraryRepository.search(sourceId, text)) } }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun onQueryChange(value: String) {
query.value = value
}
fun playSong(songId: String) {
viewModelScope.launch {
val sourceId = sourceRepository.activeSourceIdOnce() ?: return@launch
playbackController.playSong(sourceId, songId)
}
}
private companion object {
const val SEARCH_DEBOUNCE_MS = 200L
}
}
@@ -0,0 +1,125 @@
package com.subtracks.data.db
import android.content.Context
import androidx.paging.PagingSource
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.Album
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class LibraryDaoTest {
private lateinit var db: SubtracksDatabase
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
}
@After
fun tearDown() {
db.close()
}
@Test
fun albumSortsAndStarredFilter() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertAlbums(
listOf(
album(sourceId, "a", "Beta", year = 2000, starred = null),
album(sourceId, "b", "Alpha", year = 2010, starred = 5),
album(sourceId, "c", "Gamma", year = 1990, starred = null),
),
)
assertEquals(listOf("Alpha", "Beta", "Gamma"), dao.albumsByName(sourceId, starredOnly = false).page().map { it.name })
assertEquals(listOf("Gamma", "Beta", "Alpha"), dao.albumsByNameReversed(sourceId, starredOnly = false).page().map { it.name })
assertEquals(listOf("Alpha", "Beta", "Gamma"), dao.albumsByYear(sourceId, starredOnly = false).page().map { it.name })
assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, starredOnly = true).page().map { it.name })
}
@Test
fun songSortsByTitleAndStarredFilter() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertSongs(
listOf(
song(sourceId, "s1", "Zebra", starred = null),
song(sourceId, "s2", "Apple", starred = 7),
),
)
assertEquals(listOf("Apple", "Zebra"), dao.songsByTitle(sourceId, starredOnly = false).page().map { it.song.title })
assertEquals(listOf("Zebra", "Apple"), dao.songsByTitleReversed(sourceId, starredOnly = false).page().map { it.song.title })
assertEquals(listOf("Apple"), dao.songsByTitle(sourceId, starredOnly = true).page().map { it.song.title })
}
private suspend fun source(): Long =
db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0))
private suspend fun <T : Any> PagingSource<Int, T>.page(): List<T> =
(
load(
PagingSource.LoadParams.Refresh(key = null, loadSize = 100, placeholdersEnabled = false),
) as PagingSource.LoadResult.Page
).data
private fun album(
sourceId: Long,
id: String,
name: String,
year: Long?,
starred: Long?,
) = Album(
sourceId = sourceId,
id = id,
artistId = "ar-1",
name = name,
albumArtist = "Artist",
created = 0,
coverArt = null,
genre = null,
year = year,
starred = starred,
songCount = 1,
frequentRank = null,
recentRank = null,
)
private fun song(
sourceId: Long,
id: String,
title: String,
starred: Long?,
) = Song(
sourceId = sourceId,
id = id,
albumId = "al-1",
artistId = "ar-1",
title = title,
album = "Album",
artist = "Artist",
duration = 200,
track = 1,
disc = 1,
starred = starred,
genre = null,
)
}
@@ -60,7 +60,7 @@ class SyncServiceTest {
2, 2,
db db
.libraryDao() .libraryDao()
.artists(1) .artistsByName(1, false)
.allRows() .allRows()
.size, .size,
) )
@@ -68,7 +68,7 @@ class SyncServiceTest {
2, 2,
db db
.libraryDao() .libraryDao()
.songs(1) .songs(1, false)
.allRows() .allRows()
.size, .size,
) )
@@ -90,7 +90,7 @@ class SyncServiceTest {
listOf("a1"), listOf("a1"),
db db
.libraryDao() .libraryDao()
.artists(1) .artistsByName(1, false)
.allRows() .allRows()
.map { it.id }, .map { it.id },
) )
@@ -98,7 +98,7 @@ class SyncServiceTest {
listOf("s1"), listOf("s1"),
db db
.libraryDao() .libraryDao()
.songs(1) .songs(1, false)
.allRows() .allRows()
.map { it.song.id }, .map { it.song.id },
) )
@@ -200,7 +200,7 @@ class SyncServiceTest {
"Old", "Old",
db db
.libraryDao() .libraryDao()
.artists(1) .artistsByName(1, false)
.allRows() .allRows()
.single() .single()
.name, .name,
@@ -213,7 +213,7 @@ class SyncServiceTest {
"New", "New",
db db
.libraryDao() .libraryDao()
.artists(1) .artistsByName(1, false)
.allRows() .allRows()
.single() .single()
.name, .name,
@@ -0,0 +1,122 @@
package com.subtracks.ui
import androidx.activity.ComponentActivity
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
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.onRoot
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.github.takahirom.roborazzi.captureRoboImage
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.repo.SearchHit
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.SearchScreen
import com.subtracks.ui.library.sortOptionsFor
import com.subtracks.ui.theme.SubtracksTheme
import kotlinx.coroutines.flow.flowOf
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 LibraryListsScreenshotTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun libraryOptions() {
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 = ListQuery("Name"),
sortOptions = sortOptionsFor(LibraryTab.Albums),
starredSupported = true,
)
}
}
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.onRoot().captureRoboImage("src/test/screenshots/library_options.png")
}
@Test
fun search() {
composeRule.setContent {
SubtracksTheme {
SearchScreen(
query = "radio",
results =
listOf(
SearchHit("song", "s-eiirp", "Everything In Its Right Place"),
SearchHit("album", "al-kid-a", "Kid A"),
SearchHit("artist", "ar-radiohead", "Radiohead"),
SearchHit("playlist", "pl-late-night", "Late Night"),
),
onQueryChange = {},
onBack = {},
onAlbumClick = {},
onArtistClick = {},
onPlaylistClick = {},
onSongClick = {},
)
}
}
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText("Radiohead").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onRoot().captureRoboImage("src/test/screenshots/search.png")
}
private fun albums() =
(1..7).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,
)
}
}