diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index b727250b..1df8415b 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -89,26 +89,181 @@ interface LibraryDao { position: Long, ) - @Query("SELECT * FROM artists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE") - fun artists(sourceId: Long): PagingSource + @Query( + "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 - @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE") - fun albumsByName(sourceId: Long): PagingSource + @Query( + "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 - @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY albumArtist COLLATE NOCASE, year, name COLLATE NOCASE") - fun albumsByArtist(sourceId: Long): PagingSource + @Query( + "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 - @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY year DESC, name COLLATE NOCASE") - fun albumsByYear(sourceId: Long): PagingSource + @Query( + "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 - @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE") - fun albumsByRecentlyAdded(sourceId: Long): PagingSource + @Query( + "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 - @Query(SONGS_SQL) - fun songs(sourceId: Long): PagingSource + @Query( + "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 + + @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 + + @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 + + @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 + + @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 + + @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 + + @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 @Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE") - fun playlists(sourceId: Long): PagingSource + fun playlistsByName(sourceId: Long): PagingSource + + @Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE DESC") + fun playlistsByNameReversed(sourceId: Long): PagingSource + + @Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE") + fun playlistsByRecentlyAdded(sourceId: Long): PagingSource + + @Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY created ASC, name COLLATE NOCASE") + fun playlistsByRecentlyAddedReversed(sourceId: Long): PagingSource + + @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 + + @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 + + @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 + + @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 + + @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 @Query(PLAYLIST_SONGS_SQL) fun playlistSongs( diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index 8fec550c..2edf7aa6 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -12,18 +12,41 @@ import kotlinx.coroutines.flow.map 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 by preferencesDataStore("user_prefs") class UserPreferences( private val store: DataStore, ) { - val albumSort: Flow = - store.data.map { prefs -> - prefs[ALBUM_SORT]?.let { stored -> AlbumSort.entries.firstOrNull { it.name == stored } } ?: AlbumSort.Name - } + fun listQuery(tab: LibraryListTab): Flow = store.data.map { prefs -> decode(prefs[listQueryKey(tab)], tab.defaultSort) } - suspend fun setAlbumSort(sort: AlbumSort) { - store.edit { prefs -> prefs[ALBUM_SORT] = sort.name } + suspend fun setListQuery( + tab: LibraryListTab, + query: ListQuery, + ) { + store.edit { prefs -> prefs[listQueryKey(tab)] = encode(query) } } val maxBitrate: Flow = 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 { - val ALBUM_SORT = stringPreferencesKey("album_sort") val MAX_BITRATE = intPreferencesKey("max_bitrate") val STREAM_FORMAT = stringPreferencesKey("stream_format") } diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index 9f8df155..a28e431f 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -3,6 +3,7 @@ package com.subtracks.data.repo import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData +import androidx.paging.PagingSource import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.model.Album 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.SongListItem 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 +data class SearchHit( + val type: String, + val itemId: String, + val title: String, +) { + val isSong: Boolean get() = type == "song" +} + class LibraryRepository( private val db: SubtracksDatabase, private val sourceRepository: SourceRepository, @@ -21,30 +33,107 @@ class LibraryRepository( fun albums( sourceId: Long, sort: AlbumSort, + descending: Boolean, + starredOnly: Boolean, ): Flow> = - Pager(PagingConfig(pageSize = 40, enablePlaceholders = false)) { + pager(40) { + val dao = db.libraryDao() when (sort) { - AlbumSort.Name -> db.libraryDao().albumsByName(sourceId) - AlbumSort.Artist -> db.libraryDao().albumsByArtist(sourceId) - AlbumSort.Year -> db.libraryDao().albumsByYear(sourceId) - AlbumSort.RecentlyAdded -> db.libraryDao().albumsByRecentlyAdded(sourceId) + AlbumSort.Name -> { + if (descending) dao.albumsByNameReversed(sourceId, starredOnly) else dao.albumsByName(sourceId, starredOnly) + } + + 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> = - Pager(PagingConfig(pageSize = 60, enablePlaceholders = false)) { - db.libraryDao().artists(sourceId) - }.flow + fun artists( + sourceId: Long, + sort: ArtistSort, + descending: Boolean, + starredOnly: Boolean, + ): Flow> = + 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> = - Pager(PagingConfig(pageSize = 60, enablePlaceholders = false)) { - db.libraryDao().songs(sourceId) - }.flow + ArtistSort.AlbumCount -> { + if (descending) { + dao.artistsByAlbumCountReversed(sourceId, starredOnly) + } else { + dao.artistsByAlbumCount(sourceId, starredOnly) + } + } + } + } - fun playlists(sourceId: Long): Flow> = - Pager(PagingConfig(pageSize = 40, enablePlaceholders = false)) { - db.libraryDao().playlists(sourceId) - }.flow + fun playlists( + sourceId: Long, + sort: PlaylistSort, + descending: Boolean, + ): Flow> = + 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> = + 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 = + db + .searchDao() + .search(sourceId.toString(), query, limit) + .map { SearchHit(it.type, it.itemId, it.title) } fun album( sourceId: Long, @@ -74,8 +163,14 @@ class LibraryRepository( fun playlistSongs( sourceId: Long, playlistId: String, - ): Flow> = - Pager(PagingConfig(pageSize = 60, enablePlaceholders = false)) { - db.libraryDao().playlistSongs(sourceId, playlistId) - }.flow + ): Flow> = pager(60) { db.libraryDao().playlistSongs(sourceId, playlistId) } + + private fun pager( + pageSize: Int, + source: () -> PagingSource, + ): Flow> = + Pager( + config = PagingConfig(pageSize = pageSize, enablePlaceholders = false), + pagingSourceFactory = source, + ).flow } diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index 6e077cb6..0d1787b6 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -17,6 +17,7 @@ import com.subtracks.ui.library.AlbumDetailViewModel import com.subtracks.ui.library.ArtistDetailViewModel import com.subtracks.ui.library.LibraryViewModel import com.subtracks.ui.library.PlaylistDetailViewModel +import com.subtracks.ui.library.SearchViewModel import com.subtracks.ui.playback.QueueViewModel import com.subtracks.ui.settings.AddSourceViewModel import com.subtracks.ui.settings.SettingsViewModel @@ -40,6 +41,7 @@ fun appModule( single { PlaybackController(get(), get(), get()) } viewModel { RootViewModel(get()) } viewModel { LibraryViewModel(get(), get(), get(), get(), get()) } + viewModel { SearchViewModel(get(), get(), get()) } viewModel { SettingsViewModel(get(), get()) } viewModel { AddSourceViewModel(get(), get()) } viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) } diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 8f36d2d9..f875f708 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -192,6 +192,11 @@ class PlaybackController( startPosition: Long, ) = play(listOf(queueRepository.songsEntry(sourceId)), startPosition) + fun playSong( + sourceId: Long, + songId: String, + ) = play(listOf(queueRepository.songEntry(sourceId, songId)), 0) + fun shuffleAlbum( sourceId: Long, albumId: String, diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index b1251001..f692ca28 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -50,6 +50,7 @@ import com.subtracks.ui.library.AlbumDetailRoute import com.subtracks.ui.library.ArtistDetailRoute import com.subtracks.ui.library.LibraryRoute import com.subtracks.ui.library.PlaylistDetailRoute +import com.subtracks.ui.library.SearchRoute import com.subtracks.ui.playback.MiniPlayer import com.subtracks.ui.playback.NowPlayingRoute import com.subtracks.ui.playback.QueueRoute @@ -81,6 +82,7 @@ private const val FLING_VELOCITY = 1000f private object Routes { const val LIBRARY = "library" + const val SEARCH = "search" const val SETTINGS = "settings" const val ADD_SERVER = "add-server" 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)) }, onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) }, onOpenSettings = { navController.navigate(Routes.SETTINGS) }, + onSearch = { navController.navigate(Routes.SEARCH) }, 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) { SettingsRoute( onAddServer = { navController.navigate(Routes.ADD_SERVER) }, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index 88da3074..ba84f59a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons 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.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.material3.CircularProgressIndicator 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.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable 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.clipToBounds import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.vector.ImageVector 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.Playlist 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 kotlin.math.abs import kotlin.math.roundToInt @@ -82,6 +101,47 @@ enum class LibraryTab( 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 = + 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) @Composable fun LibraryRoute( @@ -89,12 +149,15 @@ fun LibraryRoute( onArtistClick: (Artist) -> Unit, onPlaylistClick: (Playlist) -> Unit, onOpenSettings: () -> Unit, + onSearch: () -> Unit, bottomInset: Dp, viewModel: LibraryViewModel = koinViewModel(), ) { var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) } val syncing by viewModel.syncing.collectAsStateWithLifecycle() val playingSongId by viewModel.playingSongId.collectAsStateWithLifecycle() + val listQuery by viewModel.listQuery(selectedTab.listTab()).collectAsStateWithLifecycle() + val listTab = selectedTab.listTab() LibraryScreen( selectedTab = selectedTab, onTabSelected = { selectedTab = it }, @@ -112,6 +175,14 @@ fun LibraryRoute( onSync = viewModel::sync, onOpenSettings = onOpenSettings, 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, playingSongId: String? = null, bottomInset: Dp = 0.dp, + listQuery: ListQuery = ListQuery(""), + sortOptions: List = emptyList(), + starredSupported: Boolean = false, + onSortChange: (String) -> Unit = {}, + onToggleSortDirection: () -> Unit = {}, + onToggleStarred: () -> Unit = {}, + onClearFilters: () -> Unit = {}, + onSearch: () -> Unit = {}, modifier: Modifier = Modifier, ) { val pagerState = @@ -141,6 +220,7 @@ fun LibraryScreen( initialPage = selectedTab.ordinal, pageCount = { LibraryTab.entries.size }, ) + var showOptions by rememberSaveable { mutableStateOf(false) } LaunchedEffect(pagerState) { snapshotFlow { pagerState.currentPage }.collect { page -> @@ -167,62 +247,176 @@ fun LibraryScreen( val titleFraction = scrollBehavior.state.collapsedFraction val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() } + val listBottomInset = bottomInset + FAB_CLEARANCE - Column( - modifier = - modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .nestedScroll(scrollBehavior.nestedScrollConnection), - ) { + Box(modifier.fillMaxSize()) { Column( modifier = Modifier - .fillMaxWidth() - .padding(top = statusBarTop), + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .nestedScroll(scrollBehavior.nestedScrollConnection), ) { - Box( + Column( modifier = Modifier .fillMaxWidth() - .height(titleHeight * (1f - titleFraction)) - .clipToBounds(), + .padding(top = statusBarTop), ) { - Text( - text = selectedTab.label, - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1, + Box( modifier = Modifier - .offset(y = -(titleHeight * titleFraction)) - .wrapContentHeight(unbounded = true) - .padding(start = 12.dp, end = 16.dp, top = titleTop), + .fillMaxWidth() + .height(titleHeight * (1f - titleFraction)) + .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, - onTabSelected = onTabSelected, - syncing = syncing, - onSync = onSync, - onOpenSettings = onOpenSettings, - ) + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth().weight(1f), + ) { page -> + 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( - state = pagerState, - modifier = Modifier.fillMaxWidth().weight(1f), - ) { page -> - when (LibraryTab.entries[page]) { - LibraryTab.Albums -> AlbumsContent(albums, coverArt, bottomInset, onAlbumClick) - LibraryTab.Artists -> ArtistsContent(artists, coverArt, bottomInset, onArtistClick) - LibraryTab.Songs -> SongsContent(songs, coverArt, bottomInset, onSongClick, playingSongId) - LibraryTab.Playlists -> PlaylistsContent(playlists, coverArt, bottomInset, onPlaylistClick) + FloatingActionButton( + onClick = { showOptions = true }, + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + ) { + Box { + Icon(Icons.Rounded.Sort, contentDescription = "List options") + if (listQuery.starredOnly) { + Box( + 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, + 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) @Composable private fun LibraryTabs( diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt index 1de10c05..f8fcabd1 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt @@ -10,6 +10,12 @@ import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.Playlist import com.subtracks.data.model.QueueKind 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.repo.LibraryRepository import com.subtracks.data.repo.SourceRepository @@ -32,32 +38,50 @@ class LibraryViewModel( private val sourceRepository: SourceRepository, private val syncManager: SyncManager, private val playbackController: PlaybackController, - userPreferences: UserPreferences, + private val userPreferences: UserPreferences, ) : ViewModel() { + private val listQueries: Map> = + LibraryListTab.entries.associateWith { tab -> + userPreferences + .listQuery(tab) + .stateIn(viewModelScope, SharingStarted.Eagerly, ListQuery(tab.defaultSort)) + } + val albums: Flow> = libraryRepository.activeSourceId .filterNotNull() .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) val artists: Flow> = libraryRepository.activeSourceId .filterNotNull() - .flatMapLatest { libraryRepository.artists(it) } - .cachedIn(viewModelScope) - - val songs: Flow> = - libraryRepository.activeSourceId - .filterNotNull() - .flatMapLatest { libraryRepository.songs(it) } - .cachedIn(viewModelScope) + .flatMapLatest { sourceId -> + listQueries.getValue(LibraryListTab.Artists).flatMapLatest { query -> + libraryRepository.artists(sourceId, query.artistSort(), query.descending, query.starredOnly) + } + }.cachedIn(viewModelScope) val playlists: Flow> = libraryRepository.activeSourceId .filterNotNull() - .flatMapLatest { libraryRepository.playlists(it) } - .cachedIn(viewModelScope) + .flatMapLatest { sourceId -> + listQueries.getValue(LibraryListTab.Playlists).flatMapLatest { query -> + libraryRepository.playlists(sourceId, query.playlistSort(), query.descending) + } + }.cachedIn(viewModelScope) + + val songs: Flow> = + 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 = syncManager.status @@ -69,6 +93,15 @@ class LibraryViewModel( .map { state -> state.item?.id?.takeIf { state.context?.kind == QueueKind.Songs } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + fun listQuery(tab: LibraryListTab): StateFlow = listQueries.getValue(tab) + + fun setListQuery( + tab: LibraryListTab, + query: ListQuery, + ) { + viewModelScope.launch { userPreferences.setListQuery(tab, query) } + } + fun coverArt( coverArt: String?, thumbnail: Boolean, @@ -83,3 +116,11 @@ class LibraryViewModel( 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 diff --git a/app/src/main/kotlin/com/subtracks/ui/library/SearchScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/SearchScreen.kt new file mode 100644 index 00000000..b6044720 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/library/SearchScreen.kt @@ -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, + 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, + 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 + } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/SearchViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/SearchViewModel.kt new file mode 100644 index 00000000..471ac6ef --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/library/SearchViewModel.kt @@ -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> = + 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 + } +} diff --git a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt new file mode 100644 index 00000000..8848ba8d --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -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() + 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 PagingSource.page(): List = + ( + 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, + ) +} diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt index 7ef2a9ca..b5f11843 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt @@ -60,7 +60,7 @@ class SyncServiceTest { 2, db .libraryDao() - .artists(1) + .artistsByName(1, false) .allRows() .size, ) @@ -68,7 +68,7 @@ class SyncServiceTest { 2, db .libraryDao() - .songs(1) + .songs(1, false) .allRows() .size, ) @@ -90,7 +90,7 @@ class SyncServiceTest { listOf("a1"), db .libraryDao() - .artists(1) + .artistsByName(1, false) .allRows() .map { it.id }, ) @@ -98,7 +98,7 @@ class SyncServiceTest { listOf("s1"), db .libraryDao() - .songs(1) + .songs(1, false) .allRows() .map { it.song.id }, ) @@ -200,7 +200,7 @@ class SyncServiceTest { "Old", db .libraryDao() - .artists(1) + .artistsByName(1, false) .allRows() .single() .name, @@ -213,7 +213,7 @@ class SyncServiceTest { "New", db .libraryDao() - .artists(1) + .artistsByName(1, false) .allRows() .single() .name, diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryListsScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryListsScreenshotTest.kt new file mode 100644 index 00000000..62840bda --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryListsScreenshotTest.kt @@ -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() + + @Test + fun libraryOptions() { + composeRule.setContent { + SubtracksTheme { + LibraryScreen( + selectedTab = LibraryTab.Albums, + onTabSelected = {}, + albums = remember { flowOf(PagingData.from(albums())) }.collectAsLazyPagingItems(), + artists = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), + songs = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), + playlists = remember { flowOf(PagingData.empty()) }.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, + ) + } +}