优化剧集详情

This commit is contained in:
beyond committed 2026-08-31 21:09:37 +08:00
1 parent e076f17061
commit ae5b82c6fe
6 files changed
+97 -31

No files matched your search

@@ -254,7 +254,7 @@ object EmbyRepository {
try {
val url = Uri.parse("${server.fullUrl}/emby/Users/${server.userId}/Items/Resume").buildUpon()
.appendQueryParameter("Limit", limit.toString())
.appendQueryParameter("Fields", "ProductionYear,Genres,UserData")
.appendQueryParameter("Fields", "ProductionYear,Genres,UserData,SeriesId,SeriesName")
.appendQueryParameter("MediaTypes", "Video")
.appendQueryParameter("EnableImages", "true")
.appendQueryParameter("EnableUserData", "true")
@@ -137,16 +137,21 @@ private data class TmdbEpisodeDetail(
private data class TmdbRecommendationItem(
@SerializedName("id") val id: Int = 0,
@SerializedName("name") val name: String = "",
@SerializedName("title") val title: String = "",
@SerializedName("original_name") val originalName: String = "",
@SerializedName("original_title") val originalTitle: String = "",
@SerializedName("poster_path") val posterPath: String? = null,
@SerializedName("first_air_date") val firstAirDate: String = "",
@SerializedName("release_date") val releaseDate: String = "",
@SerializedName("vote_average") val voteAverage: Double = 0.0,
)
private const val TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w342"
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 const val IMAGE_BASE = TMDB_IMAGE_BASE
private val gson = Gson()
private val extrasCache = ConcurrentHashMap<Int, TmdbSeriesExtras>()
private val movieExtrasCache = ConcurrentHashMap<Int, TmdbSeriesExtras>()
@@ -218,18 +223,8 @@ object TmdbRepository {
.appendQueryParameter("page", "1")
.build()
return get(url.toString(), TmdbRecommendationsResponse::class.java)?.results.orEmpty()
.filter { it.name.isNotBlank() }
.mapNotNull { it.toRecommendation() }
.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,
)
}
}
// ── Movie support ──────────────────────────────────────────────────────
@@ -307,18 +302,8 @@ object TmdbRepository {
.appendQueryParameter("page", "1")
.build()
val result = get(url.toString(), TmdbRecommendationsResponse::class.java)?.results.orEmpty()
.filter { it.name.isNotBlank() }
.mapNotNull { it.toRecommendation() }
.take(20)
.map { item ->
TmdbRecommendation(
id = item.id,
name = item.name,
originalName = item.originalName,
posterUrl = item.posterPath?.let { IMAGE_BASE + it },
firstAirYear = item.firstAirDate.take(4),
rating = item.voteAverage,
)
}
Log.d("TmdbRepository", "getMovieRecommendations: movieId=$movieId -> ${result.size} recommendations")
return result
}
@@ -437,3 +422,22 @@ object TmdbRepository {
else -> "en-US"
}
}
private fun TmdbRecommendationItem.toRecommendation(): TmdbRecommendation? {
val displayName = recommendationTitle(name, title)
if (displayName.isBlank()) return null
return TmdbRecommendation(
id = id,
name = displayName,
originalName = originalName.ifBlank { originalTitle },
posterUrl = posterPath?.let { TMDB_IMAGE_BASE + it },
firstAirYear = recommendationYear(firstAirDate, releaseDate),
rating = voteAverage,
)
}
internal fun recommendationTitle(name: String, title: String): String =
name.ifBlank { title }
internal fun recommendationYear(firstAirDate: String, releaseDate: String): String =
firstAirDate.ifBlank { releaseDate }.take(4)
@@ -458,6 +458,13 @@ private fun ContinueWatchingPoster(item: EmbyItem, serverUrl: String, onItemClic
}
}
internal fun continueWatchingDetailId(item: EmbyItem): String =
if (item.type.equals("Episode", ignoreCase = true)) {
item.seriesId?.takeIf { it.isNotBlank() } ?: item.id
} else {
item.id
}
@Composable
private fun LazyLibrarySection(folder: EmbyMediaFolder, activeServer: EmbyServer, onItemClick: (EmbyItem) -> Unit = {}) {
val cacheKey = remember(activeServer.id, activeServer.fullUrl, activeServer.userId) {
@@ -889,7 +896,9 @@ fun HomeScreen(
SectionTitle(title = stringResource(Res.string.continue_watching))
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items(resumeItems) { item ->
ContinueWatchingPoster(item, server.fullUrl) { onOpenDetail(it.id) }
ContinueWatchingPoster(item, server.fullUrl) {
onOpenDetail(continueWatchingDetailId(it))
}
}
}
}
@@ -330,14 +330,18 @@ fun MediaDetailScreen(
val tmdbId = detail.providerIds.entries
.firstOrNull { it.key.equals("Tmdb", ignoreCase = true) }
?.value
LaunchedEffect(detail.id, detail.name, detail.productionYear, tmdbId, loadingFullDetail) {
if (!TmdbRepository.isConfigured) return@LaunchedEffect
if (detail.type == "Series" && loadingFullDetail) {
val supportsTmdbExtras = detail.type == "Series" || detail.type == "Movie"
LaunchedEffect(detail.id, detail.type, detail.name, detail.productionYear, tmdbId, loadingFullDetail) {
if (!TmdbRepository.isConfigured || !supportsTmdbExtras) {
loadingTmdb = false
return@LaunchedEffect
}
if (loadingFullDetail) {
loadingTmdb = true
return@LaunchedEffect
}
android.util.Log.d("MediaDetail", "TMDB loading: type=${detail.type}, tmdbId=$tmdbId, name=${detail.name}, year=${detail.productionYear}")
loadingTmdb = detail.type == "Series"
loadingTmdb = true
tmdbExtras = try {
if (detail.type == "Series") {
TmdbRepository.getSeriesExtras(tmdbId, detail.name, detail.productionYear)
@@ -1269,7 +1273,9 @@ private fun CastSection(cast: List<DetailCastMember>, loading: Boolean) {
@Composable
private fun CastMemberCard(member: DetailCastMember) {
Column(
modifier = Modifier.width(92.dp),
modifier = Modifier
.width(92.dp)
.height(128.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
@@ -1333,7 +1339,12 @@ private fun CastMemberCard(member: DetailCastMember) {
private fun CastSkeletonRow() {
LazyRow(horizontalArrangement = Arrangement.spacedBy(14.dp), userScrollEnabled = false) {
items(4) {
Column(Modifier.width(92.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Column(
Modifier
.width(92.dp)
.height(128.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))
@@ -0,0 +1,23 @@
package com.example.myapplication.data
import org.junit.Assert.assertEquals
import org.junit.Test
class TmdbRecommendationTest {
@Test
fun `movie recommendation uses movie title when tv name is absent`() {
assertEquals("Movie title", recommendationTitle("", "Movie title"))
}
@Test
fun `movie recommendation uses release year when tv air date is absent`() {
assertEquals("2024", recommendationYear("", "2024-06-01"))
}
@Test
fun `tv recommendation keeps tv name and air year`() {
assertEquals("Series title", recommendationTitle("Series title", "Movie title"))
assertEquals("2023", recommendationYear("2023-01-01", "2024-06-01"))
}
}
@@ -25,6 +25,25 @@ class HomeScreenDataTest {
assertEquals("The Movie", heroDisplayTitle(movie))
}
@Test
fun continueWatchingEpisodeOpensSeriesDetail() {
val episode = EmbyItem(
id = "episode-1",
name = "Episode 7",
type = "Episode",
seriesId = "series-1",
)
assertEquals("series-1", continueWatchingDetailId(episode))
}
@Test
fun continueWatchingMovieKeepsMovieDetail() {
val movie = EmbyItem(id = "movie-1", name = "The Movie", type = "Movie")
assertEquals("movie-1", continueWatchingDetailId(movie))
}
@Test
fun elapsedPlaybackFormatsMilliseconds() {
assertEquals("12:34", formatPlaybackElapsed(754_000L))