Files
lumaplayer/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt
T
2026-08-31 21:59:17 +08:00

813 lines
31 KiB
Kotlin

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.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.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
import com.example.myapplication.data.EmbyRepository
import com.example.myapplication.data.EmbyServer
import com.example.myapplication.ui.GlassSearchField
import com.example.myapplication.ui.GlassTokens
import com.example.myapplication.ui.LiquidGlassTopBar
import com.example.myapplication.ui.LumaEmptyStateContainer
import com.example.myapplication.ui.bottomBarContentPadding
import com.example.myapplication.ui.design.LumaColors
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.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(
query: String,
onQueryChange: (String) -> Unit,
onGearClick: () -> Unit,
modifier: Modifier = Modifier,
) {
LiquidGlassTopBar(modifier) {
Box(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
GlassSearchField(
value = query,
onValueChange = onQueryChange,
placeholder = stringResource(Res.string.search_hint),
onGearClick = onGearClick,
useBackdropGlass = false,
)
}
}
}
// ─── 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 = {},
) {
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("") }
// 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()
// Empty query → clear everything
if (term.isBlank() || servers.isEmpty()) {
completedResults.clear()
pendingServers.clear()
completedIds.clear()
searching = false
lastSearchedQuery = ""
SearchResultsCache.clear()
return@LaunchedEffect
}
// ★ Cache check #1: same query + results already in memory → skip
if (term == lastSearchedQuery && completedResults.isNotEmpty()) {
return@LaunchedEffect
}
// ★ Cache check #2: restore results even when this screen was recreated
// after returning from SearchAllScreen. The remembered query is lost
// with the old composition, but the process-level cache survives it.
val cached = SearchResultsCache.get(term)
if (cached != null) {
completedResults.clear()
completedResults.addAll(cached.completedResults)
completedIds.clear()
completedIds.addAll(cached.completedResults.map { it.server.id })
pendingServers.clear()
pendingServers.addAll(servers.filter { it.id in cached.pendingServerIds })
lastSearchedQuery = term
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
}
}
}
}
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()) {
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,
)
}
Box(
Modifier
.weight(1f),
) {
when {
// No servers configured
servers.isEmpty() -> SearchEmptyState(
title = stringResource(Res.string.search_no_server),
subtitle = stringResource(Res.string.search_no_server_sub),
)
// Empty query + no history
query.isBlank() && history.isEmpty() -> SearchEmptyState(
title = stringResource(Res.string.search_title),
subtitle = stringResource(Res.string.search_empty_input),
)
// 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 -> {
LumaEmptyStateContainer(
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),
verticalOffset = (-58).dp,
)
}
// 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().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().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) {
LumaEmptyStateContainer(
icon = LumaIcon.Search,
title = title,
description = subtitle,
verticalOffset = (-58).dp,
)
}
// ─── Media Poster Card ──────────────────────────────────────────────────────
@Composable
private fun SearchMediaPoster(
item: EmbyItem,
serverUrl: String,
onClick: () -> Unit,
) {
val imageUrl = EmbyRepository.buildImageUrl(serverUrl, item, maxWidth = 500)
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,
)
}
}
}