From b37cf6906852d46f2cd51256a5e724c7b4531990 Mon Sep 17 00:00:00 2001
From: beyond
Date: Mon, 31 Aug 2026 21:59:17 +0800
Subject: [PATCH] =?UTF-8?q?=E6=90=9C=E7=B4=A2=E7=95=8C=E9=9D=A2=E5=B0=8F?=
=?UTF-8?q?=E8=B0=83=E6=95=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../screens/MediaDetailScreen.kt | 151 +++++++++++-------
.../myapplication/screens/SearchScreen.kt | 24 +--
2 files changed, 103 insertions(+), 72 deletions(-)
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt
index 7c7832d..98ed087 100644
--- a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt
+++ b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt
@@ -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>(emptyList()) }
+ var selectedResourceServerId by remember(item.id) { mutableStateOf(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, loading: Boolean) {
+private fun ResourceSection(
+ resources: List,
+ 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, 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, 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)
- }
- }
-}
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 6d44de1..54c535d 100644
--- a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt
+++ b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt
@@ -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