diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt index ce061916..b1139b86 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt @@ -109,7 +109,11 @@ fun NuvioPosterCard( val posterCardStyle = rememberPosterCardStyleUiState() val cardWidth = shape.cardWidth(basePosterWidthDp = posterCardStyle.widthDp) val cardShape = RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp) - val catalogLogoOverlaySize = catalogLogoOverlaySize(basePosterWidthDp = posterCardStyle.widthDp) + val catalogLogoOverlaySize = catalogLogoOverlaySize( + basePosterWidthDp = posterCardStyle.widthDp, + shape = shape, + ) + val shouldShowTitleBelow = showTitleBelow && !posterCardStyle.hideLabelsEnabled Column( modifier = modifier.width(cardWidth), @@ -178,7 +182,7 @@ fun NuvioPosterCard( .padding(6.dp), ) } - if (showTitleBelow) { + if (shouldShowTitleBelow) { Text( text = title, style = MaterialTheme.typography.bodyMedium, @@ -302,12 +306,24 @@ private data class CatalogLogoOverlaySize( val textMaxWidth: Dp, ) -private fun catalogLogoOverlaySize(basePosterWidthDp: Int): CatalogLogoOverlaySize = - when { - basePosterWidthDp <= 108 -> CatalogLogoOverlaySize(width = 72.dp, height = 18.dp, textMaxWidth = 92.dp) - basePosterWidthDp <= 120 -> CatalogLogoOverlaySize(width = 80.dp, height = 20.dp, textMaxWidth = 104.dp) - basePosterWidthDp <= 132 -> CatalogLogoOverlaySize(width = 88.dp, height = 22.dp, textMaxWidth = 112.dp) - else -> CatalogLogoOverlaySize(width = 96.dp, height = 24.dp, textMaxWidth = 124.dp) +private fun catalogLogoOverlaySize( + basePosterWidthDp: Int, + shape: NuvioPosterShape, +): CatalogLogoOverlaySize = + if (shape == NuvioPosterShape.Landscape) { + when { + basePosterWidthDp <= 108 -> CatalogLogoOverlaySize(width = 92.dp, height = 24.dp, textMaxWidth = 120.dp) + basePosterWidthDp <= 120 -> CatalogLogoOverlaySize(width = 104.dp, height = 28.dp, textMaxWidth = 132.dp) + basePosterWidthDp <= 132 -> CatalogLogoOverlaySize(width = 116.dp, height = 30.dp, textMaxWidth = 144.dp) + else -> CatalogLogoOverlaySize(width = 128.dp, height = 34.dp, textMaxWidth = 156.dp) + } + } else { + when { + basePosterWidthDp <= 108 -> CatalogLogoOverlaySize(width = 72.dp, height = 18.dp, textMaxWidth = 92.dp) + basePosterWidthDp <= 120 -> CatalogLogoOverlaySize(width = 80.dp, height = 20.dp, textMaxWidth = 104.dp) + basePosterWidthDp <= 132 -> CatalogLogoOverlaySize(width = 88.dp, height = 22.dp, textMaxWidth = 112.dp) + else -> CatalogLogoOverlaySize(width = 96.dp, height = 24.dp, textMaxWidth = 124.dp) + } } private fun NuvioPosterShape.cardWidth(basePosterWidthDp: Int): Dp = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleRepository.kt index 449c1d06..80bcb0b7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleRepository.kt @@ -18,6 +18,7 @@ private data class StoredPosterCardStylePreferences( val heightDp: Int = DefaultPosterCardHeightDp, val cornerRadiusDp: Int = DefaultPosterCardCornerRadiusDp, val catalogLandscapeModeEnabled: Boolean = false, + val hideLabelsEnabled: Boolean = false, ) data class PosterCardStyleUiState( @@ -25,6 +26,7 @@ data class PosterCardStyleUiState( val heightDp: Int = DefaultPosterCardHeightDp, val cornerRadiusDp: Int = DefaultPosterCardCornerRadiusDp, val catalogLandscapeModeEnabled: Boolean = false, + val hideLabelsEnabled: Boolean = false, ) object PosterCardStyleRepository { @@ -78,6 +80,13 @@ object PosterCardStyleRepository { persist() } + fun setHideLabelsEnabled(enabled: Boolean) { + ensureLoaded() + if (_uiState.value.hideLabelsEnabled == enabled) return + _uiState.value = _uiState.value.copy(hideLabelsEnabled = enabled) + persist() + } + fun resetToDefaults() { ensureLoaded() if (_uiState.value == PosterCardStyleUiState()) return @@ -107,6 +116,7 @@ object PosterCardStyleRepository { heightDp = heightDp, cornerRadiusDp = cornerRadiusDp, catalogLandscapeModeEnabled = stored.catalogLandscapeModeEnabled, + hideLabelsEnabled = stored.hideLabelsEnabled, ) } else { PosterCardStyleUiState() @@ -121,6 +131,7 @@ object PosterCardStyleRepository { heightDp = _uiState.value.heightDp, cornerRadiusDp = _uiState.value.cornerRadiusDp, catalogLandscapeModeEnabled = _uiState.value.catalogLandscapeModeEnabled, + hideLabelsEnabled = _uiState.value.hideLabelsEnabled, ), ), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index f2ddd363..55c4faaa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -179,6 +179,7 @@ fun CatalogScreen( CatalogPosterTile( item = item, cornerRadiusDp = posterCardStyle.cornerRadiusDp, + hideLabels = posterCardStyle.hideLabelsEnabled, onClick = onPosterClick?.let { { it(item) } }, ) } @@ -248,6 +249,7 @@ private fun CatalogHeader( private fun CatalogPosterTile( item: MetaPreview, cornerRadiusDp: Int, + hideLabels: Boolean, onClick: (() -> Unit)? = null, ) { Column( @@ -270,24 +272,26 @@ private fun CatalogPosterTile( ) } } - Text( - text = item.name, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) } - if (detail != null) { + if (!hideLabels) { Text( - text = detail, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, + text = item.name, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) - } else { - Spacer(modifier = Modifier.height(8.dp)) + val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) } + if (detail != null) { + Text( + text = detail, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Spacer(modifier = Modifier.height(8.dp)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt index bd16d34a..fc8b4133 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt @@ -26,10 +26,10 @@ fun HomePosterCard( imageUrl = if (isLandscapeMode) (item.banner ?: item.poster) else item.poster, modifier = modifier, shape = if (isLandscapeMode) NuvioPosterShape.Landscape else item.posterShape.toNuvioPosterShape(), - detailLine = if (isLandscapeMode) null else item.releaseInfo?.let { formatReleaseDateForDisplay(it) }, - showTitleBelow = !isLandscapeMode, + detailLine = if (isLandscapeMode || posterCardStyle.hideLabelsEnabled) null else item.releaseInfo?.let { formatReleaseDateForDisplay(it) }, + showTitleBelow = !posterCardStyle.hideLabelsEnabled, bottomLeftLogoUrl = if (isLandscapeMode) item.logo else null, - bottomLeftText = if (isLandscapeMode && item.logo.isNullOrBlank()) item.name else null, + bottomLeftText = if (isLandscapeMode && item.logo.isNullOrBlank() && !posterCardStyle.hideLabelsEnabled) item.name else null, isWatched = isWatched, onClick = onClick, onLongClick = onLongClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index f4337812..4e94528e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -350,6 +350,7 @@ private fun DiscoverGridRow( DiscoverPosterTile( item = item, cornerRadiusDp = posterCardStyle.cornerRadiusDp, + hideLabels = posterCardStyle.hideLabelsEnabled, modifier = Modifier.weight(1f), isWatched = WatchingState.isPosterWatched( watchedKeys = watchedKeys, @@ -370,6 +371,7 @@ private fun DiscoverGridRow( private fun DiscoverPosterTile( item: MetaPreview, cornerRadiusDp: Int, + hideLabels: Boolean, modifier: Modifier = Modifier, isWatched: Boolean = false, onClick: (() -> Unit)? = null, @@ -402,24 +404,26 @@ private fun DiscoverPosterTile( .padding(6.dp), ) } - Text( - text = item.name, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) } - if (detail != null) { + if (!hideLabels) { Text( - text = detail, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, + text = item.name, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) - } else { - Spacer(modifier = Modifier.height(8.dp)) + val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) } + if (detail != null) { + Text( + text = detail, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Spacer(modifier = Modifier.height(8.dp)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt index 79b1e55b..17fc67d5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.settings import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -54,9 +55,11 @@ internal fun LazyListScope.posterCustomizationSettingsContent( widthDp = uiState.widthDp, cornerRadiusDp = uiState.cornerRadiusDp, catalogLandscapeModeEnabled = uiState.catalogLandscapeModeEnabled, + hideLabelsEnabled = uiState.hideLabelsEnabled, onWidthSelected = PosterCardStyleRepository::setWidthDp, onCornerRadiusSelected = PosterCardStyleRepository::setCornerRadiusDp, onCatalogLandscapeModeChange = PosterCardStyleRepository::setCatalogLandscapeModeEnabled, + onHideLabelsChange = PosterCardStyleRepository::setHideLabelsEnabled, ) } } @@ -70,9 +73,11 @@ private fun PosterCardStyleControls( widthDp: Int, cornerRadiusDp: Int, catalogLandscapeModeEnabled: Boolean, + hideLabelsEnabled: Boolean, onWidthSelected: (Int) -> Unit, onCornerRadiusSelected: (Int) -> Unit, onCatalogLandscapeModeChange: (Boolean) -> Unit, + onHideLabelsChange: (Boolean) -> Unit, ) { val widthOptions = listOf( PresetOption("Compact", 104), @@ -121,6 +126,11 @@ private fun PosterCardStyleControls( checked = catalogLandscapeModeEnabled, onCheckedChange = onCatalogLandscapeModeChange, ) + PosterToggleRow( + title = "Hide labels", + checked = hideLabelsEnabled, + onCheckedChange = onHideLabelsChange, + ) } } @@ -128,6 +138,19 @@ private fun PosterCardStyleControls( private fun PosterLandscapeModeToggleRow( checked: Boolean, onCheckedChange: (Boolean) -> Unit, +) { + PosterToggleRow( + title = "Landscape mode for shelf posters", + checked = checked, + onCheckedChange = onCheckedChange, + ) +} + +@Composable +private fun PosterToggleRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, ) { Row( modifier = Modifier.fillMaxWidth(), @@ -135,7 +158,7 @@ private fun PosterLandscapeModeToggleRow( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Landscape mode for shelf posters", + text = title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium, @@ -203,26 +226,13 @@ private fun PosterCardLivePreview( .width(animatedWidth.value) .height(animatedHeight.value) .clip(RoundedCornerShape(animatedCornerRadius.value)) - .background(MaterialTheme.colorScheme.surfaceVariant), - ) { - Box( - modifier = Modifier - .align(Alignment.TopStart) - .padding(10.dp) - .size(34.dp) - .clip(RoundedCornerShape(999.dp)) - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), - ) - Box( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(10.dp) - .width(70.dp) - .height(7.dp) - .clip(RoundedCornerShape(999.dp)) - .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.22f)), - ) - } + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + shape = RoundedCornerShape(animatedCornerRadius.value), + ), + ) } Column( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt index e785c453..b59f9ad1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt @@ -32,6 +32,7 @@ object TmdbMetadataService { private val entityBrowseCache = mutableMapOf() private val entityHeaderCache = mutableMapOf() private val entityRailCache = mutableMapOf>() + private val previewArtworkCache = mutableMapOf() suspend fun fetchPersonDetail( personId: Int, @@ -75,16 +76,16 @@ object TmdbMetadataService { val preferCrew = preferCrewCredits ?: shouldPreferCrewCredits(person.knownForDepartment) - val castMovieCredits = mapPersonMovieCreditsFromCast(credits?.cast.orEmpty()) - val crewMovieCredits = mapPersonMovieCreditsFromCrew(credits?.crew.orEmpty()) + val castMovieCredits = mapPersonMovieCreditsFromCast(credits?.cast.orEmpty(), language) + val crewMovieCredits = mapPersonMovieCreditsFromCrew(credits?.crew.orEmpty(), language) val movieCredits = when { preferCrew && crewMovieCredits.isNotEmpty() -> crewMovieCredits castMovieCredits.isNotEmpty() -> castMovieCredits else -> crewMovieCredits } - val castTvCredits = mapPersonTvCreditsFromCast(credits?.cast.orEmpty()) - val crewTvCredits = mapPersonTvCreditsFromCrew(credits?.crew.orEmpty()) + val castTvCredits = mapPersonTvCreditsFromCast(credits?.cast.orEmpty(), language) + val crewTvCredits = mapPersonTvCreditsFromCrew(credits?.crew.orEmpty(), language) val tvCredits = when { preferCrew && crewTvCredits.isNotEmpty() -> crewTvCredits castTvCredits.isNotEmpty() -> castTvCredits @@ -116,88 +117,160 @@ object TmdbMetadataService { return department.isNotBlank() && department != "acting" && department != "actors" } - private fun mapPersonMovieCreditsFromCast(cast: List): List { + private suspend fun mapPersonMovieCreditsFromCast( + cast: List, + language: String, + ): List = coroutineScope { val seen = mutableSetOf() - return cast - .filter { it.mediaType == "movie" && it.posterPath != null } + cast + .filter { it.mediaType == "movie" && (it.posterPath != null || it.backdropPath != null) } .sortedByDescending { it.voteAverage ?: 0.0 } .mapNotNull { credit -> if (!seen.add(credit.id)) return@mapNotNull null val title = credit.title ?: credit.name ?: return@mapNotNull null - MetaPreview( - id = "tmdb:${credit.id}", - type = "movie", - name = title, - poster = buildImageUrl(credit.posterPath, "w500"), - description = credit.overview?.takeIf { it.isNotBlank() }, - releaseInfo = credit.releaseDate?.take(4), - rawReleaseDate = credit.releaseDate, - popularity = credit.popularity, - ) + async { + val artwork = fetchPreviewArtwork( + tmdbId = credit.id, + mediaType = "movie", + language = language, + ) + val poster = buildImageUrl(credit.posterPath, "w500") + ?: buildImageUrl(credit.backdropPath, "w780") + ?: artwork?.backdrop + ?: return@async null + MetaPreview( + id = "tmdb:${credit.id}", + type = "movie", + name = title, + poster = poster, + banner = buildImageUrl(credit.backdropPath, "w780") ?: artwork?.backdrop, + logo = artwork?.logo, + description = credit.overview?.takeIf { it.isNotBlank() }, + releaseInfo = credit.releaseDate?.take(4), + rawReleaseDate = credit.releaseDate, + popularity = credit.popularity, + ) + } } + .awaitAll() + .filterNotNull() } - private fun mapPersonMovieCreditsFromCrew(crew: List): List { + private suspend fun mapPersonMovieCreditsFromCrew( + crew: List, + language: String, + ): List = coroutineScope { val seen = mutableSetOf() - return crew - .filter { it.mediaType == "movie" && it.posterPath != null } + crew + .filter { it.mediaType == "movie" && (it.posterPath != null || it.backdropPath != null) } .sortedByDescending { it.voteAverage ?: 0.0 } .mapNotNull { credit -> if (!seen.add(credit.id)) return@mapNotNull null val title = credit.title ?: credit.name ?: return@mapNotNull null - MetaPreview( - id = "tmdb:${credit.id}", - type = "movie", - name = title, - poster = buildImageUrl(credit.posterPath, "w500"), - description = credit.overview?.takeIf { it.isNotBlank() }, - releaseInfo = credit.releaseDate?.take(4), - rawReleaseDate = credit.releaseDate, - popularity = credit.popularity, - ) + async { + val artwork = fetchPreviewArtwork( + tmdbId = credit.id, + mediaType = "movie", + language = language, + ) + val poster = buildImageUrl(credit.posterPath, "w500") + ?: buildImageUrl(credit.backdropPath, "w780") + ?: artwork?.backdrop + ?: return@async null + MetaPreview( + id = "tmdb:${credit.id}", + type = "movie", + name = title, + poster = poster, + banner = buildImageUrl(credit.backdropPath, "w780") ?: artwork?.backdrop, + logo = artwork?.logo, + description = credit.overview?.takeIf { it.isNotBlank() }, + releaseInfo = credit.releaseDate?.take(4), + rawReleaseDate = credit.releaseDate, + popularity = credit.popularity, + ) + } } + .awaitAll() + .filterNotNull() } - private fun mapPersonTvCreditsFromCast(cast: List): List { + private suspend fun mapPersonTvCreditsFromCast( + cast: List, + language: String, + ): List = coroutineScope { val seen = mutableSetOf() - return cast - .filter { it.mediaType == "tv" && it.posterPath != null } + cast + .filter { it.mediaType == "tv" && (it.posterPath != null || it.backdropPath != null) } .sortedByDescending { it.voteAverage ?: 0.0 } .mapNotNull { credit -> if (!seen.add(credit.id)) return@mapNotNull null val title = credit.name ?: credit.title ?: return@mapNotNull null - MetaPreview( - id = "tmdb:${credit.id}", - type = "series", - name = title, - poster = buildImageUrl(credit.posterPath, "w500"), - description = credit.overview?.takeIf { it.isNotBlank() }, - releaseInfo = credit.firstAirDate?.take(4), - rawReleaseDate = credit.firstAirDate, - popularity = credit.popularity, - ) + async { + val artwork = fetchPreviewArtwork( + tmdbId = credit.id, + mediaType = "tv", + language = language, + ) + val poster = buildImageUrl(credit.posterPath, "w500") + ?: buildImageUrl(credit.backdropPath, "w780") + ?: artwork?.backdrop + ?: return@async null + MetaPreview( + id = "tmdb:${credit.id}", + type = "series", + name = title, + poster = poster, + banner = buildImageUrl(credit.backdropPath, "w780") ?: artwork?.backdrop, + logo = artwork?.logo, + description = credit.overview?.takeIf { it.isNotBlank() }, + releaseInfo = credit.firstAirDate?.take(4), + rawReleaseDate = credit.firstAirDate, + popularity = credit.popularity, + ) + } } + .awaitAll() + .filterNotNull() } - private fun mapPersonTvCreditsFromCrew(crew: List): List { + private suspend fun mapPersonTvCreditsFromCrew( + crew: List, + language: String, + ): List = coroutineScope { val seen = mutableSetOf() - return crew - .filter { it.mediaType == "tv" && it.posterPath != null } + crew + .filter { it.mediaType == "tv" && (it.posterPath != null || it.backdropPath != null) } .sortedByDescending { it.voteAverage ?: 0.0 } .mapNotNull { credit -> if (!seen.add(credit.id)) return@mapNotNull null val title = credit.name ?: credit.title ?: return@mapNotNull null - MetaPreview( - id = "tmdb:${credit.id}", - type = "series", - name = title, - poster = buildImageUrl(credit.posterPath, "w500"), - description = credit.overview?.takeIf { it.isNotBlank() }, - releaseInfo = credit.firstAirDate?.take(4), - rawReleaseDate = credit.firstAirDate, - popularity = credit.popularity, - ) + async { + val artwork = fetchPreviewArtwork( + tmdbId = credit.id, + mediaType = "tv", + language = language, + ) + val poster = buildImageUrl(credit.posterPath, "w500") + ?: buildImageUrl(credit.backdropPath, "w780") + ?: artwork?.backdrop + ?: return@async null + MetaPreview( + id = "tmdb:${credit.id}", + type = "series", + name = title, + poster = poster, + banner = buildImageUrl(credit.backdropPath, "w780") ?: artwork?.backdrop, + logo = artwork?.logo, + description = credit.overview?.takeIf { it.isNotBlank() }, + releaseInfo = credit.firstAirDate?.take(4), + rawReleaseDate = credit.firstAirDate, + popularity = credit.popularity, + ) + } } + .awaitAll() + .filterNotNull() } suspend fun fetchEntityBrowse( @@ -321,10 +394,16 @@ object TmdbMetadataService { val results = response?.results.orEmpty() val totalPages = response?.totalPages ?: page - val mappedItems = results - .filter { it.id > 0 } - .mapNotNull { item -> mapEntityDiscoverResult(item, mediaType) } - .take(ENTITY_RAIL_MAX_ITEMS) + val mappedItems = coroutineScope { + results + .filter { it.id > 0 } + .map { item -> + async { mapEntityDiscoverResult(item, mediaType, language) } + } + .awaitAll() + .filterNotNull() + .take(ENTITY_RAIL_MAX_ITEMS) + } TmdbEntityRailPageResult( items = mappedItems, @@ -406,17 +485,26 @@ object TmdbMetadataService { return header } - private fun mapEntityDiscoverResult( + private suspend fun mapEntityDiscoverResult( result: TmdbDiscoverResult, mediaType: TmdbEntityMediaType, + language: String, ): MetaPreview? { val title = result.title?.takeIf { it.isNotBlank() } ?: result.name?.takeIf { it.isNotBlank() } ?: result.originalTitle?.takeIf { it.isNotBlank() } ?: result.originalName?.takeIf { it.isNotBlank() } ?: return null + + val artwork = fetchPreviewArtwork( + tmdbId = result.id, + mediaType = mediaType.value, + language = language, + ) + val poster = buildImageUrl(result.posterPath, "w500") ?: buildImageUrl(result.backdropPath, "w780") + ?: artwork?.backdrop ?: return null val releaseInfo = when (mediaType) { TmdbEntityMediaType.MOVIE -> result.releaseDate?.take(4) @@ -427,11 +515,60 @@ object TmdbMetadataService { type = if (mediaType == TmdbEntityMediaType.TV) "series" else "movie", name = title, poster = poster, + banner = buildImageUrl(result.backdropPath, "w780") ?: artwork?.backdrop, + logo = artwork?.logo, description = result.overview?.takeIf { it.isNotBlank() }, releaseInfo = releaseInfo, ) } + private data class TmdbPreviewArtwork( + val backdrop: String?, + val logo: String?, + ) + + private suspend fun fetchPreviewArtwork( + tmdbId: Int, + mediaType: String, + language: String, + ): TmdbPreviewArtwork? = withContext(Dispatchers.Default) { + val normalizedLanguage = normalizeTmdbLanguage(language) + val cacheKey = "$tmdbId:$mediaType:$normalizedLanguage:preview_artwork" + previewArtworkCache[cacheKey]?.let { cached -> + return@withContext cached.takeIf { it.backdrop != null || it.logo != null } + } + + val includeImageLanguage = buildString { + append(normalizedLanguage.substringBefore("-")) + append(",") + append(normalizedLanguage) + append(",en,null") + } + + val response = coroutineScope { + val details = async { + fetch( + endpoint = "$mediaType/$tmdbId", + query = mapOf("language" to normalizedLanguage), + ) + } + val images = async { + fetch( + endpoint = "$mediaType/$tmdbId/images", + query = mapOf("include_image_language" to includeImageLanguage), + ) + } + details.await() to images.await() + } + + val artwork = TmdbPreviewArtwork( + backdrop = buildImageUrl(response.first?.backdropPath, "w1280"), + logo = buildImageUrl(response.second?.logos.orEmpty().selectBestLocalizedImagePath(normalizedLanguage), "w500"), + ) + previewArtworkCache[cacheKey] = artwork + artwork.takeIf { it.backdrop != null || it.logo != null } + } + private fun buildEntityMediaOrder( entityKind: TmdbEntityKind, sourceType: String,