From eaf5e432fff12910768d10e582fe585360d5f297 Mon Sep 17 00:00:00 2001
From: beyond
Date: Sat, 29 Aug 2026 16:58:58 +0800
Subject: [PATCH] =?UTF-8?q?1=E3=80=81=E5=A2=9E=E5=8A=A0=E5=89=A7=E9=9B=86?=
=?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E6=BC=94=E5=91=98=E8=A1=A8=EF=BC=8C?=
=?UTF-8?q?=E8=B5=84=E6=BA=90=E5=BA=93=E4=BF=A1=E6=81=AF=EF=BC=8C=E6=8E=A8?=
=?UTF-8?q?=E8=8D=90=EF=BC=8C=E5=AA=92=E4=BD=93=E4=BF=A1=E6=81=AF=EF=BC=8C?=
=?UTF-8?q?=E6=8E=A5=E5=85=A5tmdb?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
2、优化剧集详情页性能,UI,和数据请求逻辑
---
app/build.gradle.kts | 9 +
.../myapplication/data/EmbyApiModels.kt | 32 +
.../myapplication/data/EmbyRepository.kt | 166 ++-
.../myapplication/data/TmdbRepository.kt | 192 +++
.../myapplication/navigation/AppNavHost.kt | 4 +-
.../screens/MediaDetailScreen.kt | 1253 ++++++++++++++---
app/src/main/res/values-en/strings.xml | 58 +
app/src/main/res/values/strings.xml | 58 +
.../screens/MediaDetailDataTest.kt | 90 ++
9 files changed, 1611 insertions(+), 251 deletions(-)
create mode 100644 app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt
create mode 100644 app/src/test/kotlin/com/example/myapplication/screens/MediaDetailDataTest.kt
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index bda5155..5c1ce27 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,8 +1,16 @@
+import java.util.Properties
+
plugins {
id("com.android.application") version "9.0.1"
id("org.jetbrains.kotlin.plugin.compose")
}
+val localProperties = Properties().apply {
+ val file = rootProject.file("local.properties")
+ if (file.exists()) file.inputStream().use(::load)
+}
+val tmdbAccessToken = localProperties.getProperty("TMDB_ACCESS_TOKEN", "")
+
android {
namespace = "com.example.myapplication"
compileSdk = 35
@@ -17,6 +25,7 @@ android {
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
+ buildConfigField("String", "TMDB_ACCESS_TOKEN", "\"${tmdbAccessToken.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
}
buildTypes {
diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt
index 93591e0..5bec69c 100644
--- a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt
+++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt
@@ -66,7 +66,11 @@ data class EmbyItem(
@SerializedName("Taglines") val _taglines: List? = emptyList(),
@SerializedName("People") val _people: List? = emptyList(),
@SerializedName("Studios") val _studios: List? = emptyList(),
+ @SerializedName("ProviderIds") val _providerIds: Map? = emptyMap(),
@SerializedName("MediaStreams") val _mediaStreams: List? = emptyList(),
+ @SerializedName("MediaSources") val _mediaSources: List? = emptyList(),
+ @SerializedName("Size") val size: Long? = null,
+ @SerializedName("Bitrate") val bitrate: Int? = null,
@SerializedName("Chapters") val _chapters: List? = emptyList(),
@SerializedName("UserData") val userData: EmbyUserData? = null,
) {
@@ -75,7 +79,9 @@ data class EmbyItem(
val taglines: List get() = _taglines.orEmpty()
val people: List get() = _people.orEmpty()
val studios: List get() = _studios.orEmpty()
+ val providerIds: Map get() = _providerIds.orEmpty()
val mediaStreams: List get() = _mediaStreams.orEmpty()
+ val mediaSources: List get() = _mediaSources.orEmpty()
val chapters: List get() = _chapters.orEmpty()
val playbackPositionMs: Long
get() = (userData?.playbackPositionTicks ?: 0L).coerceAtLeast(0L) / 10_000L
@@ -107,6 +113,7 @@ data class EmbyPerson(
@SerializedName("Id") val id: String = "",
@SerializedName("Name") val name: String = "",
@SerializedName("Role") val role: String? = null,
+ @SerializedName("Type") val type: String? = null,
@SerializedName("PrimaryImageTag") val primaryImageTag: String? = null,
)
@@ -118,11 +125,33 @@ data class EmbyNamedReference(
data class EmbyMediaStream(
@SerializedName("DisplayTitle") val displayTitle: String? = null,
+ @SerializedName("DisplayLanguage") val displayLanguage: String? = null,
+ @SerializedName("Title") val title: String? = null,
@SerializedName("Type") val type: String? = null,
@SerializedName("Codec") val codec: String? = null,
@SerializedName("Language") val language: String? = null,
@SerializedName("Height") val height: Int? = null,
@SerializedName("Width") val width: Int? = null,
+ @SerializedName("BitRate") val bitRate: Int? = null,
+ @SerializedName("SampleRate") val sampleRate: Int? = null,
+ @SerializedName("Channels") val channels: Int? = null,
+ @SerializedName("ChannelLayout") val channelLayout: String? = null,
+ @SerializedName("RealFrameRate") val realFrameRate: Float? = null,
+ @SerializedName("AverageFrameRate") val averageFrameRate: Float? = null,
+ @SerializedName("VideoRange") val videoRange: String? = null,
+ @SerializedName("VideoRangeType") val videoRangeType: String? = null,
+ @SerializedName("IsInterlaced") val isInterlaced: Boolean = false,
+ @SerializedName("ColorPrimaries") val colorPrimaries: String? = null,
+ @SerializedName("ColorSpace") val colorSpace: String? = null,
+ @SerializedName("ColorTransfer") val colorTransfer: String? = null,
+ @SerializedName("Profile") val profile: String? = null,
+ @SerializedName("Level") val level: Double? = null,
+ @SerializedName("AspectRatio") val aspectRatio: String? = null,
+ @SerializedName("BitDepth") val bitDepth: Int? = null,
+ @SerializedName("PixelFormat") val pixelFormat: String? = null,
+ @SerializedName("IsExternal") val isExternal: Boolean = false,
+ @SerializedName("IsDefault") val isDefault: Boolean = false,
+ @SerializedName("IsForced") val isForced: Boolean = false,
)
data class EmbyChapter(
@@ -150,4 +179,7 @@ data class EmbyMediaSource(
@SerializedName("SupportsTranscoding") val supportsTranscoding: Boolean = false,
@SerializedName("TranscodingUrl") val transcodingUrl: String? = null,
@SerializedName("DirectStreamUrl") val directStreamUrl: String? = null,
+ @SerializedName("Size") val size: Long? = null,
+ @SerializedName("Bitrate") val bitrate: Int? = null,
+ @SerializedName("MediaStreams") val mediaStreams: List? = null,
)
diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt
index 098c9ff..4271b2b 100644
--- a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt
+++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt
@@ -304,7 +304,7 @@ object EmbyRepository {
.appendQueryParameter("IncludeItemTypes", "Movie,Series")
.appendQueryParameter("Limit", limit.toString())
.appendQueryParameter("SortBy", "SortName")
- .appendQueryParameter("Fields", "PrimaryImageAspectRatio,Overview,Genres,ProductionYear,UserData")
+ .appendQueryParameter("Fields", "PrimaryImageAspectRatio,Overview,Genres,ProductionYear,ProviderIds,UserData")
.appendQueryParameter("EnableImages", "true")
.appendQueryParameter("EnableUserData", "true")
.appendQueryParameter("EnableImageTypes", "Primary,Backdrop,Thumb,Logo")
@@ -331,7 +331,7 @@ object EmbyRepository {
suspend fun getItem(server: EmbyServer, itemId: String): EmbyItem? = withContext(Dispatchers.IO) {
try {
val url = Uri.parse("${server.fullUrl}/emby/Users/${server.userId}/Items/$itemId").buildUpon()
- .appendQueryParameter("Fields", "Overview,Genres,People,Studios,MediaStreams,Chapters,UserData")
+ .appendQueryParameter("Fields", "Overview,Genres,People,Studios,ProviderIds,MediaSources,MediaStreams,Size,Bitrate,Chapters,UserData")
.appendQueryParameter("EnableImages", "true")
.appendQueryParameter("EnableUserData", "true")
.appendQueryParameter("EnableImageTypes", "Primary,Backdrop,Thumb,Logo")
@@ -399,54 +399,120 @@ object EmbyRepository {
}
}
- /**
- * Get episodes for one season.
- */
- suspend fun getEpisodes(server: EmbyServer, seriesId: String, seasonId: String): List = withContext(Dispatchers.IO) {
- val dedicated = try {
- val url = "${server.fullUrl}/emby/Shows/$seriesId/Episodes" +
- "?userId=${server.userId}&seasonId=$seasonId&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,ImageTags,BackdropImageTags,UserData" +
- "&EnableImages=true&EnableImageTypes=Primary,Thumb,Backdrop&EnableUserData=true"
- val request = Request.Builder()
- .url(url)
- .header("X-Emby-Token", server.apiKey)
- .get()
- .build()
+ /** Get one page of episodes so the UI can render the first cards before the full season arrives. */
+ suspend fun getEpisodePage(
+ server: EmbyServer,
+ seriesId: String,
+ seasonId: String,
+ startIndex: Int = 0,
+ limit: Int? = null,
+ ): EmbyLibraryPage = withContext(Dispatchers.IO) {
+ fun Uri.Builder.withPaging(): Uri.Builder = apply {
+ appendQueryParameter("StartIndex", startIndex.coerceAtLeast(0).toString())
+ limit?.takeIf { it > 0 }?.let { appendQueryParameter("Limit", it.toString()) }
+ }
+ val dedicated = try {
+ val url = Uri.parse("${server.fullUrl}/emby/Shows/$seriesId/Episodes").buildUpon()
+ .appendQueryParameter("userId", server.userId)
+ .appendQueryParameter("seasonId", seasonId)
+ .appendQueryParameter("Fields", "PrimaryImageAspectRatio,Overview,ProductionYear,ProviderIds,ImageTags,BackdropImageTags,UserData")
+ .appendQueryParameter("EnableImages", "true")
+ .appendQueryParameter("EnableImageTypes", "Primary,Thumb,Backdrop")
+ .appendQueryParameter("EnableUserData", "true")
+ .withPaging()
+ .build()
+ val request = Request.Builder().url(url.toString()).header("X-Emby-Token", server.apiKey).get().build()
client.newCall(request).execute().use { response ->
- if (!response.isSuccessful) emptyList()
+ if (!response.isSuccessful) EmbyLibraryPage()
else {
- val body = response.body?.string().orEmpty()
- gson.fromJson(body, EmbyItemsResponse::class.java)?.items.orEmpty()
+ val parsed = gson.fromJson(response.body?.string().orEmpty(), EmbyItemsResponse::class.java)
+ EmbyLibraryPage(parsed?.items.orEmpty(), parsed?.totalRecordCount ?: 0)
}
}
} catch (_: Exception) {
- emptyList()
+ EmbyLibraryPage()
}
- if (dedicated.isNotEmpty()) return@withContext dedicated
+ if (dedicated.items.isNotEmpty()) return@withContext dedicated
return@withContext try {
- val url = "${server.fullUrl}/emby/Users/${server.userId}/Items" +
- "?ParentId=$seasonId&IncludeItemTypes=Episode&Recursive=true&SortBy=ParentIndexNumber,IndexNumber&SortOrder=Ascending" +
- "&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,ImageTags,BackdropImageTags,UserData" +
- "&EnableImages=true&EnableImageTypes=Primary,Thumb,Backdrop&EnableUserData=true"
- val request = Request.Builder()
- .url(url)
- .header("X-Emby-Token", server.apiKey)
- .get()
+ val url = Uri.parse("${server.fullUrl}/emby/Users/${server.userId}/Items").buildUpon()
+ .appendQueryParameter("ParentId", seasonId)
+ .appendQueryParameter("IncludeItemTypes", "Episode")
+ .appendQueryParameter("Recursive", "true")
+ .appendQueryParameter("SortBy", "ParentIndexNumber,IndexNumber")
+ .appendQueryParameter("SortOrder", "Ascending")
+ .appendQueryParameter("Fields", "PrimaryImageAspectRatio,Overview,ProductionYear,ProviderIds,ImageTags,BackdropImageTags,UserData")
+ .appendQueryParameter("EnableImages", "true")
+ .appendQueryParameter("EnableImageTypes", "Primary,Thumb,Backdrop")
+ .appendQueryParameter("EnableUserData", "true")
+ .withPaging()
.build()
-
+ val request = Request.Builder().url(url.toString()).header("X-Emby-Token", server.apiKey).get().build()
client.newCall(request).execute().use { response ->
- if (!response.isSuccessful) emptyList()
+ if (!response.isSuccessful) EmbyLibraryPage()
else {
- val body = response.body?.string().orEmpty()
- gson.fromJson(body, EmbyItemsResponse::class.java)?.items.orEmpty()
+ val parsed = gson.fromJson(response.body?.string().orEmpty(), EmbyItemsResponse::class.java)
+ EmbyLibraryPage(parsed?.items.orEmpty(), parsed?.totalRecordCount ?: 0)
}
}
} catch (_: Exception) {
- emptyList()
+ EmbyLibraryPage()
}
}
+
+ suspend fun getEpisodes(server: EmbyServer, seriesId: String, seasonId: String): List =
+ getEpisodePage(server, seriesId, seasonId).items
+
+ /** Returns the resume/next-up episode without waiting for seasons and episode lists. */
+ suspend fun getSeriesPlayTarget(server: EmbyServer, seriesId: String): EmbyItem? = withContext(Dispatchers.IO) {
+ try {
+ val url = Uri.parse("${server.fullUrl}/emby/Shows/NextUp").buildUpon()
+ .appendQueryParameter("UserId", server.userId)
+ .appendQueryParameter("SeriesId", seriesId)
+ .appendQueryParameter("Limit", "1")
+ .appendQueryParameter("Fields", "Overview,ParentId,UserData")
+ .appendQueryParameter("EnableUserData", "true")
+ .appendQueryParameter("EnableImages", "true")
+ .appendQueryParameter("EnableImageTypes", "Primary,Thumb,Backdrop")
+ .build()
+ val request = Request.Builder().url(url.toString()).header("X-Emby-Token", server.apiKey).get().build()
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) null
+ else gson.fromJson(response.body?.string().orEmpty(), EmbyItemsResponse::class.java)?.items?.firstOrNull()
+ }
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ /** Finds the same playable movie or episode on another server. */
+ suspend fun findMatchingPlayableItem(
+ server: EmbyServer,
+ sourceSeries: EmbyItem?,
+ sourceSeason: EmbyItem?,
+ sourceItem: EmbyItem,
+ ): EmbyItem? {
+ if (sourceSeries == null) {
+ val match = selectBestMediaMatch(
+ source = sourceItem,
+ candidates = searchItems(server, sourceItem.name, limit = 12),
+ ) ?: return null
+ return getItem(server, match.id)
+ }
+
+ val matchingSeries = selectBestMediaMatch(
+ source = sourceSeries,
+ candidates = searchItems(server, sourceSeries.name, limit = 12),
+ ) ?: return null
+ val matchingSeason = selectIndexedMatch(sourceSeason, getSeasons(server, matchingSeries.id))
+ ?: return null
+ val matchingEpisode = selectIndexedMatch(
+ sourceItem,
+ getEpisodes(server, matchingSeries.id, matchingSeason.id),
+ ) ?: return null
+ return getItem(server, matchingEpisode.id)
+ }
suspend fun resolvePlaybackSession(server: EmbyServer, item: EmbyItem): EmbyPlaybackSession? = withContext(Dispatchers.IO) {
try {
@@ -756,3 +822,37 @@ object EmbyRepository {
private const val PLAYBACK_REPORT_INTERVAL_MS = 10_000L
}
+
+internal fun selectBestMediaMatch(source: EmbyItem, candidates: List): EmbyItem? {
+ val sameType = candidates.filter { source.type == null || it.type == source.type }
+ val pool = sameType.ifEmpty { candidates }
+ if (pool.isEmpty()) return null
+
+ val sourceProviders = source.providerIds.entries.associate { it.key.lowercase() to it.value.lowercase() }
+ if (sourceProviders.isNotEmpty()) {
+ pool.firstOrNull { candidate ->
+ candidate.providerIds.any { (key, value) ->
+ sourceProviders[key.lowercase()] == value.lowercase()
+ }
+ }?.let { return it }
+ }
+
+ val normalizedName = normalizeMediaName(source.name)
+ return pool.firstOrNull {
+ normalizeMediaName(it.name) == normalizedName &&
+ (source.productionYear == null || it.productionYear == null || source.productionYear == it.productionYear)
+ }
+}
+
+internal fun selectIndexedMatch(source: EmbyItem?, candidates: List): EmbyItem? {
+ if (source == null) return candidates.firstOrNull()
+ source.indexNumber?.let { index ->
+ candidates.firstOrNull { it.indexNumber == index }?.let { return it }
+ }
+ val normalizedName = normalizeMediaName(source.name)
+ return candidates.firstOrNull { normalizeMediaName(it.name) == normalizedName }
+}
+
+private fun normalizeMediaName(value: String): String = value
+ .lowercase()
+ .replace(Regex("[^\\p{L}\\p{N}]"), "")
diff --git a/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt
new file mode 100644
index 0000000..af0f8bc
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt
@@ -0,0 +1,192 @@
+package com.example.myapplication.data
+
+import android.net.Uri
+import android.util.Log
+import com.example.myapplication.BuildConfig
+import com.google.gson.Gson
+import com.google.gson.annotations.SerializedName
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.util.Locale
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.TimeUnit
+
+data class TmdbCastMember(
+ val id: Int,
+ val name: String,
+ val originalName: String,
+ val character: String,
+ val profileUrl: String?,
+)
+
+data class TmdbRecommendation(
+ val id: Int,
+ val name: String,
+ val originalName: String,
+ val posterUrl: String?,
+ val firstAirYear: String,
+ val rating: Double,
+)
+
+data class TmdbSeriesExtras(
+ val cast: List = emptyList(),
+ val recommendations: List = emptyList(),
+)
+
+private data class TmdbSearchResponse(
+ @SerializedName("results") val results: List = emptyList(),
+)
+
+private data class TmdbSearchItem(
+ @SerializedName("id") val id: Int = 0,
+)
+
+private data class TmdbCreditsResponse(
+ @SerializedName("cast") val cast: List = emptyList(),
+)
+
+private data class TmdbCastItem(
+ @SerializedName("id") val id: Int = 0,
+ @SerializedName("name") val name: String = "",
+ @SerializedName("original_name") val originalName: String = "",
+ @SerializedName("profile_path") val profilePath: String? = null,
+ @SerializedName("roles") val roles: List = emptyList(),
+ @SerializedName("order") val order: Int = Int.MAX_VALUE,
+)
+
+private data class TmdbCastRole(
+ @SerializedName("character") val character: String = "",
+ @SerializedName("episode_count") val episodeCount: Int = 0,
+)
+
+private data class TmdbRecommendationsResponse(
+ @SerializedName("results") val results: List = emptyList(),
+)
+
+private data class TmdbRecommendationItem(
+ @SerializedName("id") val id: Int = 0,
+ @SerializedName("name") val name: String = "",
+ @SerializedName("original_name") val originalName: String = "",
+ @SerializedName("poster_path") val posterPath: String? = null,
+ @SerializedName("first_air_date") val firstAirDate: String = "",
+ @SerializedName("vote_average") val voteAverage: Double = 0.0,
+)
+
+object TmdbRepository {
+
+ private const val API_BASE = "https://api.tmdb.org/3"
+ private const val IMAGE_BASE = "https://image.tmdb.org/t/p/w342"
+ private val gson = Gson()
+ private val extrasCache = ConcurrentHashMap()
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(6, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS)
+ .build()
+
+ val isConfigured: Boolean get() = BuildConfig.TMDB_ACCESS_TOKEN.isNotBlank()
+
+ suspend fun getSeriesExtras(
+ tmdbId: String?,
+ title: String,
+ year: Int?,
+ ): TmdbSeriesExtras = withContext(Dispatchers.IO) {
+ if (!isConfigured) return@withContext TmdbSeriesExtras()
+ val resolvedId = tmdbId?.toIntOrNull() ?: searchSeriesId(title, year) ?: return@withContext TmdbSeriesExtras()
+ extrasCache[resolvedId]?.let { return@withContext it }
+ coroutineScope {
+ val cast = async { getAggregateCredits(resolvedId) }
+ val recommendations = async { getRecommendations(resolvedId) }
+ val result = TmdbSeriesExtras(cast.await(), recommendations.await())
+ if (result.cast.isNotEmpty() || result.recommendations.isNotEmpty()) {
+ extrasCache[resolvedId] = result
+ }
+ Log.d("TmdbRepository", "Loaded ${result.cast.size} cast and ${result.recommendations.size} recommendations")
+ result
+ }
+ }
+
+ private fun searchSeriesId(title: String, year: Int?): Int? {
+ if (title.isBlank()) return null
+ return performSeriesSearch(title, year?.toString())
+ ?: year?.let { performSeriesSearch(title, null) }
+ }
+
+ private fun performSeriesSearch(title: String, year: String?): Int? {
+ val url = Uri.parse("$API_BASE/search/tv").buildUpon()
+ .appendQueryParameter("query", title)
+ .appendQueryParameter("language", language())
+ .appendQueryParameter("include_adult", "false")
+ .apply { year?.let { appendQueryParameter("first_air_date_year", it) } }
+ .build()
+ return get(url.toString(), TmdbSearchResponse::class.java)?.results?.firstOrNull()?.id
+ }
+
+ private fun getAggregateCredits(seriesId: Int): List {
+ val url = Uri.parse("$API_BASE/tv/$seriesId/aggregate_credits").buildUpon()
+ .appendQueryParameter("language", language())
+ .build()
+ return get(url.toString(), TmdbCreditsResponse::class.java)?.cast.orEmpty()
+ .sortedWith(compareBy { it.order }.thenByDescending { item -> item.roles.maxOfOrNull { it.episodeCount } ?: 0 })
+ .filter { it.name.isNotBlank() }
+ .take(24)
+ .map { item ->
+ TmdbCastMember(
+ id = item.id,
+ name = item.name,
+ originalName = item.originalName,
+ character = item.roles.maxByOrNull { it.episodeCount }?.character.orEmpty(),
+ profileUrl = item.profilePath?.let { IMAGE_BASE + it },
+ )
+ }
+ }
+
+ private fun getRecommendations(seriesId: Int): List {
+ val url = Uri.parse("$API_BASE/tv/$seriesId/recommendations").buildUpon()
+ .appendQueryParameter("language", language())
+ .appendQueryParameter("page", "1")
+ .build()
+ return 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,
+ )
+ }
+ }
+
+ private fun get(url: String, type: Class): T? {
+ return try {
+ val request = Request.Builder()
+ .url(url)
+ .header("Authorization", "Bearer ${BuildConfig.TMDB_ACCESS_TOKEN}")
+ .header("Accept", "application/json")
+ .get()
+ .build()
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ Log.w("TmdbRepository", "TMDB request failed with HTTP ${response.code}")
+ null
+ }
+ else gson.fromJson(response.body?.string().orEmpty(), type)
+ }
+ } catch (error: Exception) {
+ Log.w("TmdbRepository", "TMDB request failed", error)
+ null
+ }
+ }
+
+ private fun language(): String = when (Locale.getDefault().language) {
+ "zh" -> "zh-CN"
+ else -> "en-US"
+ }
+}
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 cb65426..cf53c60 100644
--- a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt
+++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt
@@ -174,6 +174,7 @@ fun AppNavHost(
) { entry ->
MediaDetailDestination(
server = servers.firstOrNull { it.id == entry.arguments?.getString("serverId") },
+ servers = servers,
itemId = entry.arguments?.getString("itemId").orEmpty(),
onClose = { navController.popBackStack() },
onPlay = { itemId, seriesId, seasonId ->
@@ -240,6 +241,7 @@ fun AppNavHost(
@Composable
private fun MediaDetailDestination(
server: EmbyServer?,
+ servers: List,
itemId: String,
onClose: () -> Unit,
onPlay: (String, String?, String?) -> Unit,
@@ -264,7 +266,7 @@ private fun MediaDetailDestination(
}
when {
- item != null -> MediaDetailScreen(item = item!!, server = server, onClose = onClose, onPlay = onPlay)
+ item != null -> MediaDetailScreen(item = item!!, server = server, servers = servers, onClose = onClose, onPlay = onPlay)
loading -> DestinationLoading()
else -> DestinationMessage(stringResource(Res.string.detail_load_failed), background = Color.Transparent)
}
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 1c390ee..f6afad8 100644
--- a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt
+++ b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt
@@ -29,6 +29,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -53,14 +54,12 @@ import com.example.myapplication.ui.PosterImmersivePalette
import com.example.myapplication.ui.rememberPosterImmersiveBackground
import com.example.myapplication.ui.LiquidGlassButton
import com.example.myapplication.ui.GlassTokens
-import com.example.myapplication.ui.GlassDialog
import com.example.myapplication.ui.LocalGlassBackdrop
import com.example.myapplication.ui.design.LumaIcon
import com.example.myapplication.ui.design.LumaIconView
import com.example.myapplication.ui.design.LumaLoading
import com.example.myapplication.ui.design.LumaAsyncImage
import com.example.myapplication.ui.design.LumaGlassLevel
-import com.example.myapplication.ui.design.LumaGlassRuntime
import com.example.myapplication.ui.design.rememberLumaLayerBackdrop
import com.example.myapplication.ui.design.lumaGlass
import com.example.myapplication.ui.design.LumaSpacing
@@ -77,10 +76,18 @@ import androidx.compose.ui.unit.sp
import com.example.myapplication.data.EmbyItem
import com.example.myapplication.data.EmbyRepository
import com.example.myapplication.data.EmbyServer
+import com.example.myapplication.data.TmdbRecommendation
+import com.example.myapplication.data.TmdbRepository
+import com.example.myapplication.data.TmdbSeriesExtras
import com.example.myapplication.R as Res
-import com.kyant.backdrop.Backdrop
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import coil.compose.AsyncImage
import top.yukonga.miuix.kmp.basic.Text
import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.supervisorScope
import java.util.Locale
private val LocalDetailText = staticCompositionLocalOf { Color.White }
@@ -89,25 +96,70 @@ private val LocalDetailSurface = staticCompositionLocalOf { Color.White.copy(alp
private val LocalDetailBorder = staticCompositionLocalOf { Color.White.copy(alpha = 0.10f) }
private val LocalDetailAccent = staticCompositionLocalOf { Color(0xFFBFDBFE) }
+private data class DetailResource(
+ val server: EmbyServer,
+ val item: EmbyItem,
+)
+
+private data class DetailCastMember(
+ val name: String,
+ val subtitle: String,
+ val imageUrl: String?,
+)
+
@Composable
fun MediaDetailScreen(
item: EmbyItem,
server: EmbyServer?,
+ servers: List = listOfNotNull(server),
onClose: () -> Unit,
onPlay: (itemId: String, seriesId: String?, seasonId: String?) -> Unit = { _, _, _ -> },
) {
val context = LocalContext.current
- val detailLoadFailed = stringResource(Res.string.detail_load_failed)
val episodeNotReadyMessage = stringResource(Res.string.detail_episode_not_ready)
var detail by remember(item.id) { mutableStateOf(item) }
- var loadError by remember(item.id) { mutableStateOf(null) }
- var refreshing by remember(item.id) { mutableStateOf(false) }
+ var loadingFullDetail by remember(item.id) {
+ mutableStateOf(server != null && server.apiKey.isNotBlank())
+ }
var seasons by remember(item.id) { mutableStateOf>(emptyList()) }
var selectedSeasonId by rememberSaveable(item.id) { mutableStateOf(null) }
- var episodes by remember(selectedSeasonId) { mutableStateOf>(emptyList()) }
+ var episodesBySeason by remember(item.id) { mutableStateOf