From a81c6a6cd66ea6d0c0579293930c9f4d1f6486ae Mon Sep 17 00:00:00 2001 From: Divyansh Goyal Date: Tue, 16 Jun 2026 01:58:17 +0530 Subject: [PATCH] feat(playlist): add search bar to local playlists (#2120) Local playlists now have the same search/filter bar that YouTube playlists already have. Tapping the search icon in the action row reveals a search bar that filters the track list by song title or artist name (case-insensitive). Filtering is applied to the existing Paging flow via PagingData.filter in LocalPlaylistViewModel, so no extra database queries are needed. The SearchBar UI mirrors the existing implementation in PlaylistScreen for visual and behavioral consistency. --- .../ui/screen/library/LocalPlaylistScreen.kt | 120 +++++++++++++++++- .../viewModel/LocalPlaylistViewModel.kt | 20 +++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LocalPlaylistScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LocalPlaylistScreen.kt index 10a2c8bb8..0fd73f047 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LocalPlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LocalPlaylistScreen.kt @@ -49,8 +49,10 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.sharp.Sort +import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Pause import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material3.AlertDialog import androidx.compose.material3.ElevatedButton @@ -58,11 +60,15 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -92,6 +98,8 @@ import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -186,6 +194,7 @@ import simpmusic.composeapp.generated.resources.download_button import simpmusic.composeapp.generated.resources.downloaded import simpmusic.composeapp.generated.resources.downloading import simpmusic.composeapp.generated.resources.reload +import simpmusic.composeapp.generated.resources.search import simpmusic.composeapp.generated.resources.sort_by import simpmusic.composeapp.generated.resources.suggest import simpmusic.composeapp.generated.resources.sync_playlist_warning @@ -253,6 +262,10 @@ fun LocalPlaylistScreen( initial = DownloadState.STATE_NOT_DOWNLOADED, ) var shouldHideTopBar by rememberSaveable { mutableStateOf(false) } + var showSearchBar by rememberSaveable { mutableStateOf(false) } + var searchBarHeightPx by remember { mutableStateOf(0) } + var query by rememberSaveable { mutableStateOf("") } + val density = LocalDensity.current var shouldShowSuggestions by rememberSaveable { mutableStateOf(false) } var shouldShowSuggestButton by rememberSaveable { mutableStateOf(false) } @@ -353,6 +366,18 @@ fun LocalPlaylistScreen( LaunchedEffect(key1 = firstItemVisible) { shouldHideTopBar = !firstItemVisible } + // Drive the paging filter in the ViewModel from the search query. + LaunchedEffect(query) { + viewModel.setSearchQuery(query) + } + // Reset the query when the search bar is closed, and jump to the top when opened. + LaunchedEffect(showSearchBar) { + if (showSearchBar) { + lazyState.animateScrollToItem(0) + } else { + query = "" + } + } val paletteState = rememberPaletteState() val hazeState = rememberHazeState(blurEnabled = true) var bitmap by remember { @@ -491,6 +516,11 @@ fun LocalPlaylistScreen( }, state = lazyState, ) { + if (showSearchBar) { + item(contentType = "searchBarSpacer") { + Spacer(modifier = Modifier.height(with(density) { searchBarHeightPx.toDp() })) + } + } item(contentType = "header") { Box( modifier = @@ -1265,6 +1295,17 @@ fun LocalPlaylistScreen( } } Spacer(Modifier.weight(1f)) + IconButton( + onClick = { showSearchBar = !showSearchBar }, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = "Search in playlist", + tint = if (isMobilePortrait) Color.White else Color.Gray, + modifier = Modifier.size(22.dp), + ) + } AnimatedVisibility( visible = uiState.filterState == FilterState.CustomOrder, @@ -1477,7 +1518,84 @@ fun LocalPlaylistScreen( ) } AnimatedVisibility( - visible = shouldHideTopBar, + visible = showSearchBar, + enter = fadeIn() + slideInVertically(), + exit = fadeOut() + slideOutVertically(), + ) { + Box( + Modifier + .fillMaxWidth() + .onGloballyPositioned { searchBarHeightPx = it.size.height } + .then( + if (isMobilePortrait) { + Modifier.hazeEffect(hazeState) { + blurEnabled = true + blurRadius = 24.dp + backgroundColor = mutedPaletteBg + tints = listOf(HazeTint(mutedPaletteBg.copy(alpha = 0.55f))) + } + } else { + Modifier.background(Color.Black) + }, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp) + .windowInsetsPadding(WindowInsets.statusBars), + verticalAlignment = Alignment.CenterVertically, + ) { + RippleIconButton( + resId = Res.drawable.baseline_arrow_back_ios_new_24, + ) { + navController.navigateUp() + } + SearchBar( + modifier = + Modifier + .height(50.dp) + .padding(horizontal = 12.dp) + .weight(1f), + colors = + SearchBarDefaults.colors().copy( + containerColor = Color.Transparent, + ), + inputField = { + CompositionLocalProvider(LocalTextStyle provides typo().bodySmall) { + SearchBarDefaults.InputField( + query = query, + onQueryChange = { query = it }, + onSearch = { showSearchBar = false }, + expanded = showSearchBar, + onExpandedChange = { showSearchBar = it }, + placeholder = { + Text( + stringResource(Res.string.search), + style = typo().bodyMedium, + ) + }, + ) + } + }, + expanded = false, + onExpandedChange = {}, + windowInsets = WindowInsets(0, 0, 0, 0), + ) { + } + IconButton( + onClick = { + showSearchBar = !showSearchBar + }, + ) { + Icon(Icons.Rounded.Close, null, tint = Color.White) + } + } + } + } + AnimatedVisibility( + visible = shouldHideTopBar && !showSearchBar, enter = fadeIn() + slideInVertically(), exit = fadeOut() + slideOutVertically(), ) { diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/LocalPlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/LocalPlaylistViewModel.kt index 82b3510ce..bef190e5b 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/LocalPlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/LocalPlaylistViewModel.kt @@ -200,6 +200,15 @@ class LocalPlaylistViewModel( val tracksPagingState: StateFlow>> get() = _tracksPagingState private val lazyTrackPagingItems: MutableStateFlow>?> = MutableStateFlow(null) + // Search/filter query for the local playlist track list. Applied to the paging flow + // (see getTracksPagingState) so filtering works without re-querying the database. + private val _searchQuery: MutableStateFlow = MutableStateFlow("") + val searchQuery: StateFlow get() = _searchQuery + + fun setSearchQuery(query: String) { + _searchQuery.value = query + } + fun setLazyTrackPagingItems(lazyPagingItems: LazyPagingItems>) { lazyTrackPagingItems.value = lazyPagingItems Logger.d(tag, "setLazyTrackPagingItems: ${lazyTrackPagingItems.value?.itemCount}") @@ -224,6 +233,17 @@ class LocalPlaylistViewModel( modifications.fold(pagingData) { data, actions -> applyActions(data, actions) } + }.combine(_searchQuery) { pagingData, query -> + if (query.isBlank()) { + pagingData + } else { + pagingData.filter { (song, _) -> + song.title.contains(query, ignoreCase = true) || + song.artistName + ?.joinToString(", ") + ?.contains(query, ignoreCase = true) == true + } + } }.collect { _tracksPagingState.value = it }