mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
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:
@@ -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))) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user