search: expose cross-library search from a home FAB

Add FTS5-backed search across songs, albums, artists and playlists,
reachable from a search FAB on the Home tab. Terms of three characters
or more run through the trigram indices; shorter terms fall back to the
instr scan, matching the per-tab filters. Results are grouped by type
and route into detail screens or song playback.
This commit is contained in:
austinried
2026-10-04 00:30:07 +00:00
parent da57ac08af
commit 5a3d5ded78
14 changed files with 985 additions and 57 deletions
@@ -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<List<AlbumSongItem>>
@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<List<Album>>
@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<List<Artist>>
@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<List<Playlist>>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " +
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit",
)
fun searchSongs(
sourceId: Long,
search: String,
limit: Int,
): Flow<List<AlbumSongItem>>
@Query(
"SELECT song_genres.genre FROM song_genres " +
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
@@ -103,6 +103,26 @@ class LibraryRepository(
fun downloadedSongsPage(sourceId: Long): Flow<PagingData<AlbumSongItem>> = pager(60) { db.libraryDao().homeDownloadedSongs(sourceId) }
fun searchAlbums(
sourceId: Long,
query: String,
): Flow<List<Album>> = db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT)
fun searchArtists(
sourceId: Long,
query: String,
): Flow<List<Artist>> = db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT)
fun searchPlaylists(
sourceId: Long,
query: String,
): Flow<List<Playlist>> = db.libraryDao().searchPlaylists(sourceId, query, SEARCH_RESULT_LIMIT)
fun searchSongs(
sourceId: Long,
query: String,
): Flow<List<AlbumSongItem>> = db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT)
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
fun downloadedArtistsPage(sourceId: Long): Flow<PagingData<Artist>> = 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
@@ -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(
@@ -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() })
}
@@ -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?,
@@ -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),
)
}
}
}
}
@@ -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),
)
}
@@ -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<AlbumSongItem>,
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<Album>,
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<Artist>,
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<Playlist>,
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))) },
)
}
}
@@ -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<Album> = emptyList(),
val artists: List<Artist> = emptyList(),
val playlists: List<Playlist> = emptyList(),
val songs: List<AlbumSongItem> = 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<String> = _query.asStateFlow()
val results: StateFlow<SearchResults> =
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)
}
}
+1
View File
@@ -119,6 +119,7 @@
<string name="playlists_empty">No playlists yet.</string>
<string name="sync">Sync</string>
<string name="list_options">List options</string>
<string name="search">Search</string>
<string name="search_this_list">Search this list</string>
<string name="sort_by">Sort by</string>
<string name="sort_ascending">Sort ascending</string>
@@ -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 ->
@@ -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<Album>()) },
artists = remember { flowOf(PagingData.empty<Artist>()) },
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
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<String>()
@@ -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<ComponentActivity>()
@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,
)
}
@@ -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<Context>()
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,
)
}