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 006c5b70..14b322f0 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -128,6 +128,34 @@ internal const val PLAYLIST_ORDER_BY_ADDED_REVERSED = "created ASC, name COLLATE internal const val PLAYLIST_ORDER_BY_UPDATED = "changed DESC, name COLLATE NOCASE, id" internal const val PLAYLIST_ORDER_BY_UPDATED_REVERSED = "changed ASC, name COLLATE NOCASE DESC, id DESC" +// Cross-type search: a three-character-or-longer query runs through the trigram index and anything +// shorter falls back to the instr scan, mirroring the per-tab filters above. +internal const val ALBUM_SEARCH_FILTER = + "(:search <> '' AND (" + + "(length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " + + "WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " + + "OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " + + "OR instr(lower(albumArtist), lower(:search)) > 0))))" + +internal const val ARTIST_SEARCH_FILTER = + "(:search <> '' AND (" + + "(length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " + + "WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " + + "OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)))" + +internal const val PLAYLIST_SEARCH_FILTER = + "(:search <> '' AND (" + + "(length(:search) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " + + "WHERE playlist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " + + "OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)))" + +internal const val SONG_SEARCH_FILTER = + "(:search <> '' AND (" + + "(length(:search) >= 3 AND songs.rowid IN (SELECT rowid FROM song_search " + + "WHERE song_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " + + "OR (length(:search) < 3 AND (instr(lower(songs.title), lower(:search)) > 0 " + + "OR instr(lower(songs.artist), lower(:search)) > 0))))" + @Dao @DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class) interface LibraryDao { @@ -306,6 +334,48 @@ interface LibraryDao { limit: Int, ): Flow> + @Query( + "SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " + + "ORDER BY name COLLATE NOCASE, id LIMIT :limit", + ) + fun searchAlbums( + sourceId: Long, + search: String, + limit: Int, + ): Flow> + + @Query( + "SELECT * FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER " + + "ORDER BY name COLLATE NOCASE, id LIMIT :limit", + ) + fun searchArtists( + sourceId: Long, + search: String, + limit: Int, + ): Flow> + + @Query( + "SELECT * FROM playlists WHERE sourceId = :sourceId AND $PLAYLIST_SEARCH_FILTER " + + "ORDER BY name COLLATE NOCASE, id LIMIT :limit", + ) + fun searchPlaylists( + sourceId: Long, + search: String, + limit: Int, + ): Flow> + + @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 $SONG_SEARCH_FILTER " + + "ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit", + ) + fun searchSongs( + sourceId: Long, + search: String, + limit: Int, + ): Flow> + @Query( "SELECT song_genres.genre FROM song_genres " + "JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " + 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 1971a743..694ceaa0 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -103,6 +103,26 @@ class LibraryRepository( fun downloadedSongsPage(sourceId: Long): Flow> = pager(60) { db.libraryDao().homeDownloadedSongs(sourceId) } + fun searchAlbums( + sourceId: Long, + query: String, + ): Flow> = db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT) + + fun searchArtists( + sourceId: Long, + query: String, + ): Flow> = db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT) + + fun searchPlaylists( + sourceId: Long, + query: String, + ): Flow> = db.libraryDao().searchPlaylists(sourceId, query, SEARCH_RESULT_LIMIT) + + fun searchSongs( + sourceId: Long, + query: String, + ): Flow> = db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT) + fun downloadedAlbumsPage(sourceId: Long): Flow> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) } fun downloadedArtistsPage(sourceId: Long): Flow> = pager(60) { db.libraryDao().downloadedArtists(sourceId) } @@ -488,4 +508,6 @@ class LibraryRepository( const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000 +const val SEARCH_RESULT_LIMIT = 50 + fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index 464b7b89..5d93c676 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -40,6 +40,7 @@ import com.subtracks.ui.library.ArtistDetailViewModel import com.subtracks.ui.library.LibraryViewModel import com.subtracks.ui.library.PlaylistDetailViewModel import com.subtracks.ui.playback.QueueViewModel +import com.subtracks.ui.search.SearchViewModel import com.subtracks.ui.settings.AddSourceViewModel import com.subtracks.ui.settings.SettingsViewModel import kotlinx.coroutines.CoroutineScope @@ -116,6 +117,7 @@ fun appModule( viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get()) } viewModel { HomeViewModel(get(), get(), get(), get()) } viewModel { params -> HomeListViewModel(get(), get(), get(), params.get()) } + viewModel { SearchViewModel(get(), get(), get()) } viewModel { SettingsViewModel(get(), get()) } viewModel { DownloadsViewModel( diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index 5ce489e6..920847a2 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -82,6 +82,7 @@ import com.subtracks.ui.library.PlaylistDetailRoute import com.subtracks.ui.playback.MiniPlayer import com.subtracks.ui.playback.NowPlayingRoute import com.subtracks.ui.playback.QueueRoute +import com.subtracks.ui.search.SearchRoute import com.subtracks.ui.settings.AddSourceRoute import com.subtracks.ui.settings.LicensesRoute import com.subtracks.ui.settings.SettingsRoute @@ -117,6 +118,7 @@ private object Routes { const val LIBRARY = "library" const val SETTINGS = "settings" const val DOWNLOADS = "downloads" + const val SEARCH = "search" const val LICENSES = "licenses" const val ADD_SERVER = "add-server" const val EDIT_SERVER = "edit-server/{sourceId}" @@ -374,6 +376,7 @@ private fun MainNavigation() { navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id)) }, onOpenSettings = { navController.navigate(Routes.SETTINGS) }, + onOpenSearch = { navController.navigate(Routes.SEARCH) }, onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) }, onViewArtist = { artistId -> navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId)) @@ -399,6 +402,26 @@ private fun MainNavigation() { composable(Routes.DOWNLOADS) { DownloadsRoute(onBack = { navController.popBackStack() }) } + composable(Routes.SEARCH) { + SearchRoute( + onBack = { navController.popBackStack() }, + onAlbumClick = { album -> + navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) + }, + onArtistClick = { artist -> + navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt)) + }, + onPlaylistClick = { playlist -> + navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id)) + }, + onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) }, + onViewArtist = { artistId -> + navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId)) + }, + contextMenuHost = contextMenuHost, + setStar = libraryRepository::star, + ) + } composable(Routes.LICENSES) { LicensesRoute(onBack = { navController.popBackStack() }) } diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt index 9de9cbc7..92197cc5 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt @@ -362,7 +362,7 @@ private fun AlbumsList( } @Composable -private fun AlbumListRow( +internal fun AlbumListRow( album: Album, coverArt: (String?, Boolean) -> CoverArtRef?, info: String?, @@ -446,7 +446,7 @@ private fun ArtistsList( } @Composable -private fun ArtistListRow( +internal fun ArtistListRow( artist: Artist, coverArt: (String?, Boolean) -> CoverArtRef?, info: String?, 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 bd08f0ba..cc7b7d70 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -225,6 +225,7 @@ fun LibraryRoute( onArtistClick: (Artist) -> Unit, onPlaylistClick: (Playlist) -> Unit, onOpenSettings: () -> Unit, + onOpenSearch: () -> Unit = {}, onViewAlbum: (String) -> Unit, onViewArtist: (String) -> Unit, onHomeMore: (HomeSection) -> Unit = {}, @@ -306,6 +307,7 @@ fun LibraryRoute( onPlaylistClick = onPlaylistClick, onSync = viewModel::sync, onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, onItemLongClick = { contextMenuHost?.show(it, itemActions) }, listQuery = displayQuery, resetKeys = resetKeys, @@ -383,6 +385,7 @@ fun LibraryScreen( onPlaylistClick: (Playlist) -> Unit, onSync: () -> Unit, onOpenSettings: () -> Unit, + onOpenSearch: () -> Unit = {}, offline: Boolean = false, onExitOffline: () -> Unit = {}, onItemLongClick: (MenuTarget) -> Unit = {}, @@ -588,31 +591,36 @@ fun LibraryScreen( ) } - if (!searchActive && selectedTab != LibraryTab.Home) { + if (!searchActive) { + val searchFab = selectedTab == LibraryTab.Home FloatingActionButton( - onClick = { showOptions = true }, + onClick = { if (searchFab) onOpenSearch() else showOptions = true }, containerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh, contentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground, elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp), modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp), ) { - Box(Modifier.size(28.dp), contentAlignment = Alignment.Center) { - ListOptionsGlyph( - modifier = - Modifier - .size(24.dp) - .semantics { contentDescription = listOptionsLabel }, - ) - if (filtersActive) { - Box( + if (searchFab) { + Icon(Icons.Rounded.Search, contentDescription = stringResource(R.string.search)) + } else { + Box(Modifier.size(28.dp), contentAlignment = Alignment.Center) { + ListOptionsGlyph( modifier = Modifier - .align(Alignment.TopEnd) - .offset(x = 4.dp, y = (-4).dp) - .size(8.dp) - .clip(CircleShape) - .background(artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.primary), + .size(24.dp) + .semantics { contentDescription = listOptionsLabel }, ) + if (filtersActive) { + Box( + modifier = + Modifier + .align(Alignment.TopEnd) + .offset(x = 4.dp, y = (-4).dp) + .size(8.dp) + .clip(CircleShape) + .background(artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.primary), + ) + } } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt index 36b8f3be..d4a0bcb0 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt @@ -83,47 +83,16 @@ fun PlaylistsContent( items(count = items.itemCount, key = items.itemKey { it.id }) { index -> val playlist = items[index] if (playlist != null) { - ListItem( - headlineContent = { - Text( - text = playlist.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + PlaylistRow( + playlist = playlist, + coverArt = coverArt, + download = downloadStatuses[playlist.id], + onClick = { onPlaylistClick(playlist) }, + onLongClick = { + onLongClick( + MenuTarget.Playlist(playlist, coverArt(playlist.coverArt, true), downloadStatuses[playlist.id]), ) }, - supportingContent = { - Text( - text = - playlist.comment?.takeIf { it.isNotBlank() } - ?: pluralStringResource( - R.plurals.resources_song_count, - playlist.songCount.toInt(), - playlist.songCount, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - leadingContent = { - CoverArt( - ref = coverArt(playlist.coverArt, true), - name = playlist.name, - modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)), - ) - }, - trailingContent = { - ListDownloadIndicator(downloadStatuses[playlist.id], Modifier.padding(end = 8.dp)) - }, - modifier = - Modifier.combinedClickable( - onClick = { onPlaylistClick(playlist) }, - onLongClick = { - onLongClick( - MenuTarget.Playlist(playlist, coverArt(playlist.coverArt, true), downloadStatuses[playlist.id]), - ) - }, - ), - colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } } @@ -132,3 +101,47 @@ fun PlaylistsContent( } } } + +@Composable +internal fun PlaylistRow( + playlist: Playlist, + coverArt: (String?, Boolean) -> CoverArtRef?, + download: ListDownloadStatus?, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + ListItem( + headlineContent = { + Text( + text = playlist.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + Text( + text = + playlist.comment?.takeIf { it.isNotBlank() } + ?: pluralStringResource( + R.plurals.resources_song_count, + playlist.songCount.toInt(), + playlist.songCount, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + leadingContent = { + CoverArt( + ref = coverArt(playlist.coverArt, true), + name = playlist.name, + modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)), + ) + }, + trailingContent = { + ListDownloadIndicator(download, Modifier.padding(end = 8.dp)) + }, + modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) +} diff --git a/app/src/main/kotlin/com/subtracks/ui/search/SearchScreen.kt b/app/src/main/kotlin/com/subtracks/ui/search/SearchScreen.kt new file mode 100644 index 00000000..10a10ce0 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/search/SearchScreen.kt @@ -0,0 +1,267 @@ +package com.subtracks.ui.search + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.subtracks.data.model.Album +import com.subtracks.data.model.AlbumSongItem +import com.subtracks.data.model.Artist +import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.Playlist +import com.subtracks.data.model.Song +import com.subtracks.data.source.StarType +import com.subtracks.playback.PlaybackController +import com.subtracks.ui.components.ContextMenuHost +import com.subtracks.ui.components.EmptyState +import com.subtracks.ui.components.ItemActions +import com.subtracks.ui.components.MenuTarget +import com.subtracks.ui.home.AlbumListRow +import com.subtracks.ui.home.ArtistListRow +import com.subtracks.ui.library.PlaylistRow +import com.subtracks.ui.library.SongRow +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun SearchRoute( + onBack: () -> Unit, + onAlbumClick: (Album) -> Unit, + onArtistClick: (Artist) -> Unit, + onPlaylistClick: (Playlist) -> Unit, + onViewAlbum: (String) -> Unit, + onViewArtist: (String) -> Unit, + contextMenuHost: ContextMenuHost? = null, + setStar: (StarType, String, Boolean) -> Unit, + viewModel: SearchViewModel = koinViewModel(), + playbackController: PlaybackController = koinInject(), +) { + val query by viewModel.query.collectAsStateWithLifecycle() + val results by viewModel.results.collectAsStateWithLifecycle() + val playback by playbackController.state.collectAsStateWithLifecycle() + val actions = + ItemActions( + playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) }, + addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) }, + setStar = setStar, + viewAlbum = onViewAlbum, + viewArtist = onViewArtist, + ) + SearchScreen( + query = query, + onQueryChange = viewModel::setQuery, + results = results, + coverArt = viewModel::coverArt, + playingSongId = playback.item?.id, + onBack = onBack, + onAlbumClick = onAlbumClick, + onArtistClick = onArtistClick, + onPlaylistClick = onPlaylistClick, + onSongClick = viewModel::play, + onLongClick = { contextMenuHost?.show(it, actions) }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchScreen( + query: String, + onQueryChange: (String) -> Unit, + results: SearchResults, + coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null }, + playingSongId: String? = null, + onBack: () -> Unit = {}, + onAlbumClick: (Album) -> Unit = {}, + onArtistClick: (Artist) -> Unit = {}, + onPlaylistClick: (Playlist) -> Unit = {}, + onSongClick: (Song) -> Unit = {}, + onLongClick: (MenuTarget) -> Unit = {}, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") + } + }, + title = { + TextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + placeholder = { Text("Search your library") }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Rounded.Close, contentDescription = "Clear search") + } + } + }, + shape = RoundedCornerShape(8.dp), + colors = + TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + ) + }, + ) + }, + ) { padding -> + when { + query.isBlank() -> { + EmptyState( + text = "Search for songs, albums, artists and playlists.", + modifier = Modifier.padding(padding), + ) + } + + results.isEmpty -> { + EmptyState( + text = "No results for \u201C$query\u201D.", + modifier = Modifier.padding(padding), + ) + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + songResults(results.songs, coverArt, playingSongId, onSongClick, onLongClick) + albumResults(results.albums, coverArt, onAlbumClick, onLongClick) + artistResults(results.artists, coverArt, onArtistClick, onLongClick) + playlistResults(results.playlists, coverArt, onPlaylistClick, onLongClick) + } + } + } + } +} + +private fun LazyListScope.resultHeader(title: String) { + item(key = "search-header-$title") { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp), + ) + } +} + +private fun LazyListScope.songResults( + songs: List, + coverArt: (String?, Boolean) -> CoverArtRef?, + playingSongId: String?, + onClick: (Song) -> Unit, + onLongClick: (MenuTarget) -> Unit, +) { + if (songs.isEmpty()) return + resultHeader("Songs") + items(songs, key = { it.song.id }) { item -> + SongRow( + song = item.song, + coverArtId = item.coverArt, + coverArt = coverArt, + isPlaying = item.song.id == playingSongId, + durationSeconds = item.song.duration, + modifier = + Modifier.combinedClickable( + onClick = { onClick(item.song) }, + onLongClick = { onLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true))) }, + ), + ) + } +} + +private fun LazyListScope.albumResults( + albums: List, + coverArt: (String?, Boolean) -> CoverArtRef?, + onClick: (Album) -> Unit, + onLongClick: (MenuTarget) -> Unit, +) { + if (albums.isEmpty()) return + resultHeader("Albums") + items(albums, key = { it.id }) { album -> + AlbumListRow( + album = album, + coverArt = coverArt, + info = null, + onClick = { onClick(album) }, + onLongClick = { onLongClick(MenuTarget.Album(album, coverArt(album.coverArt, true))) }, + ) + } +} + +private fun LazyListScope.artistResults( + artists: List, + coverArt: (String?, Boolean) -> CoverArtRef?, + onClick: (Artist) -> Unit, + onLongClick: (MenuTarget) -> Unit, +) { + if (artists.isEmpty()) return + resultHeader("Artists") + items(artists, key = { it.id }) { artist -> + ArtistListRow( + artist = artist, + coverArt = coverArt, + info = null, + onClick = { onClick(artist) }, + onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) }, + ) + } +} + +private fun LazyListScope.playlistResults( + playlists: List, + coverArt: (String?, Boolean) -> CoverArtRef?, + onClick: (Playlist) -> Unit, + onLongClick: (MenuTarget) -> Unit, +) { + if (playlists.isEmpty()) return + resultHeader("Playlists") + items(playlists, key = { it.id }) { playlist -> + PlaylistRow( + playlist = playlist, + coverArt = coverArt, + download = null, + onClick = { onClick(playlist) }, + onLongClick = { onLongClick(MenuTarget.Playlist(playlist, coverArt(playlist.coverArt, true))) }, + ) + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/search/SearchViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/search/SearchViewModel.kt new file mode 100644 index 00000000..daf0d8a9 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/search/SearchViewModel.kt @@ -0,0 +1,75 @@ +package com.subtracks.ui.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.subtracks.data.model.Album +import com.subtracks.data.model.AlbumSongItem +import com.subtracks.data.model.Artist +import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.Playlist +import com.subtracks.data.model.Song +import com.subtracks.data.repo.LibraryRepository +import com.subtracks.data.repo.SourceRepository +import com.subtracks.playback.PlaybackController +import com.subtracks.ui.home.playSongInContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn + +data class SearchResults( + val albums: List = emptyList(), + val artists: List = emptyList(), + val playlists: List = emptyList(), + val songs: List = emptyList(), +) { + val isEmpty: Boolean + get() = albums.isEmpty() && artists.isEmpty() && playlists.isEmpty() && songs.isEmpty() +} + +@OptIn(ExperimentalCoroutinesApi::class) +class SearchViewModel( + private val libraryRepository: LibraryRepository, + private val sourceRepository: SourceRepository, + private val playbackController: PlaybackController, +) : ViewModel() { + private val _query = MutableStateFlow("") + val query: StateFlow = _query.asStateFlow() + + val results: StateFlow = + libraryRepository.activeSourceId + .flatMapLatest { sourceId -> + _query.flatMapLatest { text -> + if (sourceId == null || text.isBlank()) { + flowOf(SearchResults()) + } else { + combine( + libraryRepository.searchAlbums(sourceId, text), + libraryRepository.searchArtists(sourceId, text), + libraryRepository.searchPlaylists(sourceId, text), + libraryRepository.searchSongs(sourceId, text), + ) { albums, artists, playlists, songs -> + SearchResults(albums, artists, playlists, songs) + } + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchResults()) + + fun setQuery(value: String) { + _query.value = value + } + + fun coverArt( + coverArt: String?, + thumbnail: Boolean, + ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) + + fun play(song: Song) { + viewModelScope.playSongInContext(libraryRepository, playbackController, song) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f206a31..eca48799 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -119,6 +119,7 @@ No playlists yet. Sync List options + Search Search this list Sort by Sort ascending diff --git a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt index 159b3b01..c3658cac 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -703,6 +703,91 @@ class LibraryDaoTest { assertTrue(plan, plan.contains("album_search")) } + @Test + fun crossTypeSearchMatchesAllFourEntityTypes() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums(listOf(album(sourceId, "al-1", "Road Album", year = null, starred = null))) + dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Road Artists", albumCount = 1))) + dao.upsertPlaylists(listOf(playlist(sourceId, "pl-1", "Road Trip", created = 0, changed = 0))) + dao.upsertSongs( + listOf( + song(sourceId, "s1", "Road Song", starred = null), + song(sourceId, "s2", "Neutral", starred = null).copy(artist = "Roadie"), + song(sourceId, "s3", "Unrelated", starred = null).copy(artist = "Someone"), + ), + ) + + assertEquals(listOf("Road Album"), dao.searchAlbums(sourceId, "oad", 50).first().map { it.name }) + assertEquals(listOf("ar-1"), dao.searchArtists(sourceId, "oad", 50).first().map { it.id }) + assertEquals(listOf("pl-1"), dao.searchPlaylists(sourceId, "oad", 50).first().map { it.id }) + assertEquals( + setOf("s1", "s2"), + dao + .searchSongs(sourceId, "oad", 50) + .first() + .map { it.song.id } + .toSet(), + ) + + assertTrue(dao.searchAlbums(sourceId, "", 50).first().isEmpty()) + assertTrue(dao.searchSongs(sourceId, "", 50).first().isEmpty()) + } + + @Test + fun crossTypeSearchIsScopedToTheSource() = + runTest { + val first = source() + val second = source() + val dao = db.libraryDao() + dao.upsertAlbums( + listOf( + album(first, "a1", "Road One", year = null, starred = null), + album(second, "a2", "Road Two", year = null, starred = null), + ), + ) + + assertEquals(listOf("Road One"), dao.searchAlbums(first, "oad", 50).first().map { it.name }) + assertEquals(listOf("Road Two"), dao.searchAlbums(second, "oad", 50).first().map { it.name }) + } + + @Test + fun crossTypeSearchHonoursTheLimit() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums((1..3).map { album(sourceId, "a$it", "Road $it", year = null, starred = null) }) + + assertEquals(2, dao.searchAlbums(sourceId, "oad", 2).first().size) + } + + @Test + fun songSearchRunsThroughTheTrigramIndex() = + runTest { + val plan = + plan( + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + + "WHERE songs.sourceId = 1 AND ${SONG_SEARCH_FILTER.replace(":search", "'abc'")} " + + "ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT 20 OFFSET 0", + ) + + assertTrue(plan, plan.contains("song_search")) + } + + @Test + fun crossTypeSearchFallsBackToAScanBelowThreeCharacters() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums(listOf(album(sourceId, "al-1", "Gamma", year = null, starred = null))) + dao.upsertSongs(listOf(song(sourceId, "s1", "Gamma Ray", starred = null))) + + assertEquals(listOf("Gamma"), dao.searchAlbums(sourceId, "ga", 50).first().map { it.name }) + assertEquals(listOf("s1"), dao.searchSongs(sourceId, "ga", 50).first().map { it.song.id }) + } + private suspend fun plan(sql: String): String = db.useReaderConnection { connection -> connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement -> diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt index addbdd54..2e833ccb 100644 --- a/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt @@ -110,6 +110,33 @@ class LibraryScreenTest { assertTrue("an empty library should offer a sync", synced) } + @Test + fun theHomeTabOffersSearch() { + var searched = false + composeRule.setContent { + SubtracksTheme { + LibraryScreen( + selectedTab = LibraryTab.Home, + onTabSelected = {}, + albums = remember { flowOf(PagingData.empty()) }, + artists = remember { flowOf(PagingData.empty()) }, + playlists = remember { flowOf(PagingData.empty()) }, + coverArt = { _, _ -> null }, + onAlbumClick = {}, + onArtistClick = {}, + onPlaylistClick = {}, + onSync = {}, + onOpenSettings = {}, + onOpenSearch = { searched = true }, + ) + } + } + + composeRule.onNodeWithContentDescription("Search").performClick() + + assertTrue("the home tab should expose search", searched) + } + @Test fun theVisibleTabAndItsNeighbourCollectTheirPagingFlows() { val collected = CopyOnWriteArrayList() diff --git a/app/src/test/kotlin/com/subtracks/ui/search/SearchScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/search/SearchScreenTest.kt new file mode 100644 index 00000000..d21e4bcc --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/search/SearchScreenTest.kt @@ -0,0 +1,146 @@ +package com.subtracks.ui.search + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.subtracks.data.model.Album +import com.subtracks.data.model.AlbumSongItem +import com.subtracks.data.model.Artist +import com.subtracks.data.model.Playlist +import com.subtracks.data.model.Song +import com.subtracks.ui.theme.SubtracksTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL) +class SearchScreenTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun resultsAreGroupedByTypeAndTappingASongOrAlbumFiresItsCallback() { + var played: Song? = null + var opened: Album? = null + composeRule.setContent { + SubtracksTheme { + SearchScreen( + query = "road", + onQueryChange = {}, + results = + SearchResults( + albums = listOf(album("al-1", "Road Album")), + artists = listOf(artist("ar-1", "Road Artist")), + playlists = listOf(playlist("pl-1", "Road Trip")), + songs = listOf(AlbumSongItem(song("s1", "Road Song"), coverArt = null)), + ), + onSongClick = { played = it }, + onAlbumClick = { opened = it }, + ) + } + } + + composeRule.onNodeWithText("Songs").assertIsDisplayed() + composeRule.onNodeWithText("Albums").assertIsDisplayed() + composeRule.onNodeWithText("Artists").assertIsDisplayed() + composeRule.onNodeWithText("Playlists").assertIsDisplayed() + + composeRule.onNodeWithText("Road Song").performClick() + composeRule.onNodeWithText("Road Album").performClick() + + assertEquals("s1", played?.id) + assertEquals("al-1", opened?.id) + } + + @Test + fun anEmptyQueryPromptsAndAnEmptyResultSaysSo() { + composeRule.setContent { + SubtracksTheme { + SearchScreen(query = "", onQueryChange = {}, results = SearchResults()) + } + } + + composeRule.onNodeWithText("Search for songs, albums, artists and playlists.").assertIsDisplayed() + } + + @Test + fun aQueryWithNoResultsSaysSo() { + composeRule.setContent { + SubtracksTheme { + SearchScreen(query = "zzz", onQueryChange = {}, results = SearchResults()) + } + } + + composeRule.onNodeWithText("No results", substring = true).assertIsDisplayed() + } + + private fun album( + id: String, + name: String, + ) = Album( + sourceId = 1, + id = id, + artistId = "ar-1", + name = name, + albumArtist = "Artist", + created = 0, + coverArt = null, + genre = null, + year = null, + starred = null, + songCount = 1, + ) + + private fun artist( + id: String, + name: String, + ) = Artist( + sourceId = 1, + id = id, + name = name, + albumCount = 1, + starred = null, + coverArt = null, + ) + + private fun playlist( + id: String, + name: String, + ) = Playlist( + sourceId = 1, + id = id, + name = name, + comment = null, + coverArt = null, + songCount = 1, + created = 0, + changed = 0, + duration = 100, + ) + + private fun song( + id: String, + title: String, + ) = Song( + sourceId = 1, + id = id, + albumId = "al-1", + artistId = "ar-1", + title = title, + album = "Album", + artist = "Artist", + duration = 100, + track = 1, + disc = 1, + starred = null, + ) +} diff --git a/app/src/test/kotlin/com/subtracks/ui/search/SearchViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/search/SearchViewModelTest.kt new file mode 100644 index 00000000..37edb43d --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/search/SearchViewModelTest.kt @@ -0,0 +1,189 @@ +package com.subtracks.ui.search + +import android.content.Context +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.cancelAndJoinBlocking +import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.download.ArtworkStore +import com.subtracks.data.download.FakeDownloadEngine +import com.subtracks.data.model.Album +import com.subtracks.data.model.Artist +import com.subtracks.data.model.Playlist +import com.subtracks.data.model.Song +import com.subtracks.data.prefs.fakeUserPreferences +import com.subtracks.data.repo.DownloadRepository +import com.subtracks.data.repo.LibraryRepository +import com.subtracks.data.repo.NetworkServerActionSink +import com.subtracks.data.repo.QueueRepository +import com.subtracks.data.repo.SourceRepository +import com.subtracks.playback.FakePlayerConnection +import com.subtracks.playback.FakePlayerHandle +import com.subtracks.playback.PlaybackController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.util.concurrent.Executors + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class SearchViewModelTest { + private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + + private lateinit var db: SubtracksDatabase + private lateinit var sourceRepository: SourceRepository + private lateinit var viewModel: SearchViewModel + private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val controllerScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + val context = ApplicationProvider.getApplicationContext() + db = + Room + .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .build() + val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}")) + sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, scope = repoScope) + val downloads = + DownloadRepository( + db, + sourceRepository, + FakeDownloadEngine(), + File(context.cacheDir, "downloads-${System.nanoTime()}"), + artworkStore = artwork, + artworkFetcher = { ByteArray(0) }, + scope = repoScope, + ) + val controller = + PlaybackController( + sourceRepository, + QueueRepository(db), + FakePlayerConnection(FakePlayerHandle()), + downloads, + scope = controllerScope, + ) + viewModel = + SearchViewModel( + LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope), + sourceRepository, + controller, + ) + } + + @After + fun tearDown() { + cancelAndJoinBlocking(controllerScope, repoScope) + db.close() + Dispatchers.resetMain() + dispatcher.close() + } + + @Test + fun searchFindsEveryEntityTypeAndBlankClearsTheResults() { + runBlocking { + val id = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false) + db.libraryDao().upsertAlbums(listOf(album(id, "al-1", "Road Album"))) + db.libraryDao().upsertArtists(listOf(artist(id, "ar-1", "Road Artist"))) + db.libraryDao().upsertPlaylists(listOf(playlist(id, "pl-1", "Road Trip"))) + db.libraryDao().upsertSongs(listOf(song(id, "s1", "Road Song"))) + } + + viewModel.setQuery("oad") + val results = runBlocking { withTimeout(5_000) { viewModel.results.first { !it.isEmpty } } } + + assertEquals(listOf("Road Album"), results.albums.map { it.name }) + assertEquals(listOf("ar-1"), results.artists.map { it.id }) + assertEquals(listOf("pl-1"), results.playlists.map { it.id }) + assertEquals(listOf("s1"), results.songs.map { it.song.id }) + + viewModel.setQuery("") + val blank = runBlocking { withTimeout(5_000) { viewModel.results.first { it.isEmpty } } } + + assertTrue(blank.isEmpty) + } + + private fun album( + sourceId: Long, + id: String, + name: String, + ) = Album( + sourceId = sourceId, + id = id, + artistId = "ar-1", + name = name, + albumArtist = "Artist", + created = 0, + coverArt = null, + genre = null, + year = null, + starred = null, + songCount = 1, + ) + + private fun artist( + sourceId: Long, + id: String, + name: String, + ) = Artist( + sourceId = sourceId, + id = id, + name = name, + albumCount = 1, + starred = null, + coverArt = null, + ) + + private fun playlist( + sourceId: Long, + id: String, + name: String, + ) = Playlist( + sourceId = sourceId, + id = id, + name = name, + comment = null, + coverArt = null, + songCount = 1, + created = 0, + changed = 0, + duration = 100, + ) + + private fun song( + sourceId: Long, + id: String, + title: String, + ) = Song( + sourceId = sourceId, + id = id, + albumId = "al-1", + artistId = "ar-1", + title = title, + album = "Album", + artist = "Artist", + duration = 100, + track = 1, + disc = 1, + starred = null, + ) +}