From 6cb94b7793c6bfd1a6504e76a978b03ef0f34442 Mon Sep 17 00:00:00 2001 From: beyond Date: Mon, 31 Aug 2026 15:54:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=8F=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../myapplication/data/TmdbRepository.kt | 92 +++++++++++++++++++ .../myapplication/navigation/AppNavHost.kt | 12 ++- .../screens/MediaDetailScreen.kt | 26 ++++-- .../myapplication/screens/RecordsScreen.kt | 7 +- 4 files changed, 123 insertions(+), 14 deletions(-) diff --git a/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt index d8f3ff1..816fa20 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt @@ -149,6 +149,7 @@ object TmdbRepository { private const val IMAGE_BASE = "https://image.tmdb.org/t/p/w342" private val gson = Gson() private val extrasCache = ConcurrentHashMap() + private val movieExtrasCache = ConcurrentHashMap() private val client = OkHttpClient.Builder() .connectTimeout(6, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) @@ -231,6 +232,97 @@ object TmdbRepository { } } + // ── Movie support ────────────────────────────────────────────────────── + + suspend fun getMovieExtras( + tmdbId: String?, + title: String, + year: Int?, + ): TmdbSeriesExtras = withContext(Dispatchers.IO) { + if (!isConfigured) return@withContext TmdbSeriesExtras() + Log.d("TmdbRepository", "getMovieExtras: tmdbId=$tmdbId, title=$title, year=$year") + val resolvedId = tmdbId?.toIntOrNull() + ?: searchMovieId(title, year) + ?: searchMovieId(title, null) + ?: return@withContext TmdbSeriesExtras().also { + Log.w("TmdbRepository", "getMovieExtras: could not resolve TMDB ID for '$title'") + } + Log.d("TmdbRepository", "getMovieExtras: resolvedId=$resolvedId") + movieExtrasCache[resolvedId]?.let { cached -> + Log.d("TmdbRepository", "getMovieExtras: returning cached result") + return@withContext cached + } + coroutineScope { + val cast = async { getMovieCredits(resolvedId) } + val recommendations = async { getMovieRecommendations(resolvedId) } + val result = TmdbSeriesExtras(cast.await(), recommendations.await()) + if (result.cast.isNotEmpty() || result.recommendations.isNotEmpty()) { + movieExtrasCache[resolvedId] = result + } + Log.d("TmdbRepository", "Loaded ${result.cast.size} movie cast and ${result.recommendations.size} movie recommendations") + result + } + } + + private fun searchMovieId(title: String, year: Int?): Int? { + if (title.isBlank()) return null + val result = performMovieSearch(title, year?.toString()) + Log.d("TmdbRepository", "searchMovieId: '$title' year=$year -> $result") + return result + } + + private fun performMovieSearch(title: String, year: String?): Int? { + val url = Uri.parse("$API_BASE/search/movie").buildUpon() + .appendQueryParameter("query", title) + .appendQueryParameter("language", language()) + .appendQueryParameter("include_adult", "false") + .apply { year?.let { appendQueryParameter("year", it) } } + .build() + return get(url.toString(), TmdbSearchResponse::class.java)?.results?.firstOrNull()?.id + } + + private fun getMovieCredits(movieId: Int): List { + val url = Uri.parse("$API_BASE/movie/$movieId/credits").buildUpon() + .appendQueryParameter("language", language()) + .build() + val result = get(url.toString(), TmdbCreditsResponse::class.java)?.cast.orEmpty() + .filter { it.name.isNotBlank() } + .take(24) + .map { item -> + TmdbCastMember( + id = item.id, + name = item.name, + originalName = item.originalName, + character = item.roles.firstOrNull()?.character.orEmpty(), + profileUrl = item.profilePath?.let { IMAGE_BASE + it }, + ) + } + Log.d("TmdbRepository", "getMovieCredits: movieId=$movieId -> ${result.size} cast") + return result + } + + private fun getMovieRecommendations(movieId: Int): List { + val url = Uri.parse("$API_BASE/movie/$movieId/recommendations").buildUpon() + .appendQueryParameter("language", language()) + .appendQueryParameter("page", "1") + .build() + val result = get(url.toString(), TmdbRecommendationsResponse::class.java)?.results.orEmpty() + .filter { it.name.isNotBlank() } + .take(20) + .map { item -> + TmdbRecommendation( + id = item.id, + name = item.name, + originalName = item.originalName, + posterUrl = item.posterPath?.let { IMAGE_BASE + it }, + firstAirYear = item.firstAirDate.take(4), + rating = item.voteAverage, + ) + } + Log.d("TmdbRepository", "getMovieRecommendations: movieId=$movieId -> ${result.size} recommendations") + return result + } + /** Get calendar episodes for all watchlist items. */ suspend fun getCalendarEpisodes(watchlist: List): List = withContext(Dispatchers.IO) { if (!isConfigured || watchlist.isEmpty()) return@withContext emptyList() diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt index e58a541..c774d88 100644 --- a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt +++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt @@ -87,6 +87,16 @@ fun AppNavHost( } } + val openHome: () -> Unit = { + if (navController.currentDestination?.route != AppRoute.Home.pattern) { + navController.navigate(AppRoute.Home.pattern) { + popUpTo(AppRoute.Home.pattern) { saveState = true } + launchSingleTop = true + restoreState = true + } + } + } + NavHost( navController = navController, startDestination = AppRoute.Home.pattern, @@ -130,7 +140,7 @@ fun AppNavHost( LibraryScreen( servers = servers, selectedServer = selectedServer, - onSelectServer = onSelectServer, + onSelectServer = { id -> onSelectServer(id); openHome() }, onDeleteServer = onDeleteServer, onEditServer = { server -> navigateOnce(AppRoute.ServerEditor.create(server.id)) }, isGrid = isGrid, 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 f695174..5d9b650 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt @@ -142,7 +142,7 @@ fun MediaDetailScreen( var resources by remember(item.id) { mutableStateOf>(emptyList()) } var tmdbExtras by remember(item.id) { mutableStateOf(TmdbSeriesExtras()) } var loadingTmdb by remember(item.id) { - mutableStateOf(item.type == "Series" && TmdbRepository.isConfigured) + mutableStateOf(TmdbRepository.isConfigured) } var isFavorited by remember(item.id, server?.id) { mutableStateOf(false) } var isWatching by remember(item.id, server?.id) { mutableStateOf(false) } @@ -331,14 +331,19 @@ fun MediaDetailScreen( .firstOrNull { it.key.equals("Tmdb", ignoreCase = true) } ?.value LaunchedEffect(detail.id, detail.name, detail.productionYear, tmdbId, loadingFullDetail) { - if (detail.type != "Series" || !TmdbRepository.isConfigured) return@LaunchedEffect - if (loadingFullDetail) { + if (!TmdbRepository.isConfigured) return@LaunchedEffect + if (detail.type == "Series" && loadingFullDetail) { loadingTmdb = true return@LaunchedEffect } - loadingTmdb = true + android.util.Log.d("MediaDetail", "TMDB loading: type=${detail.type}, tmdbId=$tmdbId, name=${detail.name}, year=${detail.productionYear}") + loadingTmdb = detail.type == "Series" tmdbExtras = try { - TmdbRepository.getSeriesExtras(tmdbId, detail.name, detail.productionYear) + if (detail.type == "Series") { + TmdbRepository.getSeriesExtras(tmdbId, detail.name, detail.productionYear) + } else { + TmdbRepository.getMovieExtras(tmdbId, detail.name, detail.productionYear) + } } catch (error: CancellationException) { throw error } catch (_: Exception) { @@ -346,6 +351,7 @@ fun MediaDetailScreen( } finally { loadingTmdb = false } + android.util.Log.d("MediaDetail", "TMDB result: cast=${tmdbExtras.cast.size}, recs=${tmdbExtras.recommendations.size}") } val castMembers = remember(detail.people, tmdbExtras.cast, server?.fullUrl) { @@ -454,7 +460,7 @@ fun MediaDetailScreen( // Favorite + Watchlist buttons (bottom, smaller) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically, ) { DetailSmallButton( @@ -471,7 +477,6 @@ fun MediaDetailScreen( }, ) if (detail.type == "Series") { - Spacer(Modifier.width(12.dp)) DetailSmallButton( icon = LumaIcon.Calendar, label = if (isWatching) stringResource(Res.string.detail_unwatch) else stringResource(Res.string.detail_watch), @@ -635,12 +640,12 @@ fun MediaDetailScreen( ResourceSection(resources = resources, loading = loadingResources) } - if (detail.type == "Series") { + if (castMembers.isNotEmpty() || loadingTmdb) { Spacer(Modifier.height(22.dp)) CastSection(castMembers, loading = loadingTmdb && castMembers.isEmpty()) } - if (detail.type == "Series") { + if (tmdbExtras.recommendations.isNotEmpty() || loadingTmdb) { Spacer(Modifier.height(22.dp)) RecommendationSection(tmdbExtras.recommendations, loading = loadingTmdb && tmdbExtras.recommendations.isEmpty()) } @@ -917,10 +922,11 @@ private fun DetailSmallButton( label: String, tint: Color, onClick: () -> Unit, + modifier: Modifier = Modifier, ) { LiquidGlassButton( onClick = onClick, - modifier = Modifier.height(36.dp).widthIn(min = 80.dp), + modifier = modifier.height(36.dp).widthIn(min = 100.dp), cornerRadius = 999.dp, fill = GlassTokens.glassFillStrong, ) { diff --git a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt index e9774b7..964997f 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt @@ -462,8 +462,7 @@ private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> }) Box( Modifier .weight(1f) - .fillMaxWidth() - .padding(bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()), + .fillMaxWidth(), ) { if (loading) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -488,7 +487,9 @@ private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> }) val groupedByDate = filteredEpisodes.groupBy { it.airDate }.toSortedMap() LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 16.dp), + contentPadding = PaddingValues( + bottom = 16.dp + bottomBarContentPadding(86.dp).calculateBottomPadding(), + ), ) { groupedByDate.forEach { (dateStr, episodes) -> val date = try { java.time.LocalDate.parse(dateStr) } catch (_: Exception) { null }