diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 24324d07..bee00f3b 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -1656,6 +1656,7 @@
%1$d playable files
All
Refresh cloud library
+ Search cloud library
Select provider
Select type
Ready to play
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
index 43447cda..d4d7a7f4 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
@@ -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
@@ -127,6 +130,7 @@ fun LibraryScreen(
}
var selectedProviderId by rememberSaveable { mutableStateOf(null) }
var selectedTypeName by rememberSaveable { mutableStateOf(null) }
+ var cloudSearchQuery by rememberSaveable { mutableStateOf("") }
val selectedType = remember(selectedTypeName) {
selectedTypeName?.let { runCatching { CloudLibraryItemType.valueOf(it) }.getOrNull() }
}
@@ -300,6 +304,11 @@ fun LibraryScreen(
selectedProviderId = selectedProviderId,
selectedType = selectedType,
selectedCloudItemKey = selectedCloudItemKey,
+ searchQuery = cloudSearchQuery,
+ onSearchQueryChange = {
+ cloudSearchQuery = it
+ selectedCloudItemKey = null
+ },
onProviderSelected = {
selectedProviderId = it
selectedTypeName = null
@@ -436,6 +445,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,
@@ -481,8 +492,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) {
@@ -507,6 +529,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() }
@@ -550,6 +580,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(