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 2b7b5b5..5c39111 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt @@ -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") diff --git a/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt index 816fa20..7799fed 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt @@ -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() private val movieExtrasCache = ConcurrentHashMap() @@ -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) diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt index 3a9c9d1..57d142c 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt @@ -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)) + } } } } 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 5d9b650..2e78d04 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt @@ -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, 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)) diff --git a/app/src/test/kotlin/com/example/myapplication/data/TmdbRecommendationTest.kt b/app/src/test/kotlin/com/example/myapplication/data/TmdbRecommendationTest.kt new file mode 100644 index 0000000..152648b --- /dev/null +++ b/app/src/test/kotlin/com/example/myapplication/data/TmdbRecommendationTest.kt @@ -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")) + } +} diff --git a/app/src/test/kotlin/com/example/myapplication/screens/HomeScreenDataTest.kt b/app/src/test/kotlin/com/example/myapplication/screens/HomeScreenDataTest.kt index a56b393..f6ea67c 100644 --- a/app/src/test/kotlin/com/example/myapplication/screens/HomeScreenDataTest.kt +++ b/app/src/test/kotlin/com/example/myapplication/screens/HomeScreenDataTest.kt @@ -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))