From f1b5a96c2fbd9dd12c738528a868365a59fa2de3 Mon Sep 17 00:00:00 2001
From: beyond
Date: Sun, 30 Aug 2026 22:17:01 +0800
Subject: [PATCH] =?UTF-8?q?1=E3=80=81=E5=AE=8C=E5=96=84=E3=80=81=E4=BC=98?=
=?UTF-8?q?=E5=8C=96=E6=90=9C=E7=B4=A2=E7=95=8C=E9=9D=A2=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../myapplication/data/EmbyApiModels.kt | 2 +
.../myapplication/data/EmbyRepository.kt | 2 +-
.../myapplication/navigation/AppNavHost.kt | 24 +
.../myapplication/navigation/AppRoute.kt | 4 +
.../myapplication/screens/SearchAllScreen.kt | 304 +++++++
.../myapplication/screens/SearchScreen.kt | 797 ++++++++++++++++--
app/src/main/res/values-en/strings.xml | 6 +
app/src/main/res/values/strings.xml | 6 +
8 files changed, 1051 insertions(+), 94 deletions(-)
create mode 100644 app/src/main/kotlin/com/example/myapplication/screens/SearchAllScreen.kt
diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt
index bdf0ebe..a31c0ea 100644
--- a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt
+++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt
@@ -73,6 +73,8 @@ data class EmbyItem(
@SerializedName("Bitrate") val bitrate: Int? = null,
@SerializedName("Chapters") val _chapters: List? = emptyList(),
@SerializedName("UserData") val userData: EmbyUserData? = null,
+ @SerializedName("RecursiveChildCount") val recursiveChildCount: Int? = null,
+ @SerializedName("ChildCount") val childCount: Int? = null,
) {
val genres: List get() = _genres.orEmpty()
val backdropImageTags: List get() = _backdropImageTags.orEmpty()
diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt
index 67ad18e..2b7b5b5 100644
--- a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt
+++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt
@@ -373,7 +373,7 @@ object EmbyRepository {
.appendQueryParameter("IncludeItemTypes", "Movie,Series")
.appendQueryParameter("Limit", limit.toString())
.appendQueryParameter("SortBy", "SortName")
- .appendQueryParameter("Fields", "PrimaryImageAspectRatio,Overview,Genres,ProductionYear,ProviderIds,UserData")
+ .appendQueryParameter("Fields", "PrimaryImageAspectRatio,Overview,Genres,ProductionYear,ProviderIds,UserData,RecursiveChildCount,ChildCount")
.appendQueryParameter("EnableImages", "true")
.appendQueryParameter("EnableUserData", "true")
.appendQueryParameter("EnableImageTypes", "Primary,Backdrop,Thumb,Logo")
diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt
index 4c3f242..c89c709 100644
--- a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt
+++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt
@@ -37,6 +37,7 @@ import com.example.myapplication.screens.LibraryScreen
import com.example.myapplication.screens.MediaDetailScreen
import com.example.myapplication.screens.RecordsScreen
import com.example.myapplication.screens.RecordsTopBar
+import com.example.myapplication.screens.SearchAllScreen
import com.example.myapplication.screens.SearchScreen
import com.example.myapplication.screens.SearchTopBar
import com.example.myapplication.screens.SettingsScreen
@@ -160,13 +161,36 @@ fun AppNavHost(
Box(Modifier.weight(1f)) {
SearchScreen(
query = query,
+ servers = servers,
activeServer = selectedServer,
onOpenDetail = openDetail,
+ onViewAll = { serverId ->
+ navController.navigate(AppRoute.SearchAll.create(serverId, query)) {
+ launchSingleTop = true
+ }
+ },
+ onQueryChange = { query = it },
)
}
}
}
+ composable(
+ route = AppRoute.SearchAll.pattern,
+ arguments = listOf(
+ navArgument("serverId") { type = NavType.StringType },
+ navArgument("query") { type = NavType.StringType },
+ ),
+ ) { entry ->
+ SearchAllScreen(
+ serverId = entry.arguments?.getString("serverId").orEmpty(),
+ initialQuery = entry.arguments?.getString("query").orEmpty(),
+ servers = servers,
+ onOpenDetail = openDetail,
+ onBack = { navController.popBackStack() },
+ )
+ }
+
composable(
route = AppRoute.MediaDetail.pattern,
arguments = listOf(
diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt
index 977123b..5a6b93a 100644
--- a/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt
+++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt
@@ -26,6 +26,10 @@ sealed interface AppRoute {
override val pattern = "library/server/{serverId}"
fun create(serverId: String? = null) = "library/server/${(serverId ?: "-").encode()}"
}
+ data object SearchAll : AppRoute {
+ override val pattern = "search/all/{serverId}/{query}"
+ fun create(serverId: String, query: String) = "search/all/${serverId.encode()}/${query.encode()}"
+ }
}
private fun String.encode(): String = Uri.encode(this)
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SearchAllScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SearchAllScreen.kt
new file mode 100644
index 0000000..a0813ee
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/screens/SearchAllScreen.kt
@@ -0,0 +1,304 @@
+package com.example.myapplication.screens
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coil.compose.AsyncImage
+import coil.request.ImageRequest
+import com.example.myapplication.R as Res
+import com.example.myapplication.data.EmbyItem
+import com.example.myapplication.data.EmbyRepository
+import com.example.myapplication.data.EmbyServer
+import com.example.myapplication.ui.GlassTokens
+import com.example.myapplication.ui.design.LumaColors
+import com.example.myapplication.ui.design.LumaEmptyState
+import com.example.myapplication.ui.design.LumaErrorState
+import com.example.myapplication.ui.design.LumaIcon
+import com.example.myapplication.ui.design.LumaIconView
+import com.example.myapplication.ui.design.LumaLoading
+import com.example.myapplication.ui.design.LumaPageTopBar
+import com.example.myapplication.ui.design.LumaSkeleton
+import com.example.myapplication.ui.design.LumaShapes
+import top.yukonga.miuix.kmp.basic.Text
+
+@Composable
+fun SearchAllScreen(
+ serverId: String,
+ initialQuery: String,
+ servers: List,
+ onOpenDetail: (String) -> Unit = {},
+ onBack: () -> Unit = {},
+) {
+ val server = remember(serverId) { servers.firstOrNull { it.id == serverId } }
+ var results by remember(serverId, initialQuery) { mutableStateOf>(emptyList()) }
+ var loading by remember(serverId, initialQuery) { mutableStateOf(true) }
+ var error by remember(serverId, initialQuery) { mutableStateOf(false) }
+
+ androidx.compose.runtime.LaunchedEffect(serverId, initialQuery) {
+ val term = initialQuery.trim()
+ if (server == null || server.apiKey.isBlank() || term.isBlank()) {
+ results = emptyList()
+ loading = false
+ return@LaunchedEffect
+ }
+ loading = true
+ try {
+ results = com.example.myapplication.data.EmbyRepository.searchItems(server, term)
+ error = false
+ } catch (_: kotlinx.coroutines.CancellationException) {
+ throw kotlinx.coroutines.CancellationException()
+ } catch (_: Exception) {
+ results = emptyList()
+ error = true
+ }
+ loading = false
+ }
+
+ Column(Modifier.fillMaxSize()) {
+ // Server header: icon + name + back
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .statusBarsPadding()
+ .padding(horizontal = 18.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ com.example.myapplication.ui.design.LumaIconButton(
+ icon = LumaIcon.Back,
+ contentDescription = stringResource(Res.string.back),
+ onClick = onBack,
+ selected = false,
+ level = com.example.myapplication.ui.design.LumaGlassLevel.Floating,
+ iconSize = 20.dp,
+ radius = 24.dp,
+ )
+ Spacer(Modifier.size(12.dp))
+
+ // Server icon
+ Box(
+ modifier = Modifier
+ .size(32.dp)
+ .clip(CircleShape)
+ .background(LumaColors.backgroundElevated),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (server != null && server.iconUrl.isNotBlank()) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(server.iconUrl)
+ .crossfade(true)
+ .build(),
+ contentDescription = server.name,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(32.dp)
+ .clip(CircleShape),
+ )
+ } else {
+ LumaIconView(
+ icon = LumaIcon.Server,
+ contentDescription = null,
+ tint = GlassTokens.subtext,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+ Spacer(Modifier.width(10.dp))
+
+ // Server name + result count
+ Column(Modifier.weight(1f)) {
+ Text(
+ text = server?.name?.ifBlank { server.host }.orEmpty(),
+ color = LumaColors.text,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+
+ // Results count
+ if (!loading && results.isNotEmpty()) {
+ Text(
+ text = "${results.size} ${stringResource(Res.string.search)}",
+ color = GlassTokens.subtext,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(horizontal = 18.dp, vertical = 2.dp),
+ )
+ }
+
+ // Content
+ Box(
+ Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp),
+ ) {
+ when {
+ loading -> {
+ Column(
+ Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ LumaLoading(label = stringResource(Res.string.loading_search))
+ }
+ }
+ error -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ LumaErrorState(
+ title = stringResource(Res.string.error_content_title),
+ description = stringResource(Res.string.error_content_sub),
+ retryLabel = stringResource(Res.string.retry),
+ onRetry = { loading = true },
+ )
+ }
+ }
+ results.isEmpty() -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ LumaEmptyState(
+ icon = LumaIcon.Search,
+ title = stringResource(Res.string.search_all_empty),
+ description = stringResource(Res.string.search_no_result_prefix) + initialQuery.trim() + stringResource(Res.string.search_no_result_suffix),
+ showIconBackground = false,
+ )
+ }
+ }
+ else -> {
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(115.dp),
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ items(items = results, key = { it.id }) { item ->
+ SearchAllMediaCard(
+ item = item,
+ serverUrl = server?.fullUrl.orEmpty(),
+ onClick = { onOpenDetail(item.id) },
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SearchAllMediaCard(
+ item: EmbyItem,
+ serverUrl: String,
+ onClick: () -> Unit,
+) {
+ val imageUrl = EmbyRepository.buildImageUrl(serverUrl, item, maxWidth = 500)
+ val title = item.seriesName?.takeIf { item.type == "Episode" && it.isNotBlank() } ?: item.name
+ val yearText = item.productionYear?.toString() ?: ""
+ val episodeCount = item.recursiveChildCount ?: item.childCount
+
+ Column(
+ modifier = Modifier
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() },
+ onClick = onClick,
+ ),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(0.68f)
+ .clip(RoundedCornerShape(LumaShapes.small))
+ .background(LumaColors.backgroundElevated),
+ ) {
+ if (imageUrl != null) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(imageUrl)
+ .crossfade(true)
+ .build(),
+ contentDescription = title,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+
+ if (episodeCount != null && episodeCount > 0) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(end = 6.dp, bottom = 6.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .background(Color.Black.copy(alpha = 0.65f))
+ .padding(horizontal = 6.dp, vertical = 3.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "$episodeCount",
+ color = Color.White,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ }
+ }
+
+ Text(
+ text = title,
+ color = GlassTokens.text,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ if (yearText.isNotEmpty()) {
+ Text(
+ text = yearText,
+ color = GlassTokens.subtext,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Normal,
+ maxLines = 1,
+ )
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt
index 15e37ec..375f78b 100644
--- a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt
+++ b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt
@@ -1,38 +1,54 @@
package com.example.myapplication.screens
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
-import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.grid.GridCells
-import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
-import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.example.myapplication.R as Res
import com.example.myapplication.data.EmbyItem
@@ -42,13 +58,70 @@ import com.example.myapplication.ui.GlassSearchField
import com.example.myapplication.ui.GlassTokens
import com.example.myapplication.ui.LiquidGlassTopBar
import com.example.myapplication.ui.bottomBarContentPadding
+import com.example.myapplication.ui.design.LumaColors
import com.example.myapplication.ui.design.LumaEmptyState
import com.example.myapplication.ui.design.LumaErrorState
import com.example.myapplication.ui.design.LumaIcon
-import com.example.myapplication.ui.design.LumaMediaCard
+import com.example.myapplication.ui.design.LumaIconView
+import com.example.myapplication.ui.design.LumaLoading
import com.example.myapplication.ui.design.LumaSkeleton
+import com.example.myapplication.ui.design.LumaShapes
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import org.json.JSONArray
import top.yukonga.miuix.kmp.basic.Text
+import kotlin.math.min
+
+// ─── Data ───────────────────────────────────────────────────────────────────
+
+private data class ServerSearchResult(
+ val server: EmbyServer,
+ val items: List,
+ val loading: Boolean,
+ val error: Boolean,
+)
+
+// ─── Search History Persistence ─────────────────────────────────────────────
+
+private const val PREFS_NAME = "search_history"
+private const val HISTORY_KEY = "history"
+private const val MAX_HISTORY = 20
+
+private fun loadSearchHistory(context: android.content.Context): List {
+ val prefs = context.getSharedPreferences(PREFS_NAME, android.content.Context.MODE_PRIVATE)
+ val json = prefs.getString(HISTORY_KEY, null) ?: return emptyList()
+ return try {
+ val array = JSONArray(json)
+ (0 until min(array.length(), MAX_HISTORY)).map { array.getString(it) }
+ } catch (_: Exception) {
+ emptyList()
+ }
+}
+
+private fun saveSearchQuery(context: android.content.Context, query: String) {
+ val trimmed = query.trim()
+ if (trimmed.isBlank()) return
+ val current = loadSearchHistory(context).toMutableList()
+ current.remove(trimmed)
+ current.add(0, trimmed)
+ val trimmedList = current.take(MAX_HISTORY)
+ val array = JSONArray()
+ trimmedList.forEach { array.put(it) }
+ context.getSharedPreferences(PREFS_NAME, android.content.Context.MODE_PRIVATE)
+ .edit()
+ .putString(HISTORY_KEY, array.toString())
+ .apply()
+}
+
+private fun clearSearchHistory(context: android.content.Context) {
+ context.getSharedPreferences(PREFS_NAME, android.content.Context.MODE_PRIVATE)
+ .edit()
+ .remove(HISTORY_KEY)
+ .apply()
+}
+
+// ─── SearchTopBar ───────────────────────────────────────────────────────────
@Composable
fun SearchTopBar(
@@ -74,106 +147,575 @@ fun SearchTopBar(
}
}
+// ─── Search Results Cache ───────────────────────────────────────────────────
+
+private data class CachedSearchResults(
+ val completedResults: List,
+ val pendingServerIds: List,
+)
+
+private object SearchResultsCache {
+ private var cachedQuery: String = ""
+ private var cachedResults: CachedSearchResults? = null
+
+ @Synchronized
+ fun get(query: String): CachedSearchResults? {
+ return if (query == cachedQuery) cachedResults else null
+ }
+
+ @Synchronized
+ fun put(query: String, results: CachedSearchResults) {
+ cachedQuery = query
+ cachedResults = results
+ }
+
+ @Synchronized
+ fun clear() {
+ cachedQuery = ""
+ cachedResults = null
+ }
+}
+
+// ─── SearchScreen ───────────────────────────────────────────────────────────
+
@Composable
fun SearchScreen(
query: String,
+ servers: List = emptyList(),
activeServer: EmbyServer? = null,
onOpenDetail: (String) -> Unit = {},
+ onViewAll: (String) -> Unit = {},
+ onQueryChange: (String) -> Unit = {},
) {
- var results by remember(activeServer?.id) { mutableStateOf>(emptyList()) }
- var loading by remember(activeServer?.id) { mutableStateOf(false) }
- var loadError by remember(activeServer?.id) { mutableStateOf(false) }
- var reloadToken by remember { mutableStateOf(0) }
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val completedResults = remember { mutableStateListOf() }
+ val pendingServers = remember { mutableStateListOf() }
+ val completedIds = remember { mutableSetOf() }
+ var searching by remember { mutableStateOf(false) }
+ var historyVersion by remember { mutableIntStateOf(0) }
+ var lastSearchedQuery by remember { mutableStateOf("") }
- LaunchedEffect(query, activeServer?.id, activeServer?.apiKey, reloadToken) {
- val server = activeServer
+ // Stabilize LaunchedEffect key — use only server IDs, not list reference
+ val serverIdsKey = remember(servers) { servers.map { it.id }.joinToString(",") }
+
+ LaunchedEffect(query, serverIdsKey) {
val term = query.trim()
- if (server == null || server.apiKey.isBlank() || term.isBlank()) {
- results = emptyList()
- loading = false
- loadError = false
+
+ // Empty query → clear everything
+ if (term.isBlank() || servers.isEmpty()) {
+ completedResults.clear()
+ pendingServers.clear()
+ completedIds.clear()
+ searching = false
+ lastSearchedQuery = ""
+ SearchResultsCache.clear()
return@LaunchedEffect
}
- delay(280)
- loading = true
- try {
- results = EmbyRepository.searchItems(server, term)
- loadError = false
- } catch (_: Exception) {
- results = emptyList()
- loadError = true
+
+ // ★ Cache check #1: same query + results already in memory → skip
+ if (term == lastSearchedQuery && completedResults.isNotEmpty()) {
+ return@LaunchedEffect
+ }
+
+ // ★ Cache check #2: same query + cache hit → restore from cache
+ if (term == lastSearchedQuery) {
+ val cached = SearchResultsCache.get(term)
+ if (cached != null) {
+ completedResults.clear()
+ completedResults.addAll(cached.completedResults)
+ pendingServers.clear()
+ searching = false
+ return@LaunchedEffect
+ }
+ }
+
+ // ★ Cache check #3: same query + still searching → don't restart
+ if (term == lastSearchedQuery && searching) {
+ return@LaunchedEffect
+ }
+
+ val searchableServers = servers.filter { it.apiKey.isNotBlank() }
+ if (searchableServers.isEmpty()) {
+ completedResults.clear()
+ pendingServers.clear()
+ completedIds.clear()
+ searching = false
+ lastSearchedQuery = ""
+ return@LaunchedEffect
+ }
+
+ // ★ Set searching = true BEFORE any state clear or debounce
+ // This prevents the error/empty state from flashing
+ searching = true
+ lastSearchedQuery = term
+ saveSearchQuery(context, term)
+
+ delay(350) // debounce
+
+ // Clear previous results AFTER setting searching = true
+ completedResults.clear()
+ pendingServers.clear()
+ completedIds.clear()
+ pendingServers.addAll(searchableServers)
+
+ // Search each server independently
+ val totalServers = searchableServers.size
+ searchableServers.forEach { server ->
+ scope.launch {
+ try {
+ val items = EmbyRepository.searchItems(server, term)
+ val result = ServerSearchResult(server, items, false, false)
+ if (server.id !in completedIds) {
+ completedIds.add(server.id)
+ completedResults.add(result)
+ pendingServers.removeAll { it.id == server.id }
+ }
+ } catch (_: CancellationException) {
+ throw CancellationException()
+ } catch (_: Exception) {
+ val result = ServerSearchResult(server, emptyList(), false, true)
+ if (server.id !in completedIds) {
+ completedIds.add(server.id)
+ completedResults.add(result)
+ pendingServers.removeAll { it.id == server.id }
+ }
+ }
+ // ★ Cache after EVERY server completes (not just at the end)
+ // So partial results are available if user navigates away mid-search
+ SearchResultsCache.put(
+ term,
+ CachedSearchResults(
+ completedResults = completedResults.toList(),
+ pendingServerIds = pendingServers.map { it.id },
+ ),
+ )
+ if (completedIds.size >= totalServers) {
+ searching = false
+ }
+ }
}
- loading = false
}
+
+ val history = remember(query, historyVersion) {
+ if (query.isBlank()) loadSearchHistory(context) else emptyList()
+ }
+
+ // Derived states for UI rendering
+ val hasAnyResults = remember(completedResults) { completedResults.any { it.items.isNotEmpty() } }
+ val hasAnyErrors = remember(completedResults) { completedResults.any { it.error } }
+ val allEmpty = remember(completedResults, searching) {
+ !searching && completedResults.isNotEmpty() && completedResults.all { it.items.isEmpty() && !it.error }
+ }
+ val allErrors = remember(completedResults, searching) {
+ !searching && completedResults.isNotEmpty() && completedResults.all { it.error }
+ }
+
Box(Modifier.fillMaxSize()) {
- Box(
- Modifier
- .fillMaxSize()
- .padding(horizontal = 16.dp)
- .padding(bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()),
- ) {
- when {
- activeServer == null -> SearchEmptyState(
- title = stringResource(Res.string.search_no_server),
- subtitle = stringResource(Res.string.search_no_server_sub),
+ Column(Modifier.fillMaxSize()) {
+ // Loading progress bar — always visible during search
+ AnimatedVisibility(
+ visible = searching,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth().height(2.dp),
+ color = GlassTokens.accent,
+ trackColor = Color.Transparent,
)
- query.isBlank() -> SearchEmptyState(
- title = stringResource(Res.string.search_title),
- subtitle = stringResource(Res.string.search_empty_input),
- )
- loading -> LazyVerticalGrid(
- columns = GridCells.Adaptive(minSize = 150.dp),
- modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(
- top = 16.dp,
- bottom = bottomBarContentPadding(86.dp).calculateBottomPadding(),
- ),
- verticalArrangement = Arrangement.spacedBy(14.dp),
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- items(6) {
- LumaSkeleton(Modifier.fillMaxWidth().aspectRatio(0.68f), RoundedCornerShape(16.dp))
- }
- }
- loadError -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- LumaErrorState(
- title = stringResource(Res.string.error_content_title),
- description = stringResource(Res.string.error_content_sub),
- retryLabel = stringResource(Res.string.retry),
- onRetry = { reloadToken++ },
+ }
+
+ Box(
+ Modifier
+ .weight(1f)
+ .padding(bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()),
+ ) {
+ when {
+ // No servers configured
+ servers.isEmpty() -> SearchEmptyState(
+ title = stringResource(Res.string.search_no_server),
+ subtitle = stringResource(Res.string.search_no_server_sub),
)
- }
- results.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- LumaEmptyState(
- icon = LumaIcon.Search,
- title = stringResource(Res.string.search_no_result_title),
- description = stringResource(Res.string.search_no_result_prefix) + query.trim() + stringResource(Res.string.search_no_result_suffix),
- showIconBackground = false,
+ // Empty query + no history
+ query.isBlank() && history.isEmpty() -> SearchEmptyState(
+ title = stringResource(Res.string.search_title),
+ subtitle = stringResource(Res.string.search_empty_input),
)
- }
- else -> LazyVerticalGrid(
- columns = GridCells.Adaptive(minSize = 150.dp),
- modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(
- top = 16.dp,
- bottom = bottomBarContentPadding(86.dp).calculateBottomPadding(),
- ),
- verticalArrangement = Arrangement.spacedBy(14.dp),
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- items(results, key = { it.id }) { item ->
- SearchMediaCard(
- item = item,
- serverUrl = activeServer.fullUrl,
- onClick = { onOpenDetail(item.id) },
- )
+ // Empty query + has history
+ query.isBlank() && history.isNotEmpty() -> SearchHistoryList(
+ history = history,
+ onSelect = { onQueryChange(it) },
+ onClear = {
+ clearSearchHistory(context)
+ historyVersion++
+ },
+ )
+ // ★ KEY FIX: searching = true always shows loading, never error/empty
+ // This prevents the flash of error/empty state during transitions
+ searching -> CenteredLoading()
+ // Search complete: all servers returned errors
+ allErrors -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ LumaErrorState(
+ title = stringResource(Res.string.error_content_title),
+ description = stringResource(Res.string.error_content_sub),
+ retryLabel = stringResource(Res.string.retry),
+ onRetry = { lastSearchedQuery = "" },
+ )
+ }
}
+ // Search complete: all servers returned empty
+ allEmpty -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ LumaEmptyState(
+ icon = LumaIcon.Search,
+ title = stringResource(Res.string.search_no_result_title),
+ description = stringResource(Res.string.search_no_result_prefix) + query.trim() + stringResource(Res.string.search_no_result_suffix),
+ showIconBackground = false,
+ )
+ }
+ }
+ // Has results (even if some are still loading or errored)
+ else -> ServerResultsList(
+ completedResults = completedResults,
+ pendingServers = pendingServers,
+ onOpenDetail = onOpenDetail,
+ onViewAll = onViewAll,
+ )
}
}
}
}
}
+// ─── Centered Loading ───────────────────────────────────────────────────────
+
+@Composable
+private fun CenteredLoading() {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ LumaLoading(label = stringResource(Res.string.loading_search))
+ }
+ }
+}
+
+// ─── Search History List ────────────────────────────────────────────────────
+
+@Composable
+private fun SearchHistoryList(
+ history: List,
+ onSelect: (String) -> Unit,
+ onClear: () -> Unit,
+) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(top = 8.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()),
+ ) {
+ item(key = "header") {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(Res.string.search_history),
+ color = GlassTokens.text,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ Box(
+ Modifier
+ .heightIn(min = 48.dp)
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() },
+ onClick = onClear,
+ )
+ .padding(horizontal = 8.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = stringResource(Res.string.search_history_clear),
+ color = GlassTokens.accent,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ }
+ }
+
+ items(items = history, key = { it }) { query ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() },
+ onClick = { onSelect(query) },
+ )
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ LumaIconView(
+ icon = LumaIcon.Search,
+ contentDescription = null,
+ tint = GlassTokens.subtext,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(Modifier.width(14.dp))
+ Text(
+ text = query,
+ color = GlassTokens.text,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Normal,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ }
+}
+
+// ─── Server Results List (completion-order) ─────────────────────────────────
+
+@Composable
+private fun ServerResultsList(
+ completedResults: SnapshotStateList,
+ pendingServers: SnapshotStateList,
+ onOpenDetail: (String) -> Unit,
+ onViewAll: (String) -> Unit,
+) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = 8.dp,
+ bottom = bottomBarContentPadding(86.dp).calculateBottomPadding(),
+ ),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ // Completed results — in completion order
+ items(
+ items = completedResults,
+ key = { "done_${it.server.id}" },
+ ) { result ->
+ if (result.items.isNotEmpty()) {
+ ServerSearchSection(
+ server = result.server,
+ items = result.items,
+ onOpenDetail = onOpenDetail,
+ onViewAll = { onViewAll(result.server.id) },
+ )
+ } else if (result.error) {
+ ServerErrorSection(server = result.server)
+ }
+ }
+
+ // Pending/loading servers — after all completed results
+ items(
+ items = pendingServers,
+ key = { "pending_${it.id}" },
+ ) { server ->
+ ServerLoadingSection(server = server)
+ }
+ }
+}
+
+// ─── Server Search Section ──────────────────────────────────────────────────
+
+@Composable
+private fun ServerSearchSection(
+ server: EmbyServer,
+ items: List,
+ onOpenDetail: (String) -> Unit,
+ onViewAll: () -> Unit,
+) {
+ Column(
+ modifier = Modifier.padding(start = 16.dp, end = 0.dp, top = 12.dp),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(end = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(32.dp)
+ .clip(CircleShape)
+ .background(GlassTokens.glassFillSoft),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (server.iconUrl.isNotBlank()) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(server.iconUrl)
+ .crossfade(true)
+ .build(),
+ contentDescription = server.name,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(32.dp)
+ .clip(CircleShape),
+ )
+ } else {
+ LumaIconView(
+ icon = LumaIcon.Server,
+ contentDescription = server.name,
+ tint = GlassTokens.subtext,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+
+ Spacer(Modifier.width(10.dp))
+
+ Text(
+ text = server.name.ifBlank { server.host },
+ color = GlassTokens.text,
+ fontSize = 17.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+
+ Row(
+ modifier = Modifier
+ .heightIn(min = 48.dp)
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() },
+ onClick = onViewAll,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(Res.string.search_view_all),
+ color = GlassTokens.accent,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Spacer(Modifier.width(2.dp))
+ LumaIconView(
+ icon = LumaIcon.ChevronRight,
+ contentDescription = null,
+ tint = GlassTokens.accent,
+ modifier = Modifier.size(16.dp),
+ )
+ }
+ }
+
+ Spacer(Modifier.height(10.dp))
+
+ LazyRow(
+ contentPadding = PaddingValues(end = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ items(items = items, key = { it.id }) { item ->
+ SearchMediaPoster(
+ item = item,
+ serverUrl = server.fullUrl,
+ onClick = { onOpenDetail(item.id) },
+ )
+ }
+ }
+ }
+}
+
+// ─── Server Loading Section ─────────────────────────────────────────────────
+
+@Composable
+private fun ServerLoadingSection(server: EmbyServer) {
+ Column(
+ modifier = Modifier.padding(start = 16.dp, end = 0.dp, top = 12.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ Modifier
+ .size(32.dp)
+ .clip(CircleShape)
+ .background(GlassTokens.glassFillSoft)
+ )
+ Spacer(Modifier.width(10.dp))
+ Box(
+ Modifier
+ .width(80.dp)
+ .height(18.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .background(GlassTokens.glassFillSoft)
+ )
+ }
+ Spacer(Modifier.height(10.dp))
+
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ items(4) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ LumaSkeleton(
+ Modifier
+ .width(115.dp)
+ .aspectRatio(0.68f),
+ RoundedCornerShape(LumaShapes.small),
+ )
+ Box(
+ Modifier
+ .width(72.dp)
+ .height(12.dp)
+ .clip(RoundedCornerShape(3.dp))
+ .background(GlassTokens.glassFillSoft),
+ )
+ }
+ }
+ }
+ }
+}
+
+// ─── Server Error Section ───────────────────────────────────────────────────
+
+@Composable
+private fun ServerErrorSection(server: EmbyServer) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(32.dp)
+ .clip(CircleShape)
+ .background(LumaColors.backgroundElevated),
+ contentAlignment = Alignment.Center,
+ ) {
+ LumaIconView(
+ icon = LumaIcon.Server,
+ contentDescription = server.name,
+ tint = GlassTokens.subtext,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ Spacer(Modifier.width(10.dp))
+ Text(
+ text = server.name.ifBlank { server.host },
+ color = GlassTokens.subtext,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ Spacer(Modifier.width(8.dp))
+ LumaIconView(
+ icon = LumaIcon.Warning,
+ contentDescription = null,
+ tint = GlassTokens.danger,
+ modifier = Modifier.size(16.dp),
+ )
+ }
+}
+
+// ─── Empty State ────────────────────────────────────────────────────────────
+
@Composable
private fun SearchEmptyState(title: String, subtitle: String) {
Box(
@@ -186,17 +728,86 @@ private fun SearchEmptyState(title: String, subtitle: String) {
}
}
+// ─── Media Poster Card ──────────────────────────────────────────────────────
+
@Composable
-private fun SearchMediaCard(item: EmbyItem, serverUrl: String, onClick: () -> Unit) {
+private fun SearchMediaPoster(
+ item: EmbyItem,
+ serverUrl: String,
+ onClick: () -> Unit,
+) {
val imageUrl = EmbyRepository.buildImageUrl(serverUrl, item, maxWidth = 500)
- LumaMediaCard(
- model = imageUrl?.let {
- ImageRequest.Builder(LocalContext.current).data(it).crossfade(true).build()
- },
- title = item.name,
- titleColor = GlassTokens.text,
- titleSize = 14.sp,
- titleSpacing = 7.dp,
- onClick = onClick,
- )
+ val posterAspectRatio = 0.68f
+ val title = item.seriesName?.takeIf { item.type == "Episode" && it.isNotBlank() } ?: item.name
+ val yearText = item.productionYear?.toString() ?: ""
+ val episodeCount = item.recursiveChildCount ?: item.childCount
+
+ Column(
+ modifier = Modifier
+ .width(115.dp)
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() },
+ onClick = onClick,
+ ),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(posterAspectRatio)
+ .clip(RoundedCornerShape(LumaShapes.small))
+ .background(LumaColors.backgroundElevated),
+ ) {
+ if (imageUrl != null) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(imageUrl)
+ .crossfade(true)
+ .build(),
+ contentDescription = title,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+
+ if (episodeCount != null && episodeCount > 0) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(end = 6.dp, bottom = 6.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .background(Color.Black.copy(alpha = 0.65f))
+ .padding(horizontal = 6.dp, vertical = 3.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "$episodeCount",
+ color = Color.White,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ }
+ }
+
+ Text(
+ text = title,
+ color = GlassTokens.text,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ if (yearText.isNotEmpty()) {
+ Text(
+ text = yearText,
+ color = GlassTokens.subtext,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Normal,
+ maxLines = 1,
+ )
+ }
+ }
}
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index 1cbe6f8..0278b52 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -50,6 +50,12 @@
No searchable media library
Connect an Emby server in the Server tab first
No matching content
+ View All
+ Searching %d servers
+ Search History
+ Clear
+ Search Results
+ No matching content
Personal Media Center
Not connected
More
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index be0c68e..01868f4 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -51,6 +51,12 @@
暂无可搜索的媒体库
请先在资源库中连接 Emby 服务器
没有找到匹配内容
+ 查看全部
+ 正在搜索 %d 个服务器
+ 搜索历史
+ 清除
+ 搜索结果
+ 没有找到匹配内容
个人媒体中心
未连接
更多