1、完善、优化搜索界面功能
This commit is contained in:
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user