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>>(emptyMap()) } + var completedSeasonIds by remember(item.id) { mutableStateOf>(emptySet()) } var selectedEpisodeId by rememberSaveable(item.id) { mutableStateOf(null) } var showAllEpisodes by remember(item.id) { mutableStateOf(false) } + var loadingSeasons by remember(item.id) { mutableStateOf(detail.type == "Series") } + var loadingEpisodes by remember(item.id) { mutableStateOf(false) } + var loadingMoreEpisodes by remember(item.id) { mutableStateOf(false) } + var seriesPlayTarget by remember(item.id) { mutableStateOf(null) } + var loadingPlayTarget by remember(item.id) { mutableStateOf(detail.type == "Series") } + 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 tmdbExtras by remember(item.id) { mutableStateOf(TmdbSeriesExtras()) } + var loadingTmdb by remember(item.id) { + mutableStateOf(item.type == "Series" && TmdbRepository.isConfigured) + } + val episodes = selectedSeasonId?.let(episodesBySeason::get).orEmpty() + + LaunchedEffect(item.id, server?.id, server?.apiKey) { + val currentServer = server + if (currentServer == null || currentServer.apiKey.isBlank()) { + detail = item + loadingFullDetail = false + return@LaunchedEffect + } + loadingFullDetail = true + try { + detail = EmbyRepository.getItem(currentServer, item.id) ?: item + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + detail = item + } finally { + loadingFullDetail = false + } + } LaunchedEffect(episodes) { if (selectedEpisodeId == null || episodes.none { it.id == selectedEpisodeId }) { @@ -117,33 +169,42 @@ fun MediaDetailScreen( LaunchedEffect(item.id, server?.id, server?.apiKey) { if (server == null || server.apiKey.isBlank()) return@LaunchedEffect - refreshing = true + if (detail.type != "Series") { + loadingSeasons = false + return@LaunchedEffect + } + loadingSeasons = true try { - val fullItem = EmbyRepository.getItem(server, item.id) - if (fullItem == null) { - loadError = detailLoadFailed - } else { - detail = fullItem - loadError = null - } - - if (detail.type == "Series") { - val loadedSeasons = EmbyRepository.getSeasons(server, item.id) - seasons = loadedSeasons - selectedSeasonId = loadedSeasons - .firstOrNull { it.id == selectedSeasonId } - ?.id - ?: loadedSeasons.firstOrNull()?.id - } else { - seasons = emptyList() - selectedSeasonId = null - } + val loadedSeasons = EmbyRepository.getSeasons(server, item.id) + seasons = loadedSeasons + selectedSeasonId = loadedSeasons + .firstOrNull { it.id == selectedSeasonId } + ?.id + ?: loadedSeasons.firstOrNull()?.id } catch (error: CancellationException) { throw error } catch (_: Exception) { - loadError = detailLoadFailed + seasons = emptyList() } finally { - refreshing = false + loadingSeasons = false + } + } + + LaunchedEffect(item.id, server?.id, server?.apiKey, detail.type) { + if (detail.type != "Series") { + loadingPlayTarget = false + return@LaunchedEffect + } + val currentServer = server ?: return@LaunchedEffect + loadingPlayTarget = true + seriesPlayTarget = try { + EmbyRepository.getSeriesPlayTarget(currentServer, item.id) + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + null + } finally { + loadingPlayTarget = false } } @@ -152,12 +213,149 @@ fun MediaDetailScreen( val seriesId = item.id val seasonId = selectedSeasonId ?: return@LaunchedEffect if (detail.type != "Series" || seasonId.isBlank()) return@LaunchedEffect - episodes = try { - EmbyRepository.getEpisodes(currentServer, seriesId, seasonId) + if (completedSeasonIds.contains(seasonId)) return@LaunchedEffect + loadingEpisodes = true + loadingMoreEpisodes = false + val firstPage = try { + EmbyRepository.getEpisodePage(currentServer, seriesId, seasonId, limit = 8) } catch (error: CancellationException) { throw error } catch (_: Exception) { - emptyList() + com.example.myapplication.data.EmbyLibraryPage() + } + var loadedEpisodes = firstPage.items + episodesBySeason = episodesBySeason + (seasonId to loadedEpisodes) + loadingEpisodes = false + + val totalCount = firstPage.totalCount.coerceAtLeast(loadedEpisodes.size) + if (loadedEpisodes.size < totalCount) { + loadingMoreEpisodes = true + try { + while (loadedEpisodes.size < totalCount) { + val page = EmbyRepository.getEpisodePage( + currentServer, + seriesId, + seasonId, + startIndex = loadedEpisodes.size, + limit = 24, + ) + if (page.items.isEmpty()) break + val appended = (loadedEpisodes + page.items).distinctBy { it.id } + if (appended.size == loadedEpisodes.size) break + loadedEpisodes = appended + episodesBySeason = episodesBySeason + (seasonId to loadedEpisodes) + } + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + // Keep the first page visible; an incomplete season can retry when selected again. + } finally { + loadingMoreEpisodes = false + } + } + if (loadedEpisodes.size >= totalCount) { + completedSeasonIds = completedSeasonIds + seasonId + } + } + + val selectedPlayableItem = if (detail.type == "Series") { + episodes.firstOrNull { it.id == selectedEpisodeId } ?: episodes.firstOrNull() + } else { + detail + } + val buttonPlayTarget = if (detail.type != "Series") { + detail + } else if (useSelectedEpisodeForPlayback) { + selectedPlayableItem + } else { + seriesPlayTarget ?: selectedPlayableItem + } + + LaunchedEffect( + selectedPlayableItem?.id, + selectedSeasonId, + server?.id, + servers.map { "${it.id}:${it.apiKey}:${it.isOnline}" }, + ) { + val currentServer = server ?: return@LaunchedEffect + val sourceItem = selectedPlayableItem ?: return@LaunchedEffect + val availableServers = servers.filter { it.isEnabled && it.isOnline && it.apiKey.isNotBlank() } + resources = emptyList() + loadingResources = true + resources = supervisorScope { + availableServers.map { candidateServer -> + async { + val matchedItem = try { + if (candidateServer.id == currentServer.id) { + EmbyRepository.getItem(candidateServer, sourceItem.id) ?: sourceItem + } else { + EmbyRepository.findMatchingPlayableItem( + server = candidateServer, + sourceSeries = detail.takeIf { it.type == "Series" }, + sourceSeason = seasons.firstOrNull { it.id == selectedSeasonId }, + sourceItem = sourceItem, + ) + } + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + null + } + matchedItem?.let { DetailResource(candidateServer, it) } + } + }.awaitAll().filterNotNull() + } + loadingResources = false + } + + val tmdbId = detail.providerIds.entries + .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) { + loadingTmdb = true + return@LaunchedEffect + } + loadingTmdb = true + tmdbExtras = try { + TmdbRepository.getSeriesExtras(tmdbId, detail.name, detail.productionYear) + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + TmdbSeriesExtras() + } finally { + loadingTmdb = false + } + } + + val castMembers = remember(detail.people, tmdbExtras.cast, server?.fullUrl) { + val actors = detail.people.filter { it.type.equals("Actor", ignoreCase = true) } + .ifEmpty { detail.people } + if (actors.isNotEmpty()) { + actors.take(24).map { person -> + val tmdbMatch = tmdbExtras.cast.firstOrNull { + normalizePersonName(it.name) == normalizePersonName(person.name) || + normalizePersonName(it.originalName) == normalizePersonName(person.name) + } + DetailCastMember( + name = person.name, + subtitle = person.role?.takeIf { it.isNotBlank() } + ?: tmdbMatch?.character.orEmpty(), + imageUrl = EmbyRepository.buildPersonImageUrl(server?.fullUrl.orEmpty(), person) + ?: tmdbMatch?.profileUrl, + ) + } + } else { + tmdbExtras.cast.map { person -> + DetailCastMember( + name = person.name, + subtitle = person.character.ifBlank { + person.originalName.takeIf { it != person.name }.orEmpty() + }, + imageUrl = person.profileUrl, + ) + } } } @@ -213,23 +411,21 @@ fun MediaDetailScreen( .offset(y = (-34).dp) .padding(horizontal = 16.dp), ) { - DetailHeadline(detail = detail, refreshing = refreshing, errorText = loadError) + DetailHeadline(detail = detail, serverUrl = server?.fullUrl.orEmpty()) Spacer(Modifier.height(18.dp)) Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - val playTarget = if (detail.type == "Series") { - episodes.firstOrNull { it.id == selectedEpisodeId } ?: episodes.firstOrNull() - } else { - detail - } - DetailPlayButton(resumePositionMs = playTarget?.playbackPositionMs ?: detail.playbackPositionMs) { - if (playTarget == null) { + DetailPlayButton( + resumePositionMs = buttonPlayTarget?.playbackPositionMs ?: detail.playbackPositionMs, + loading = loadingPlayTarget && buttonPlayTarget == null, + ) { + if (buttonPlayTarget == null) { Toast.makeText(context, episodeNotReadyMessage, Toast.LENGTH_SHORT).show() } else { onPlay( - playTarget.id, + buttonPlayTarget.id, detail.id.takeIf { detail.type == "Series" }, - selectedSeasonId.takeIf { detail.type == "Series" }, + (buttonPlayTarget.seasonId ?: selectedSeasonId).takeIf { detail.type == "Series" }, ) } } @@ -265,29 +461,38 @@ fun MediaDetailScreen( } } - if (seasons.isNotEmpty()) { + if (detail.type == "Series") { Spacer(Modifier.height(20.dp)) Text(text = stringResource(Res.string.detail_episodes), color = LocalDetailText.current, fontSize = 17.sp, fontWeight = FontWeight.Bold) Spacer(Modifier.height(10.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { + if (loadingSeasons) { + EpisodeSkeletonRow(label = stringResource(Res.string.detail_loading_seasons)) + } else if (seasons.isNotEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { LazyRow( modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { items(seasons.size) { index -> val season = seasons[index] val selected = season.id == selectedSeasonId Box( Modifier - .heightIn(min = 48.dp) + .height(42.dp) .clip(RoundedCornerShape(999.dp)) .background(if (selected) LocalDetailAccent.current else LocalDetailSurface.current) .border(1.dp, if (selected) Color.Transparent else LocalDetailBorder.current, RoundedCornerShape(999.dp)) - .clickable { selectedSeasonId = season.id } - .padding(horizontal = 14.dp, vertical = 7.dp), + .clickable { + useSelectedEpisodeForPlayback = true + selectedEpisodeId = null + selectedSeasonId = season.id + } + .padding(horizontal = 15.dp), + contentAlignment = Alignment.Center, ) { Text( text = season.name, @@ -297,79 +502,102 @@ fun MediaDetailScreen( LocalDetailText.current.copy(alpha = 0.84f) }, fontSize = 12.sp, + lineHeight = 16.sp, + maxLines = 1, + textAlign = TextAlign.Center, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, ) } } } - Text( - text = stringResource(Res.string.detail_view_all), - color = LocalDetailAccent.current, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .heightIn(min = 48.dp) + Box( + modifier = Modifier + .height(42.dp) .clickable(enabled = episodes.isNotEmpty()) { showAllEpisodes = true }, - ) - } - - if (episodes.isEmpty()) { - Spacer(Modifier.height(12.dp)) - Box( - Modifier - .fillMaxWidth() - .height(68.dp) - .clip(RoundedCornerShape(16.dp)) - .background(LocalDetailSurface.current), - contentAlignment = Alignment.Center, - ) { - LumaLoading( - label = stringResource(Res.string.detail_loading_episodes), - modifier = Modifier.padding(vertical = LumaSpacing.xs), - ) - } - } else { - Spacer(Modifier.height(12.dp)) - BoxWithConstraints(Modifier.fillMaxWidth()) { - val episodeSpacing = 9.dp - val episodeWidth = (maxWidth - episodeSpacing) / 2 - LazyRow( - horizontalArrangement = Arrangement.spacedBy(episodeSpacing), + contentAlignment = Alignment.Center, ) { - items(episodes.size) { index -> - val episode = episodes[index] - Box(Modifier.width(episodeWidth)) { - EpisodeCard( - item = episode, - serverUrl = server?.fullUrl.orEmpty(), - fallbackImageUrl = seriesFallbackImageUrl, - selected = episode.id == selectedEpisodeId, - onClick = { selectedEpisodeId = episode.id }, - ) + Text( + text = stringResource(Res.string.detail_view_all), + color = if (episodes.isNotEmpty()) LocalDetailAccent.current else LocalDetailSecondary.current, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } + + if (loadingEpisodes || !episodesBySeason.containsKey(selectedSeasonId)) { + Spacer(Modifier.height(12.dp)) + EpisodeSkeletonRow(label = stringResource(Res.string.detail_loading_first_episodes)) + } else if (episodes.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + BoxWithConstraints(Modifier.fillMaxWidth()) { + val episodeSpacing = 9.dp + val episodeWidth = (maxWidth - episodeSpacing) / 2 + LazyRow(horizontalArrangement = Arrangement.spacedBy(episodeSpacing)) { + items(episodes.size) { index -> + val episode = episodes[index] + Box(Modifier.width(episodeWidth)) { + EpisodeCard( + item = episode, + serverUrl = server?.fullUrl.orEmpty(), + fallbackImageUrl = seriesFallbackImageUrl, + selected = episode.id == selectedEpisodeId, + onClick = { + useSelectedEpisodeForPlayback = true + selectedEpisodeId = episode.id + }, + ) + } + } + if (loadingMoreEpisodes) { + item { + Box( + Modifier.width(96.dp).height(150.dp), + contentAlignment = Alignment.Center, + ) { + LumaLoading(label = stringResource(Res.string.detail_loading_more_episodes)) + } + } } } } - } - } - } - - if (detail.people.isNotEmpty()) { - Spacer(Modifier.height(22.dp)) - Text(text = stringResource(Res.string.detail_cast), color = LocalDetailText.current, fontSize = 17.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(12.dp)) - LazyRow(horizontalArrangement = Arrangement.spacedBy(13.dp)) { - items(detail.people.take(18).size) { index -> - PersonCard( - person = detail.people[index], - serverUrl = server?.fullUrl.orEmpty(), + } else { + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(Res.string.detail_no_episodes), + color = LocalDetailSecondary.current, + fontSize = 12.sp, ) } } } - Spacer(Modifier.height(22.dp)) - DetailInfoCard(item = detail) + if (selectedPlayableItem != null) { + Spacer(Modifier.height(22.dp)) + ResourceSection(resources = resources, loading = loadingResources) + } + + if (detail.type == "Series") { + Spacer(Modifier.height(22.dp)) + CastSection(castMembers, loading = loadingTmdb && castMembers.isEmpty()) + } + + if (detail.type == "Series") { + Spacer(Modifier.height(22.dp)) + RecommendationSection(tmdbExtras.recommendations, loading = loadingTmdb && tmdbExtras.recommendations.isEmpty()) + } + + if (selectedPlayableItem != null) { + val currentMedia = resources.firstOrNull { it.server.id == server?.id }?.item ?: selectedPlayableItem + Spacer(Modifier.height(22.dp)) + MediaInformationSection( + item = currentMedia, + loading = loadingResources && resources.isEmpty() && + selectedPlayableItem.mediaSources.isEmpty() && selectedPlayableItem.mediaStreams.isEmpty(), + ) + } + Spacer(Modifier.height(38.dp)) } } @@ -408,9 +636,9 @@ fun MediaDetailScreen( serverUrl = server?.fullUrl.orEmpty(), fallbackImageUrl = seriesFallbackImageUrl, background = immersive, - backdrop = detailBackdrop, onDismiss = { showAllEpisodes = false }, onSelect = { episode -> + useSelectedEpisodeForPlayback = true selectedEpisodeId = episode.id showAllEpisodes = false }, @@ -505,21 +733,40 @@ private fun DetailHero( } @Composable -private fun DetailHeadline(detail: EmbyItem, refreshing: Boolean, errorText: String?) { +private fun DetailHeadline(detail: EmbyItem, serverUrl: String) { + val logoUrl = detail.getLogoUrl(serverUrl, maxWidth = 720) + var logoLoaded by remember(logoUrl) { mutableStateOf(false) } Column( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - Text( - text = detail.name, - color = LocalDetailText.current, - fontSize = 23.sp, - fontWeight = FontWeight.Bold, - lineHeight = 28.sp, - textAlign = TextAlign.Center, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) + Box( + Modifier.fillMaxWidth().heightIn(min = 58.dp, max = 96.dp), + contentAlignment = Alignment.Center, + ) { + if (!logoLoaded) { + Text( + text = detail.name, + color = LocalDetailText.current, + fontSize = 23.sp, + fontWeight = FontWeight.Bold, + lineHeight = 28.sp, + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + if (logoUrl != null) { + AsyncImage( + model = logoUrl, + contentDescription = detail.name, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth(0.76f).heightIn(max = 96.dp), + onSuccess = { logoLoaded = true }, + onError = { logoLoaded = false }, + ) + } + } detail.taglines.firstOrNull()?.let { tagline -> Spacer(Modifier.height(6.dp)) @@ -562,33 +809,34 @@ private fun DetailHeadline(detail: EmbyItem, refreshing: Boolean, errorText: Str textAlign = TextAlign.Center, ) - if (refreshing) { - Spacer(Modifier.height(7.dp)) - Text(text = stringResource(Res.string.detail_loading), color = LocalDetailSecondary.current.copy(alpha = 0.75f), fontSize = 11.sp) - } - errorText?.let { - Spacer(Modifier.height(7.dp)) - Text(text = it, color = Color(0xFFFCA5A5), fontSize = 11.sp) - } } } @Composable -private fun DetailPlayButton(resumePositionMs: Long, onClick: () -> Unit) { +private fun DetailPlayButton(resumePositionMs: Long, loading: Boolean, onClick: () -> Unit) { LiquidGlassButton( onClick = onClick, modifier = Modifier.width(188.dp).height(48.dp), + enabled = !loading, cornerRadius = 999.dp, fill = GlassTokens.glassFillStrong, ) { - LumaIconView( - icon = LumaIcon.Play, - contentDescription = null, - tint = GlassTokens.accent, - modifier = Modifier.size(21.dp), - ) + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = GlassTokens.accent, + strokeWidth = 2.dp, + ) + } else { + LumaIconView( + icon = LumaIcon.Play, + contentDescription = null, + tint = GlassTokens.accent, + modifier = Modifier.size(21.dp), + ) + } Spacer(Modifier.width(7.dp)) Text( - text = if (resumePositionMs > 0L) { + text = if (!loading && resumePositionMs > 0L) { stringResource(Res.string.detail_continue_format, formatPlaybackTime(resumePositionMs)) } else { stringResource(Res.string.detail_play_now) @@ -610,17 +858,13 @@ private fun formatPlaybackTime(positionMs: Long): String { @Composable private fun DetailSectionCard(title: String, content: @Composable () -> Unit) { + val shape = RoundedCornerShape(18.dp) Column( Modifier .fillMaxWidth() - .lumaGlass( - backdrop = LocalGlassBackdrop.current, - radius = 18.dp, - level = LumaGlassLevel.Clear, - tint = LocalDetailSurface.current, - borderColor = LocalDetailBorder.current, - shadowElevation = 2.dp, - ) + .clip(shape) + .background(LocalDetailSurface.current) + .border(1.dp, LocalDetailBorder.current, shape) .padding(16.dp), ) { Text(text = title, color = LocalDetailText.current, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) @@ -629,6 +873,635 @@ private fun DetailSectionCard(title: String, content: @Composable () -> Unit) { } } +@Composable +private fun DetailLoadingCard(label: String) { + Box( + Modifier + .fillMaxWidth() + .height(68.dp) + .clip(RoundedCornerShape(16.dp)) + .background(LocalDetailSurface.current), + contentAlignment = Alignment.Center, + ) { + LumaLoading(label = label, modifier = Modifier.padding(vertical = LumaSpacing.xs)) + } +} + +@Composable +private fun EpisodeSkeletonRow(label: String) { + BoxWithConstraints(Modifier.fillMaxWidth().height(184.dp)) { + LazyRow( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(9.dp), + userScrollEnabled = false, + ) { + val spacing = 9.dp + val cardWidth = (maxWidth - spacing) / 2 + items(2) { + Column(Modifier.width(cardWidth)) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(21.dp)) + .background(LocalDetailSurface.current) + .border(1.dp, LocalDetailBorder.current.copy(alpha = 0.55f), RoundedCornerShape(21.dp)), + ) + Spacer(Modifier.height(9.dp)) + Box( + Modifier + .fillMaxWidth(0.72f) + .height(14.dp) + .clip(RoundedCornerShape(7.dp)) + .background(LocalDetailSurface.current), + ) + Spacer(Modifier.height(7.dp)) + Box( + Modifier + .fillMaxWidth() + .height(30.dp) + .clip(RoundedCornerShape(8.dp)) + .background(LocalDetailSurface.current.copy(alpha = 0.72f)), + ) + } + } + } + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LumaLoading(label = label) + } + } +} + +@Composable +private fun ResourceSkeletonRow(label: String) { + BoxWithConstraints(Modifier.fillMaxWidth().height(130.dp)) { + val spacing = 9.dp + val cardWidth = (maxWidth - spacing) * 0.54f + LazyRow( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(spacing), + userScrollEnabled = false, + ) { + items(2) { + Box( + Modifier + .width(cardWidth) + .height(128.dp) + .clip(RoundedCornerShape(21.dp)) + .background(LocalDetailSurface.current) + .border(1.dp, LocalDetailBorder.current.copy(alpha = 0.55f), RoundedCornerShape(21.dp)), + ) + } + } + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LumaLoading(label = label) + } + } +} + +@Composable +private fun ResourceSection(resources: List, loading: Boolean) { + DetailSectionTitle(stringResource(Res.string.detail_resources), LumaIcon.Server) + Spacer(Modifier.height(12.dp)) + when { + loading && resources.isEmpty() -> ResourceSkeletonRow(stringResource(Res.string.detail_loading_resources)) + resources.isEmpty() -> Text( + text = stringResource(Res.string.detail_no_resources), + color = LocalDetailSecondary.current, + fontSize = 12.sp, + ) + else -> Column { + val bestBitrate = resources.maxOfOrNull(::resourceVideoBitrate) ?: 0 + BoxWithConstraints(Modifier.fillMaxWidth()) { + val spacing = 9.dp + val cardWidth = (maxWidth - spacing) * 0.54f + LazyRow(horizontalArrangement = Arrangement.spacedBy(spacing)) { + items(resources.size) { index -> + val resource = resources[index] + ResourceCard( + resource = resource, + isBest = resources.size > 1 && bestBitrate > 0 && resourceVideoBitrate(resource) == bestBitrate, + modifier = Modifier.width(cardWidth), + ) + } + } + } + val qualities = resources.mapNotNull(::resourceQualityLabel).distinct() + if (qualities.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(qualities.size) { index -> + Box( + Modifier + .height(36.dp) + .clip(RoundedCornerShape(999.dp)) + .background(LocalDetailSurface.current) + .border(1.dp, LocalDetailBorder.current, RoundedCornerShape(999.dp)) + .padding(horizontal = 17.dp), + contentAlignment = Alignment.Center, + ) { + Text(qualities[index], color = LocalDetailText.current, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + } + } + } + } + } + } +} + +@Composable +private fun ResourceCard(resource: DetailResource, isBest: Boolean, 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) } + val videoBitrate = resourceVideoBitrate(resource) + val audioCount = streams.count { it.type.equals("Audio", ignoreCase = true) } + val subtitleCount = streams.count { it.type.equals("Subtitle", ignoreCase = true) } + val dynamicRange = (video?.videoRangeType ?: video?.videoRange).orEmpty().ifBlank { "SDR" } + val versionCount = resourceVersionCount(resource.item) + val shape = RoundedCornerShape(21.dp) + Box(modifier.height(128.dp)) { + Column( + Modifier + .fillMaxSize() + .clip(shape) + .background( + Brush.linearGradient( + listOf( + LocalDetailSurface.current, + LocalDetailAccent.current.copy(alpha = 0.08f), + ) + ) + ) + .border(1.dp, LocalDetailBorder.current, shape) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.Center, + ) { + Row( + modifier = Modifier.padding(end = if (versionCount > 1) 22.dp else 0.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier.size(36.dp).clip(RoundedCornerShape(11.dp)).background(LocalDetailSurface.current), + contentAlignment = Alignment.Center, + ) { + if (resource.server.iconUrl.isNotBlank()) { + LumaAsyncImage( + model = resource.server.iconUrl, + contentDescription = resource.server.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } else { + LumaIconView(LumaIcon.Server, contentDescription = null, tint = LocalDetailAccent.current, modifier = Modifier.size(19.dp)) + } + } + Spacer(Modifier.width(8.dp)) + Text( + text = resource.server.name.ifBlank { resource.server.displayHost }, + color = LocalDetailText.current, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (isBest) { + Box( + Modifier.clip(RoundedCornerShape(8.dp)).background(Color(0xFFFFC857).copy(alpha = 0.18f)).padding(horizontal = 6.dp, vertical = 4.dp), + ) { + Text(stringResource(Res.string.detail_best_resource), color = Color(0xFFFFB020), fontSize = 9.sp, fontWeight = FontWeight.Bold) + } + } + } + + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + ResourceMetric(LumaIcon.Headphones, "$audioCount", valueSize = 10) + ResourceMetric(LumaIcon.Subtitle, "$subtitleCount", valueSize = 10) + Box( + Modifier.clip(RoundedCornerShape(8.dp)).background(LocalDetailSurface.current).padding(horizontal = 7.dp, vertical = 4.dp), + ) { + Text(dynamicRange.uppercase(Locale.ROOT), color = LocalDetailSecondary.current, fontSize = 9.sp, fontWeight = FontWeight.SemiBold) + } + } + + Spacer(Modifier.height(5.dp)) + Box(Modifier.fillMaxWidth().height(1.dp).background(LocalDetailBorder.current.copy(alpha = 0.65f))) + Spacer(Modifier.height(5.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + ResourceMetric(LumaIcon.Database, formatFileSize(source?.size ?: resource.item.size), valueSize = 10) + Spacer(Modifier.weight(1f)) + ResourceMetric(LumaIcon.Activity, formatBitrate(videoBitrate), valueSize = 10) + } + } + if (versionCount > 1) { + Box( + Modifier + .align(Alignment.TopEnd) + .padding(7.dp) + .size(25.dp) + .clip(CircleShape) + .background(LocalDetailText.current), + contentAlignment = Alignment.Center, + ) { + Text("$versionCount", color = if (LocalDetailText.current.luminance() > 0.5f) Color.Black else Color.White, fontSize = 10.sp, fontWeight = FontWeight.Bold) + } + } + } +} + +@Composable +private fun ResourceMetric(icon: LumaIcon, value: String, valueSize: Int = 11) { + Row(verticalAlignment = Alignment.CenterVertically) { + LumaIconView(icon, contentDescription = null, tint = LocalDetailSecondary.current, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(5.dp)) + Text(value, color = LocalDetailSecondary.current, fontSize = valueSize.sp, fontWeight = FontWeight.Medium) + } +} + +private fun resourceVideoBitrate(resource: DetailResource): Int { + val source = resource.item.mediaSources.firstOrNull() + val streams = source?.mediaStreams.orEmpty().ifEmpty { resource.item.mediaStreams } + return streams.firstOrNull { it.type.equals("Video", ignoreCase = true) }?.bitRate + ?: source?.bitrate + ?: resource.item.bitrate + ?: 0 +} + +internal fun resourceVersionCount(item: EmbyItem): Int { + val explicitVideoSources = item.mediaSources.count { source -> + source.mediaStreams.orEmpty().any { it.type.equals("Video", ignoreCase = true) } + } + if (explicitVideoSources > 0) return explicitVideoSources + if (item.mediaSources.isNotEmpty()) return item.mediaSources.size + return if (item.mediaStreams.any { it.type.equals("Video", ignoreCase = true) }) 1 else 0 +} + +private fun resourceQualityLabel(resource: DetailResource): String? { + 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) } ?: return null + return when { + (video.height ?: 0) >= 2160 || (video.width ?: 0) >= 3840 -> "4K" + (video.height ?: 0) >= 1080 -> "1080p" + (video.height ?: 0) >= 720 -> "720p" + video.height != null -> "${video.height}p" + else -> null + } +} + +@Composable +private fun CastSection(cast: List, loading: Boolean) { + DetailSectionTitle(stringResource(Res.string.detail_cast), LumaIcon.Users) + Spacer(Modifier.height(12.dp)) + if (loading) { + CastSkeletonRow() + return + } + if (cast.isEmpty()) { + Text(stringResource(Res.string.detail_no_cast), color = LocalDetailSecondary.current, fontSize = 12.sp) + return + } + LazyRow(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + items(cast.size) { index -> CastMemberCard(cast[index]) } + } +} + +@Composable +private fun CastMemberCard(member: DetailCastMember) { + Column( + modifier = Modifier.width(92.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier + .size(88.dp) + .clip(CircleShape) + .background( + Brush.linearGradient( + listOf( + LocalDetailAccent.current.copy(alpha = 0.22f), + LocalDetailSurface.current, + ) + ) + ) + .border(1.dp, LocalDetailBorder.current, CircleShape), + contentAlignment = Alignment.Center, + ) { + if (member.imageUrl != null) { + LumaAsyncImage( + model = member.imageUrl, + contentDescription = member.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } else { + Text( + text = member.name.take(1), + color = LocalDetailText.current.copy(alpha = 0.82f), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(Modifier.height(8.dp)) + Text( + text = member.name, + color = LocalDetailText.current, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + if (member.subtitle.isNotBlank()) { + Spacer(Modifier.height(2.dp)) + Text( + text = member.subtitle, + color = LocalDetailSecondary.current, + fontSize = 10.sp, + maxLines = 1, + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun CastSkeletonRow() { + LazyRow(horizontalArrangement = Arrangement.spacedBy(14.dp), userScrollEnabled = false) { + items(4) { + Column(Modifier.width(92.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Box(Modifier.size(88.dp).clip(CircleShape).background(LocalDetailSurface.current)) + Spacer(Modifier.height(8.dp)) + Box(Modifier.width(62.dp).height(12.dp).clip(RoundedCornerShape(6.dp)).background(LocalDetailSurface.current)) + } + } + } +} + +@Composable +private fun RecommendationSection(recommendations: List, loading: Boolean) { + DetailSectionTitle(stringResource(Res.string.detail_recommendations), LumaIcon.Discover) + Spacer(Modifier.height(12.dp)) + if (loading) { + RecommendationSkeletonRow() + return + } + if (recommendations.isEmpty()) { + Text(stringResource(Res.string.detail_no_recommendations), color = LocalDetailSecondary.current, fontSize = 12.sp) + return + } + LazyRow(horizontalArrangement = Arrangement.spacedBy(11.dp)) { + items(recommendations.size) { index -> RecommendationCard(recommendations[index]) } + } +} + +@Composable +private fun RecommendationCard(item: TmdbRecommendation) { + Column(Modifier.width(126.dp)) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(2f / 3f) + .clip(RoundedCornerShape(20.dp)) + .background( + Brush.linearGradient( + listOf(LocalDetailAccent.current.copy(alpha = 0.20f), LocalDetailSurface.current) + ) + ) + .border(1.dp, LocalDetailBorder.current, RoundedCornerShape(20.dp)), + contentAlignment = Alignment.Center, + ) { + if (item.posterUrl != null) { + LumaAsyncImage( + model = item.posterUrl, + contentDescription = item.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } else { + LumaIconView( + LumaIcon.Media, + contentDescription = null, + tint = LocalDetailSecondary.current, + modifier = Modifier.size(30.dp), + ) + } + } + Spacer(Modifier.height(8.dp)) + Text( + text = item.name, + color = LocalDetailText.current, + fontSize = 12.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + val metadata = buildList { + if (item.firstAirYear.isNotBlank()) add(item.firstAirYear) + if (item.rating > 0.0) add(String.format(Locale.ROOT, "%.1f", item.rating)) + }.joinToString(" · ") + if (metadata.isNotBlank()) { + Spacer(Modifier.height(3.dp)) + Text(metadata, color = LocalDetailSecondary.current, fontSize = 10.sp, maxLines = 1) + } + } +} + +@Composable +private fun RecommendationSkeletonRow() { + LazyRow(horizontalArrangement = Arrangement.spacedBy(11.dp), userScrollEnabled = false) { + items(3) { + Column(Modifier.width(126.dp)) { + Box( + Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(RoundedCornerShape(20.dp)) + .background(LocalDetailSurface.current), + ) + Spacer(Modifier.height(8.dp)) + Box(Modifier.fillMaxWidth(0.78f).height(13.dp).clip(RoundedCornerShape(7.dp)).background(LocalDetailSurface.current)) + } + } + } +} + +private fun normalizePersonName(value: String): String = value + .lowercase(Locale.ROOT) + .filter { it.isLetterOrDigit() } + +@Composable +private fun MediaInformationSection(item: EmbyItem, loading: Boolean) { + val source = item.mediaSources.firstOrNull() + val streams = source?.mediaStreams.orEmpty().ifEmpty { item.mediaStreams } + DetailSectionTitle(stringResource(Res.string.detail_media_information), LumaIcon.Media) + Spacer(Modifier.height(12.dp)) + when { + loading -> DetailLoadingCard(stringResource(Res.string.detail_loading_media)) + streams.isEmpty() -> Text( + text = stringResource(Res.string.detail_no_media_info), + color = LocalDetailSecondary.current, + fontSize = 12.sp, + ) + else -> LazyRow(horizontalArrangement = Arrangement.spacedBy(9.dp)) { + items(streams.size) { index -> + val stream = streams[index] + val typeIndex = streams.take(index + 1).count { it.type.equals(stream.type, ignoreCase = true) } + val typeCount = streams.count { it.type.equals(stream.type, ignoreCase = true) } + MediaStreamCard(stream = stream, index = typeIndex, typeCount = typeCount) + } + } + } +} + +@Composable +private fun MediaStreamCard(stream: com.example.myapplication.data.EmbyMediaStream, index: Int, typeCount: Int) { + val type = stream.type.orEmpty() + val isVideo = type.equals("Video", ignoreCase = true) + val isAudio = type.equals("Audio", ignoreCase = true) + val title = when { + isVideo && typeCount == 1 -> stringResource(Res.string.detail_video) + isVideo -> stringResource(Res.string.detail_video_stream, index) + isAudio && typeCount == 1 -> stringResource(Res.string.detail_audio) + isAudio -> stringResource(Res.string.detail_audio_stream, index) + typeCount == 1 -> stringResource(Res.string.detail_subtitle) + else -> stringResource(Res.string.detail_subtitle_stream, index) + } + val emptyValue = "—" + fun String?.orEmptyValue(): String = this?.takeIf { it.isNotBlank() } ?: emptyValue + val yes = stringResource(Res.string.detail_yes) + val no = stringResource(Res.string.detail_no) + val rows = when { + isVideo -> listOf( + stringResource(Res.string.detail_display_title) to (stream.displayTitle ?: stream.title).orEmptyValue(), + stringResource(Res.string.detail_language) to stream.language.orEmptyValue(), + stringResource(Res.string.detail_codec) to stream.codec?.uppercase(Locale.ROOT).orEmptyValue(), + stringResource(Res.string.detail_resolution) to if (stream.width != null && stream.height != null) "${stream.width}×${stream.height}" else emptyValue, + stringResource(Res.string.detail_frame_rate) to (stream.realFrameRate ?: stream.averageFrameRate)?.takeIf { it > 0f }?.let(::formatFrameRate).orEmptyValue(), + stringResource(Res.string.detail_stream_bitrate) to formatBitrate(stream.bitRate), + stringResource(Res.string.detail_dynamic_range) to (stream.videoRangeType ?: stream.videoRange).orEmptyValue(), + stringResource(Res.string.detail_interlaced) to if (stream.isInterlaced) yes else no, + stringResource(Res.string.detail_color_primaries) to stream.colorPrimaries.orEmptyValue(), + stringResource(Res.string.detail_color_space) to stream.colorSpace.orEmptyValue(), + stringResource(Res.string.detail_profile) to stream.profile.orEmptyValue(), + stringResource(Res.string.detail_level) to formatLevel(stream.level), + stringResource(Res.string.detail_aspect_ratio) to stream.aspectRatio.orEmptyValue(), + stringResource(Res.string.detail_bit_depth) to stream.bitDepth?.toString().orEmptyValue(), + stringResource(Res.string.detail_pixel_format) to stream.pixelFormat.orEmptyValue(), + ) + isAudio -> listOf( + stringResource(Res.string.detail_language) to stream.language.orEmptyValue(), + stringResource(Res.string.detail_codec) to stream.codec?.uppercase(Locale.ROOT).orEmptyValue(), + stringResource(Res.string.detail_profile) to stream.profile.orEmptyValue(), + stringResource(Res.string.detail_stream_bitrate) to formatBitrate(stream.bitRate), + stringResource(Res.string.detail_layout) to stream.channelLayout.orEmptyValue(), + stringResource(Res.string.detail_channel_count) to stream.channels?.toString().orEmptyValue(), + stringResource(Res.string.detail_sample_rate) to stream.sampleRate?.takeIf { it > 0 }?.let(::formatSampleRate).orEmptyValue(), + stringResource(Res.string.detail_external) to if (stream.isExternal) yes else no, + stringResource(Res.string.detail_default) to if (stream.isDefault) yes else no, + stringResource(Res.string.detail_display_title) to (stream.displayTitle ?: stream.title).orEmptyValue(), + stringResource(Res.string.detail_display_language) to stream.displayLanguage.orEmptyValue(), + ) + else -> listOf( + stringResource(Res.string.detail_display_title) to (stream.displayTitle ?: stream.title).orEmptyValue(), + stringResource(Res.string.detail_display_language) to stream.displayLanguage.orEmptyValue(), + stringResource(Res.string.detail_language) to stream.language.orEmptyValue(), + stringResource(Res.string.detail_codec) to stream.codec?.uppercase(Locale.ROOT).orEmptyValue(), + stringResource(Res.string.detail_external) to if (stream.isExternal) yes else no, + stringResource(Res.string.detail_default) to if (stream.isDefault) yes else no, + stringResource(Res.string.detail_forced) to if (stream.isForced) yes else no, + ) + } + Column( + Modifier + .width(210.dp) + .height(404.dp) + .clip(RoundedCornerShape(21.dp)) + .background(LocalDetailSurface.current) + .border(1.dp, LocalDetailBorder.current, RoundedCornerShape(21.dp)) + .padding(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(34.dp).clip(RoundedCornerShape(11.dp)).background(LocalDetailAccent.current.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center, + ) { + LumaIconView( + icon = when { isVideo -> LumaIcon.Media; isAudio -> LumaIcon.Headphones; else -> LumaIcon.Subtitle }, + contentDescription = null, + tint = LocalDetailAccent.current, + modifier = Modifier.size(19.dp), + ) + } + Spacer(Modifier.width(10.dp)) + Text(title, color = LocalDetailText.current, fontSize = 15.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + LumaIconView(LumaIcon.Check, contentDescription = null, tint = LocalDetailAccent.current, modifier = Modifier.size(18.dp)) + } + Spacer(Modifier.height(14.dp)) + rows.forEach { (label, value) -> + MediaValueRow(label, value) + } + } +} + +@Composable +private fun MediaValueRow(label: String, value: String) { + Row(Modifier.fillMaxWidth().heightIn(min = 21.dp), verticalAlignment = Alignment.Top) { + Text(label, color = LocalDetailText.current.copy(alpha = 0.78f), fontSize = 11.sp, modifier = Modifier.width(66.dp)) + Spacer(Modifier.width(8.dp)) + Text( + value, + color = LocalDetailSecondary.current, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun DetailSectionTitle(title: String, icon: LumaIcon) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(30.dp).clip(RoundedCornerShape(10.dp)).background(LocalDetailAccent.current.copy(alpha = 0.13f)), + contentAlignment = Alignment.Center, + ) { + LumaIconView(icon, contentDescription = null, tint = LocalDetailAccent.current, modifier = Modifier.size(17.dp)) + } + Spacer(Modifier.width(9.dp)) + Text(title, color = LocalDetailText.current, fontSize = 17.sp, fontWeight = FontWeight.Bold) + } +} + +internal fun formatFileSize(bytes: Long?): String { + if (bytes == null || bytes <= 0L) return "—" + val gib = bytes / 1_073_741_824.0 + return if (gib >= 1.0) String.format(Locale.ROOT, "%.2f GB", gib) else String.format(Locale.ROOT, "%.0f MB", bytes / 1_048_576.0) +} + +internal fun formatBitrate(bitsPerSecond: Int?): String { + if (bitsPerSecond == null || bitsPerSecond <= 0) return "—" + return if (bitsPerSecond >= 1_000_000) { + String.format(Locale.ROOT, "%.1f Mbps", bitsPerSecond / 1_000_000.0) + } else { + String.format(Locale.ROOT, "%.0f Kbps", bitsPerSecond / 1_000.0) + } +} + +private fun formatFrameRate(frameRate: Float): String = String.format(Locale.ROOT, "%.2f fps", frameRate) + +private fun formatSampleRate(sampleRate: Int): String = "${sampleRate} Hz" + +private fun formatLevel(level: Double?): String { + if (level == null) return "—" + return if (level % 1.0 == 0.0) level.toInt().toString() else String.format(Locale.ROOT, "%.1f", level) +} + @Composable private fun EpisodeListDialog( title: String, @@ -637,7 +1510,6 @@ private fun EpisodeListDialog( serverUrl: String, fallbackImageUrl: String?, background: PosterImmersivePalette, - backdrop: Backdrop?, onDismiss: () -> Unit, onSelect: (EmbyItem) -> Unit, ) { @@ -647,19 +1519,23 @@ private fun EpisodeListDialog( Color.Black.copy(alpha = 0.10f) } - GlassDialog(backdrop = backdrop, onDismissRequest = onDismiss, modifier = Modifier.widthIn(max = 360.dp)) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + CompositionLocalProvider(LocalGlassBackdrop provides null) { + Box( + Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 44.dp), + contentAlignment = Alignment.Center, + ) { Column( Modifier .fillMaxWidth() - .lumaGlass( - backdrop = null, - radius = 18.dp, - level = LumaGlassLevel.Strong, - tint = background.hero.copy(alpha = if (background.isDark) 0.20f else 0.12f), - borderColor = dialogBorder, - shadowElevation = 8.dp, - ) - .padding(4.dp), + .widthIn(max = 440.dp) + .clip(RoundedCornerShape(24.dp)) + .background(background.pageBottom.copy(alpha = 0.98f)) + .border(1.dp, dialogBorder, RoundedCornerShape(24.dp)) + .padding(20.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -696,6 +1572,8 @@ private fun EpisodeListDialog( } } } + } + } } } @@ -886,6 +1764,9 @@ private fun EpisodeCard( fontSize = 13.sp, fontWeight = FontWeight.SemiBold, lineHeight = 18.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.height(18.dp), style = LocalTextStyle.current, ) @@ -897,72 +1778,10 @@ private fun EpisodeCard( lineHeight = 16.sp, maxLines = 2, overflow = TextOverflow.Ellipsis, + modifier = Modifier.height(32.dp), ) } } -@Composable -private fun PersonCard(person: com.example.myapplication.data.EmbyPerson, serverUrl: String) { - val imageUrl = EmbyRepository.buildPersonImageUrl(serverUrl, person) - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(64.dp)) { - Box( - Modifier - .size(59.dp) - .clip(CircleShape) - .background(Color(0xFF17233A)) - .border(1.dp, LocalDetailBorder.current, CircleShape), - contentAlignment = Alignment.Center, - ) { - if (imageUrl != null) { - LumaAsyncImage(model = imageUrl, contentDescription = person.name, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) - } else { - Text(text = person.name.take(1), color = LocalDetailText.current.copy(alpha = 0.72f), fontSize = 18.sp, fontWeight = FontWeight.Bold) - } - } - Spacer(Modifier.height(5.dp)) - Text(person.name, color = LocalDetailText.current.copy(alpha = 0.92f), fontSize = 11.sp, maxLines = 1, textAlign = TextAlign.Center, overflow = TextOverflow.Ellipsis) - person.role?.let { - Text(it, color = LocalDetailSecondary.current.copy(alpha = 0.70f), fontSize = 10.sp, maxLines = 1, textAlign = TextAlign.Center, overflow = TextOverflow.Ellipsis) - } - } -} - -@Composable -private fun DetailInfoCard(item: EmbyItem) { - val studios = item.studios.joinToString(" / ") { it.name } - val videoStream = item.mediaStreams.firstOrNull { it.type == "Video" } - val audioStreams = item.mediaStreams.filter { it.type == "Audio" } - - DetailSectionCard(title = stringResource(Res.string.detail_information)) { - InfoRow(label = stringResource(Res.string.detail_type), value = item.type ?: stringResource(Res.string.detail_unknown)) - item.originalTitle?.takeIf { it.isNotBlank() && it != item.name }?.let { - InfoRow(label = stringResource(Res.string.detail_original_title), value = it) - } - studios.takeIf { it.isNotBlank() }?.let { InfoRow(label = stringResource(Res.string.detail_studio), value = it) } - videoStream?.let { - val quality = listOfNotNull(it.width, it.height).joinToString("×").ifBlank { null } - val value = listOfNotNull(quality, it.codec?.uppercase()).joinToString(" · ") - if (value.isNotBlank()) InfoRow(label = stringResource(Res.string.detail_quality), value = value) - } - if (audioStreams.isNotEmpty()) { - InfoRow(label = stringResource(Res.string.detail_audio_track), value = stringResource(Res.string.detail_audio_track_count, audioStreams.count())) - } - } -} - -@Composable -private fun InfoRow(label: String, value: String) { - Row( - Modifier - .fillMaxWidth() - .padding(top = 8.dp), - verticalAlignment = Alignment.Top, - ) { - Text(label, color = LocalDetailSecondary.current.copy(alpha = 0.68f), fontSize = 12.sp, modifier = Modifier.width(58.dp)) - Spacer(Modifier.width(10.dp)) - Text(value, color = LocalDetailText.current.copy(alpha = 0.90f), fontSize = 12.sp, modifier = Modifier.weight(1f)) - } -} - @Composable private fun formatRuntime(ticks: Long?): String? { if (ticks == null || ticks <= 0) return null diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 8a0f183..94c89d9 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -120,6 +120,7 @@ Synopsis Episodes Cast & Crew + No cast information available Details Type Original title @@ -139,6 +140,63 @@ %1$d min %1$d hr %1$d hr %2$d min + Resources + Best + Matching server resources... + No matching resources + Video size + Video bitrate + %1$d audio · %2$d subtitles + Recommendations + No recommendations available + Media information + Loading media information... + No media information + Video %1$d + Audio %1$d + Subtitle %1$d + Video + Audio + Subtitle + Codec · %1$s + Resolution · %1$d×%2$d + Bitrate · %1$s + Dynamic range · %1$s + Frame rate · %1$s + Pixel format · %1$s + Sample rate · %1$s + Channels · %1$s + Language · %1$s + Default · %1$s Forced · %2$s + Yes + No + Locating playback progress... + Loading seasons... + Loading the first episodes... + Loading more + No episodes in this season + Display title + Display language + Language + Codec + Resolution + Frame rate + Bitrate + Dynamic range + Interlaced + Color primaries + Color space + Profile + Level + Aspect ratio + Bit depth + Pixel format + Layout + Channels + Sample rate + External + Default + Forced Preparing playback... Playback failed Subtitles diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6bd8cd2..a6ab011 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -120,6 +120,7 @@ 简介 剧集 演职人员 + 暂未获取到演员信息 详细信息 类型 原名 @@ -139,6 +140,63 @@ %1$d 分钟 %1$d 小时 %1$d 小时 %2$d 分钟 + 资源列表 + 最佳 + 正在匹配服务器资源... + 未匹配到可用资源 + 视频大小 + 视频码率 + %1$d 个音频 · %2$d 个字幕 + 推荐作品 + 暂未获取到推荐作品 + 媒体信息 + 正在读取媒体信息... + 暂无媒体信息 + 视频 %1$d + 音频 %1$d + 字幕 %1$d + 视频 + 音频 + 字幕 + 编码 · %1$s + 分辨率 · %1$d×%2$d + 码率 · %1$s + 动态范围 · %1$s + 帧率 · %1$s + 像素格式 · %1$s + 采样率 · %1$s + 声道 · %1$s + 语言 · %1$s + 默认 · %1$s 强制 · %2$s + 是 + 否 + 正在定位播放进度... + 正在获取季度... + 正在加载首批分集... + 继续加载 + 本季暂无可用分集 + 显示标题 + 显示语言 + 语言 + 编码 + 分辨率 + 帧率 + 比特率 + 动态范围 + 隔行扫描 + 色彩原色 + 色彩空间 + 配置 + 等级 + 长宽比 + 位深 + 像素格式 + 布局 + 声道 + 采样率 + 外部 + 默认 + 强制 正在准备播放... 播放失败 字幕 diff --git a/app/src/test/kotlin/com/example/myapplication/screens/MediaDetailDataTest.kt b/app/src/test/kotlin/com/example/myapplication/screens/MediaDetailDataTest.kt new file mode 100644 index 0000000..05ccefd --- /dev/null +++ b/app/src/test/kotlin/com/example/myapplication/screens/MediaDetailDataTest.kt @@ -0,0 +1,90 @@ +package com.example.myapplication.screens + +import com.example.myapplication.data.EmbyItem +import com.example.myapplication.data.EmbyMediaStream +import com.example.myapplication.data.EmbyMediaSource +import com.example.myapplication.data.selectBestMediaMatch +import com.example.myapplication.data.selectIndexedMatch +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class MediaDetailDataTest { + + @Test + fun `provider id takes priority when matching resources`() { + val source = EmbyItem( + id = "source", + name = "Different localized title", + type = "Series", + _providerIds = mapOf("Tmdb" to "123"), + ) + val expected = EmbyItem( + id = "match", + name = "完全不同的标题", + type = "Series", + _providerIds = mapOf("TMDB" to "123"), + ) + + assertEquals(expected, selectBestMediaMatch(source, listOf(expected))) + } + + @Test + fun `title and year are used when provider id is absent`() { + val source = EmbyItem(id = "source", name = "The Example!", type = "Movie", productionYear = 2025) + val wrongYear = EmbyItem(id = "old", name = "The Example", type = "Movie", productionYear = 2020) + val expected = EmbyItem(id = "match", name = "The-Example", type = "Movie", productionYear = 2025) + + assertEquals(expected, selectBestMediaMatch(source, listOf(wrongYear, expected))) + assertNull(selectBestMediaMatch(source, listOf(wrongYear))) + } + + @Test + fun `season and episode matching uses index first`() { + val source = EmbyItem(id = "source", name = "第二集", indexNumber = 2) + val expected = EmbyItem(id = "match", name = "Episode Two", indexNumber = 2) + + assertEquals(expected, selectIndexedMatch(source, listOf(expected))) + } + + @Test + fun `resource values use readable units`() { + assertEquals("1.50 GB", formatFileSize(1_610_612_736L)) + assertEquals("8.0 Mbps", formatBitrate(8_000_000)) + assertEquals("—", formatBitrate(null)) + } + + @Test + fun `resource badge counts video versions only`() { + val video = EmbyMediaStream(type = "Video") + val audio = EmbyMediaStream(type = "Audio") + val item = EmbyItem( + id = "episode", + name = "Episode", + _mediaSources = listOf( + EmbyMediaSource(id = "version-1", mediaStreams = listOf(video, audio)), + EmbyMediaSource(id = "version-2", mediaStreams = listOf(video)), + EmbyMediaSource(id = "audio-only", mediaStreams = listOf(audio)), + ), + ) + + assertEquals(2, resourceVersionCount(item)) + assertEquals(1, resourceVersionCount(EmbyItem(id = "single", name = "Single", _mediaStreams = listOf(video)))) + } + + @Test + fun `media stream fields used by detail cards are parsed`() { + val stream = Gson().fromJson( + """{"Type":"Video","DisplayTitle":"4K H264","Profile":"High","Level":51,"BitDepth":8,"IsInterlaced":false,"ColorPrimaries":"bt709","ColorSpace":"bt709"}""", + EmbyMediaStream::class.java, + ) + + assertEquals("4K H264", stream.displayTitle) + assertEquals("High", stream.profile) + assertEquals(51.0, stream.level!!, 0.0) + assertEquals(8, stream.bitDepth) + assertEquals("bt709", stream.colorPrimaries) + assertEquals("bt709", stream.colorSpace) + } +}