813 lines
31 KiB
Kotlin
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,
|
|
)
|
|
}
|
|
}
|
|
}
|