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 个服务器 + 搜索历史 + 清除 + 搜索结果 + 没有找到匹配内容 个人媒体中心 未连接 更多