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 }