1、完善、优化搜索界面功能

This commit is contained in:
beyond committed 2026-08-30 22:17:01 +08:00
1 parent 7aad0982df
commit f1b5a96c2f
8 files changed
+1051 -94

No files matched your search

@@ -73,6 +73,8 @@ data class EmbyItem(
@SerializedName("Bitrate") val bitrate: Int? = null,
@SerializedName("Chapters") val _chapters: List<EmbyChapter>? = emptyList(),
@SerializedName("UserData") val userData: EmbyUserData? = null,
@SerializedName("RecursiveChildCount") val recursiveChildCount: Int? = null,
@SerializedName("ChildCount") val childCount: Int? = null,
) {
val genres: List<String> get() = _genres.orEmpty()
val backdropImageTags: List<String> get() = _backdropImageTags.orEmpty()
@@ -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")
@@ -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(
@@ -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)
@@ -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<EmbyServer>,
onOpenDetail: (String) -> Unit = {},
onBack: () -> Unit = {},
) {
val server = remember(serverId) { servers.firstOrNull { it.id == serverId } }
var results by remember(serverId, initialQuery) { mutableStateOf<List<EmbyItem>>(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,
)
}
}
}
@@ -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<EmbyItem>,
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<String> {
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<ServerSearchResult>,
val pendingServerIds: List<String>,
)
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<EmbyServer> = emptyList(),
activeServer: EmbyServer? = null,
onOpenDetail: (String) -> Unit = {},
onViewAll: (String) -> Unit = {},
onQueryChange: (String) -> Unit = {},
) {
var results by remember(activeServer?.id) { mutableStateOf<List<EmbyItem>>(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<ServerSearchResult>() }
val pendingServers = remember { mutableStateListOf<EmbyServer>() }
val completedIds = remember { mutableSetOf<String>() }
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<String>,
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<ServerSearchResult>,
pendingServers: SnapshotStateList<EmbyServer>,
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<EmbyItem>,
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,
)
}
}
}
+6
View File
@@ -50,6 +50,12 @@
<string name="search_no_server">No searchable media library</string>
<string name="search_no_server_sub">Connect an Emby server in the Server tab first</string>
<string name="search_no_result_title">No matching content</string>
<string name="search_view_all">View All</string>
<string name="search_loading_servers">Searching %d servers</string>
<string name="search_history">Search History</string>
<string name="search_history_clear">Clear</string>
<string name="search_all_title">Search Results</string>
<string name="search_all_empty">No matching content</string>
<string name="home_media_center">Personal Media Center</string>
<string name="home_not_connected">Not connected</string>
<string name="more">More</string>
+6
View File
@@ -51,6 +51,12 @@
<string name="search_no_server">暂无可搜索的媒体库</string>
<string name="search_no_server_sub">请先在资源库中连接 Emby 服务器</string>
<string name="search_no_result_title">没有找到匹配内容</string>
<string name="search_view_all">查看全部</string>
<string name="search_loading_servers">正在搜索 %d 个服务器</string>
<string name="search_history">搜索历史</string>
<string name="search_history_clear">清除</string>
<string name="search_all_title">搜索结果</string>
<string name="search_all_empty">没有找到匹配内容</string>
<string name="home_media_center">个人媒体中心</string>
<string name="home_not_connected">未连接</string>
<string name="more">更多</string>