优化剧集详情
This commit is contained in:
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))
|
||||
|
||||
Reference in new issue
Block a user