feat(library): search the cloud library

The Cloud view only had provider and type filters, so finding a specific
release in a large debrid account meant scrolling the whole list, even
though the item names and file names are already loaded on the client.

Adds a local search field under the toolbar that narrows the already
loaded items. It matches the item name or any of its file names, since
the useful identifier is often in the filename rather than the title,
and it stacks on top of the existing provider and type filters.
This commit is contained in:
YLaskco 2026-07-23 10:18:39 -04:00
parent b1c9d08435
commit 79269f461a
2 changed files with 64 additions and 1 deletions

View file

@ -1657,6 +1657,7 @@
<string name="cloud_library_playable_file_count">%1$d playable files</string>
<string name="cloud_library_provider_all">All</string>
<string name="cloud_library_refresh">Refresh cloud library</string>
<string name="cloud_library_search_label">Search cloud library</string>
<string name="cloud_library_select_provider">Select provider</string>
<string name="cloud_library_select_type">Select type</string>
<string name="cloud_library_status_ready">Ready to play</string>

View file

@ -31,15 +31,18 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.GridView
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.ViewAgenda
import com.nuvio.app.core.ui.NuvioLoadingIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -132,6 +135,7 @@ fun LibraryScreen(
}
var selectedProviderId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedTypeName by rememberSaveable { mutableStateOf<String?>(null) }
var cloudSearchQuery by rememberSaveable { mutableStateOf("") }
val selectedType = remember(selectedTypeName) {
selectedTypeName?.let { runCatching { CloudLibraryItemType.valueOf(it) }.getOrNull() }
}
@ -305,6 +309,11 @@ fun LibraryScreen(
selectedProviderId = selectedProviderId,
selectedType = selectedType,
selectedCloudItemKey = selectedCloudItemKey,
searchQuery = cloudSearchQuery,
onSearchQueryChange = {
cloudSearchQuery = it
selectedCloudItemKey = null
},
onProviderSelected = {
selectedProviderId = it
selectedTypeName = null
@ -443,6 +452,8 @@ private fun LazyListScope.cloudLibraryContent(
selectedProviderId: String?,
selectedType: CloudLibraryItemType?,
selectedCloudItemKey: String?,
searchQuery: String,
onSearchQueryChange: (String) -> Unit,
onProviderSelected: (String?) -> Unit,
onTypeSelected: (CloudLibraryItemType?) -> Unit,
onItemSelected: (CloudLibraryItem) -> Unit,
@ -488,8 +499,19 @@ private fun LazyListScope.cloudLibraryContent(
.distinct()
.sortedBy { type -> type.ordinal }
val effectiveSelectedType = selectedType?.takeIf { type -> type in availableTypes }
val filteredItems = providerItems
val typeFilteredItems = providerItems
.filter { item -> effectiveSelectedType == null || item.type == effectiveSelectedType }
// Local filter over the already-loaded library. Matches the item name or any of its
// file names, since the useful identifier is often in the filename, not the title.
val trimmedQuery = searchQuery.trim()
val filteredItems = if (trimmedQuery.isEmpty()) {
typeFilteredItems
} else {
typeFilteredItems.filter { item ->
item.name.contains(trimmedQuery, ignoreCase = true) ||
item.files.any { file -> file.name.contains(trimmedQuery, ignoreCase = true) }
}
}
val selectedItem = filteredItems.firstOrNull { it.stableKey == selectedCloudItemKey }
if (selectedItem != null) {
@ -514,6 +536,14 @@ private fun LazyListScope.cloudLibraryContent(
)
}
item(key = "cloud-library-search") {
CloudLibrarySearchField(
query = searchQuery,
onQueryChange = onSearchQueryChange,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
uiState.providers
.filter { providerState -> selectedProviderId == null || providerState.providerId == selectedProviderId }
.filter { providerState -> !providerState.errorMessage.isNullOrBlank() && providerState.items.isEmpty() }
@ -557,6 +587,38 @@ private fun LazyListScope.cloudLibraryContent(
}
}
@Composable
private fun CloudLibrarySearchField(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
placeholder = { Text(stringResource(Res.string.cloud_library_search_label)) },
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = null,
)
},
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = stringResource(Res.string.compose_search_clear),
)
}
}
},
)
}
private fun LazyListScope.cloudLibrarySkeletonItems() {
item(key = "cloud-library-skeleton-toolbar") {
CloudLibrarySkeletonToolbar(