1、增加剧集详情页演员表,资源库信息,推荐,媒体信息,接入tmdb

2、优化剧集详情页性能,UI,和数据请求逻辑
This commit is contained in:
beyond committed 2026-08-29 16:58:58 +08:00
1 parent ca5ab982da
commit eaf5e432ff
9 files changed
+1611 -251

No files matched your search

+9
View File
@@ -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 {
@@ -66,7 +66,11 @@ data class EmbyItem(
@SerializedName("Taglines") val _taglines: List<String>? = emptyList(),
@SerializedName("People") val _people: List<EmbyPerson>? = emptyList(),
@SerializedName("Studios") val _studios: List<EmbyNamedReference>? = emptyList(),
@SerializedName("ProviderIds") val _providerIds: Map<String, String>? = emptyMap(),
@SerializedName("MediaStreams") val _mediaStreams: List<EmbyMediaStream>? = emptyList(),
@SerializedName("MediaSources") val _mediaSources: List<EmbyMediaSource>? = emptyList(),
@SerializedName("Size") val size: Long? = null,
@SerializedName("Bitrate") val bitrate: Int? = null,
@SerializedName("Chapters") val _chapters: List<EmbyChapter>? = emptyList(),
@SerializedName("UserData") val userData: EmbyUserData? = null,
) {
@@ -75,7 +79,9 @@ data class EmbyItem(
val taglines: List<String> get() = _taglines.orEmpty()
val people: List<EmbyPerson> get() = _people.orEmpty()
val studios: List<EmbyNamedReference> get() = _studios.orEmpty()
val providerIds: Map<String, String> get() = _providerIds.orEmpty()
val mediaStreams: List<EmbyMediaStream> get() = _mediaStreams.orEmpty()
val mediaSources: List<EmbyMediaSource> get() = _mediaSources.orEmpty()
val chapters: List<EmbyChapter> 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<EmbyMediaStream>? = null,
)
@@ -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<EmbyItem> = 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<EmbyItem> =
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>): 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>): 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}]"), "")
@@ -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<TmdbCastMember> = emptyList(),
val recommendations: List<TmdbRecommendation> = emptyList(),
)
private data class TmdbSearchResponse(
@SerializedName("results") val results: List<TmdbSearchItem> = emptyList(),
)
private data class TmdbSearchItem(
@SerializedName("id") val id: Int = 0,
)
private data class TmdbCreditsResponse(
@SerializedName("cast") val cast: List<TmdbCastItem> = 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<TmdbCastRole> = 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<TmdbRecommendationItem> = 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<Int, TmdbSeriesExtras>()
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<TmdbCastMember> {
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<TmdbCastItem> { 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<TmdbRecommendation> {
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 <T> get(url: String, type: Class<T>): 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"
}
}
@@ -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<EmbyServer>,
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)
}
File diff suppressed because it is too large. Load diff
+58
View File
@@ -120,6 +120,7 @@
<string name="detail_overview">Synopsis</string>
<string name="detail_episodes">Episodes</string>
<string name="detail_cast">Cast &amp; Crew</string>
<string name="detail_no_cast">No cast information available</string>
<string name="detail_information">Details</string>
<string name="detail_type">Type</string>
<string name="detail_original_title">Original title</string>
@@ -139,6 +140,63 @@
<string name="detail_runtime_minutes">%1$d min</string>
<string name="detail_runtime_hours">%1$d hr</string>
<string name="detail_runtime_hours_minutes">%1$d hr %2$d min</string>
<string name="detail_resources">Resources</string>
<string name="detail_best_resource">Best</string>
<string name="detail_loading_resources">Matching server resources...</string>
<string name="detail_no_resources">No matching resources</string>
<string name="detail_file_size">Video size</string>
<string name="detail_bitrate">Video bitrate</string>
<string name="detail_track_summary">%1$d audio · %2$d subtitles</string>
<string name="detail_recommendations">Recommendations</string>
<string name="detail_no_recommendations">No recommendations available</string>
<string name="detail_media_information">Media information</string>
<string name="detail_loading_media">Loading media information...</string>
<string name="detail_no_media_info">No media information</string>
<string name="detail_video_stream">Video %1$d</string>
<string name="detail_audio_stream">Audio %1$d</string>
<string name="detail_subtitle_stream">Subtitle %1$d</string>
<string name="detail_video">Video</string>
<string name="detail_audio">Audio</string>
<string name="detail_subtitle">Subtitle</string>
<string name="detail_codec_value">Codec · %1$s</string>
<string name="detail_resolution_value">Resolution · %1$d×%2$d</string>
<string name="detail_bitrate_value">Bitrate · %1$s</string>
<string name="detail_dynamic_range_value">Dynamic range · %1$s</string>
<string name="detail_frame_rate_value">Frame rate · %1$s</string>
<string name="detail_pixel_format_value">Pixel format · %1$s</string>
<string name="detail_sample_rate_value">Sample rate · %1$s</string>
<string name="detail_channels_value">Channels · %1$s</string>
<string name="detail_language_value">Language · %1$s</string>
<string name="detail_subtitle_flags">Default · %1$s Forced · %2$s</string>
<string name="detail_yes">Yes</string>
<string name="detail_no">No</string>
<string name="detail_locating_progress">Locating playback progress...</string>
<string name="detail_loading_seasons">Loading seasons...</string>
<string name="detail_loading_first_episodes">Loading the first episodes...</string>
<string name="detail_loading_more_episodes">Loading more</string>
<string name="detail_no_episodes">No episodes in this season</string>
<string name="detail_display_title">Display title</string>
<string name="detail_display_language">Display language</string>
<string name="detail_language">Language</string>
<string name="detail_codec">Codec</string>
<string name="detail_resolution">Resolution</string>
<string name="detail_frame_rate">Frame rate</string>
<string name="detail_stream_bitrate">Bitrate</string>
<string name="detail_dynamic_range">Dynamic range</string>
<string name="detail_interlaced">Interlaced</string>
<string name="detail_color_primaries">Color primaries</string>
<string name="detail_color_space">Color space</string>
<string name="detail_profile">Profile</string>
<string name="detail_level">Level</string>
<string name="detail_aspect_ratio">Aspect ratio</string>
<string name="detail_bit_depth">Bit depth</string>
<string name="detail_pixel_format">Pixel format</string>
<string name="detail_layout">Layout</string>
<string name="detail_channel_count">Channels</string>
<string name="detail_sample_rate">Sample rate</string>
<string name="detail_external">External</string>
<string name="detail_default">Default</string>
<string name="detail_forced">Forced</string>
<string name="player_preparing">Preparing playback...</string>
<string name="player_failed">Playback failed</string>
<string name="player_subtitle">Subtitles</string>
+58
View File
@@ -120,6 +120,7 @@
<string name="detail_overview">简介</string>
<string name="detail_episodes">剧集</string>
<string name="detail_cast">演职人员</string>
<string name="detail_no_cast">暂未获取到演员信息</string>
<string name="detail_information">详细信息</string>
<string name="detail_type">类型</string>
<string name="detail_original_title">原名</string>
@@ -139,6 +140,63 @@
<string name="detail_runtime_minutes">%1$d 分钟</string>
<string name="detail_runtime_hours">%1$d 小时</string>
<string name="detail_runtime_hours_minutes">%1$d 小时 %2$d 分钟</string>
<string name="detail_resources">资源列表</string>
<string name="detail_best_resource">最佳</string>
<string name="detail_loading_resources">正在匹配服务器资源...</string>
<string name="detail_no_resources">未匹配到可用资源</string>
<string name="detail_file_size">视频大小</string>
<string name="detail_bitrate">视频码率</string>
<string name="detail_track_summary">%1$d 个音频 · %2$d 个字幕</string>
<string name="detail_recommendations">推荐作品</string>
<string name="detail_no_recommendations">暂未获取到推荐作品</string>
<string name="detail_media_information">媒体信息</string>
<string name="detail_loading_media">正在读取媒体信息...</string>
<string name="detail_no_media_info">暂无媒体信息</string>
<string name="detail_video_stream">视频 %1$d</string>
<string name="detail_audio_stream">音频 %1$d</string>
<string name="detail_subtitle_stream">字幕 %1$d</string>
<string name="detail_video">视频</string>
<string name="detail_audio">音频</string>
<string name="detail_subtitle">字幕</string>
<string name="detail_codec_value">编码 · %1$s</string>
<string name="detail_resolution_value">分辨率 · %1$d×%2$d</string>
<string name="detail_bitrate_value">码率 · %1$s</string>
<string name="detail_dynamic_range_value">动态范围 · %1$s</string>
<string name="detail_frame_rate_value">帧率 · %1$s</string>
<string name="detail_pixel_format_value">像素格式 · %1$s</string>
<string name="detail_sample_rate_value">采样率 · %1$s</string>
<string name="detail_channels_value">声道 · %1$s</string>
<string name="detail_language_value">语言 · %1$s</string>
<string name="detail_subtitle_flags">默认 · %1$s 强制 · %2$s</string>
<string name="detail_yes">是</string>
<string name="detail_no">否</string>
<string name="detail_locating_progress">正在定位播放进度...</string>
<string name="detail_loading_seasons">正在获取季度...</string>
<string name="detail_loading_first_episodes">正在加载首批分集...</string>
<string name="detail_loading_more_episodes">继续加载</string>
<string name="detail_no_episodes">本季暂无可用分集</string>
<string name="detail_display_title">显示标题</string>
<string name="detail_display_language">显示语言</string>
<string name="detail_language">语言</string>
<string name="detail_codec">编码</string>
<string name="detail_resolution">分辨率</string>
<string name="detail_frame_rate">帧率</string>
<string name="detail_stream_bitrate">比特率</string>
<string name="detail_dynamic_range">动态范围</string>
<string name="detail_interlaced">隔行扫描</string>
<string name="detail_color_primaries">色彩原色</string>
<string name="detail_color_space">色彩空间</string>
<string name="detail_profile">配置</string>
<string name="detail_level">等级</string>
<string name="detail_aspect_ratio">长宽比</string>
<string name="detail_bit_depth">位深</string>
<string name="detail_pixel_format">像素格式</string>
<string name="detail_layout">布局</string>
<string name="detail_channel_count">声道</string>
<string name="detail_sample_rate">采样率</string>
<string name="detail_external">外部</string>
<string name="detail_default">默认</string>
<string name="detail_forced">强制</string>
<string name="player_preparing">正在准备播放...</string>
<string name="player_failed">播放失败</string>
<string name="player_subtitle">字幕</string>
@@ -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)
}
}