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