搜索界面小调整

This commit is contained in:
beyond committed 2026-08-31 21:59:17 +08:00
1 parent d6149d08a1
commit b37cf69068
2 files changed
+103 -72

No files matched your search

@@ -111,6 +111,8 @@ private data class DetailCastMember(
val imageUrl: String?,
)
private val DetailPrimaryButtonWidth = 188.dp
@Composable
fun MediaDetailScreen(
item: EmbyItem,
@@ -140,6 +142,7 @@ fun MediaDetailScreen(
var useSelectedEpisodeForPlayback by remember(item.id) { mutableStateOf(false) }
var loadingResources by remember(item.id) { mutableStateOf(false) }
var resources by remember(item.id) { mutableStateOf<List<DetailResource>>(emptyList()) }
var selectedResourceServerId by remember(item.id) { mutableStateOf<String?>(null) }
var tmdbExtras by remember(item.id) { mutableStateOf(TmdbSeriesExtras()) }
var loadingTmdb by remember(item.id) {
mutableStateOf(TmdbRepository.isConfigured)
@@ -327,6 +330,14 @@ fun MediaDetailScreen(
loadingResources = false
}
LaunchedEffect(selectedPlayableItem?.id, resources, server?.id) {
selectedResourceServerId = resources
.firstOrNull { it.server.id == server?.id }
?.server
?.id
?: resources.firstOrNull()?.server?.id
}
val tmdbId = detail.providerIds.entries
.firstOrNull { it.key.equals("Tmdb", ignoreCase = true) }
?.value
@@ -462,12 +473,20 @@ fun MediaDetailScreen(
}
Spacer(Modifier.height(10.dp))
// Favorite + Watchlist buttons (bottom, smaller)
val showWatchlistButton = detail.type == "Series"
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
modifier = Modifier
.then(if (showWatchlistButton) Modifier.width(DetailPrimaryButtonWidth) else Modifier)
.align(Alignment.CenterHorizontally),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
DetailSmallButton(
modifier = if (showWatchlistButton) {
Modifier.weight(1f)
} else {
Modifier.widthIn(min = 100.dp)
},
icon = LumaIcon.Favorite,
label = if (isFavorited) stringResource(Res.string.detail_unfavorite) else stringResource(Res.string.detail_favorite),
tint = if (isFavorited) Color(0xFFFF3B30) else GlassTokens.text,
@@ -480,8 +499,9 @@ fun MediaDetailScreen(
}
},
)
if (detail.type == "Series") {
if (showWatchlistButton) {
DetailSmallButton(
modifier = Modifier.weight(1f),
icon = LumaIcon.Calendar,
label = if (isWatching) stringResource(Res.string.detail_unwatch) else stringResource(Res.string.detail_watch),
tint = if (isWatching) Color(0xFF34C759) else GlassTokens.text,
@@ -641,7 +661,12 @@ fun MediaDetailScreen(
if (selectedPlayableItem != null) {
Spacer(Modifier.height(22.dp))
ResourceSection(resources = resources, loading = loadingResources)
ResourceSection(
resources = resources,
loading = loadingResources,
selectedResourceServerId = selectedResourceServerId,
onResourceSelected = { selectedResourceServerId = it.server.id },
)
}
if (castMembers.isNotEmpty() || loadingTmdb) {
@@ -840,54 +865,55 @@ private fun DetailHeadline(detail: EmbyItem, serverUrl: String) {
}
}
detail.taglines.firstOrNull()?.let { tagline ->
Spacer(Modifier.height(6.dp))
Spacer(Modifier.height(9.dp))
Row(
Modifier.fillMaxWidth().height(20.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
detail.communityRating?.takeIf { it > 0.0 }?.let { rating ->
Text(
text = "★ ${String.format("%.1f", rating)}",
color = Color(0xFFFFC107),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.width(8.dp))
}
detail.productionYear?.let { year ->
Text(
text = "$year",
color = LocalDetailText.current.copy(alpha = 0.88f),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
)
Spacer(Modifier.width(8.dp))
}
Text(
text = tagline,
color = LocalDetailAccent.current.copy(alpha = 0.90f),
text = listOfNotNull(
detail.type?.let {
when (it) {
"Series" -> stringResource(Res.string.detail_type_series)
"Movie" -> stringResource(Res.string.detail_type_movie)
else -> it
}
},
detail.genres.take(2).joinToString(" / ").takeIf { it.isNotBlank() },
).joinToString(" · "),
color = LocalDetailSecondary.current.copy(alpha = 0.82f),
fontSize = 12.sp,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(9.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
detail.communityRating?.takeIf { it > 0.0 }?.let { rating ->
LumaIconView(LumaIcon.Star, contentDescription = null, tint = Color(0xFFFFD54F), modifier = Modifier.size(14.dp))
Spacer(Modifier.width(3.dp))
Text(text = String.format("%.1f", rating), color = LocalDetailText.current, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.width(9.dp))
}
if (detail.productionYear != null) {
Text(text = "${detail.productionYear}", color = LocalDetailSecondary.current, fontSize = 12.sp)
Spacer(Modifier.width(8.dp))
}
formatRuntime(detail.runTimeTicks)?.let { runtime ->
Text(text = runtime, color = LocalDetailSecondary.current, fontSize = 12.sp)
}
}
Spacer(Modifier.height(6.dp))
Text(
text = when {
detail.type == "Series" -> stringResource(Res.string.detail_type_series)
detail.type == "Movie" -> stringResource(Res.string.detail_type_movie)
else -> detail.type ?: stringResource(Res.string.detail_type_media)
},
color = Color(0xFFBFDBFE),
fontSize = 11.sp,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun DetailPlayButton(resumePositionMs: Long, loading: Boolean, onClick: () -> Unit) {
LiquidGlassButton(
onClick = onClick,
modifier = Modifier.width(188.dp).height(48.dp),
modifier = Modifier.width(DetailPrimaryButtonWidth).height(48.dp),
enabled = !loading,
cornerRadius = 999.dp,
fill = GlassTokens.glassFillStrong,
@@ -930,7 +956,7 @@ private fun DetailSmallButton(
) {
LiquidGlassButton(
onClick = onClick,
modifier = modifier.height(36.dp).widthIn(min = 100.dp),
modifier = modifier.height(36.dp),
cornerRadius = 999.dp,
fill = GlassTokens.glassFillStrong,
) {
@@ -944,7 +970,7 @@ private fun DetailSmallButton(
Text(
text = label,
color = GlassTokens.text,
fontSize = 12.sp,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
}
@@ -1062,7 +1088,12 @@ private fun ResourceSkeletonRow(label: String) {
}
@Composable
private fun ResourceSection(resources: List<DetailResource>, loading: Boolean) {
private fun ResourceSection(
resources: List<DetailResource>,
loading: Boolean,
selectedResourceServerId: String?,
onResourceSelected: (DetailResource) -> Unit,
) {
DetailSectionTitle(stringResource(Res.string.detail_resources), LumaIcon.Server)
Spacer(Modifier.height(12.dp))
when {
@@ -1083,6 +1114,8 @@ private fun ResourceSection(resources: List<DetailResource>, loading: Boolean) {
ResourceCard(
resource = resource,
isBest = resources.size > 1 && bestBitrate > 0 && resourceVideoBitrate(resource) == bestBitrate,
selected = resource.server.id == selectedResourceServerId,
onClick = { onResourceSelected(resource) },
modifier = Modifier.width(cardWidth),
)
}
@@ -1112,7 +1145,13 @@ private fun ResourceSection(resources: List<DetailResource>, loading: Boolean) {
}
@Composable
private fun ResourceCard(resource: DetailResource, isBest: Boolean, modifier: Modifier = Modifier) {
private fun ResourceCard(
resource: DetailResource,
isBest: Boolean,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val source = resource.item.mediaSources.firstOrNull()
val streams = source?.mediaStreams.orEmpty().ifEmpty { resource.item.mediaStreams }
val video = streams.firstOrNull { it.type.equals("Video", ignoreCase = true) }
@@ -1131,11 +1170,16 @@ private fun ResourceCard(resource: DetailResource, isBest: Boolean, modifier: Mo
Brush.linearGradient(
listOf(
LocalDetailSurface.current,
LocalDetailAccent.current.copy(alpha = 0.08f),
LocalDetailAccent.current.copy(alpha = if (selected) 0.18f else 0.08f),
)
)
)
.border(1.dp, LocalDetailBorder.current, shape)
.border(
width = if (selected) 1.5.dp else 1.dp,
color = if (selected) LocalDetailAccent.current else LocalDetailBorder.current,
shape = shape,
)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.Center,
) {
@@ -1891,20 +1935,3 @@ private fun EpisodeCard(
)
}
}
@Composable
private fun formatRuntime(ticks: Long?): String? {
if (ticks == null || ticks <= 0) return null
val minutes = ticks / 600_000_000L
if (minutes <= 0) return null
return if (minutes < 60) {
stringResource(Res.string.detail_runtime_minutes, minutes.toInt())
} else {
val hours = (minutes / 60).toInt()
val remainingMinutes = (minutes % 60).toInt()
if (remainingMinutes == 0) {
stringResource(Res.string.detail_runtime_hours, hours)
} else {
stringResource(Res.string.detail_runtime_hours_minutes, hours, remainingMinutes)
}
}
}
@@ -218,16 +218,20 @@ fun SearchScreen(
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 #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