feat: label control

This commit is contained in:
tapframe 2026-04-13 19:05:24 +05:30
parent 2d1ab47919
commit e373759de7
7 changed files with 305 additions and 123 deletions

View file

@ -109,7 +109,11 @@ fun NuvioPosterCard(
val posterCardStyle = rememberPosterCardStyleUiState() val posterCardStyle = rememberPosterCardStyleUiState()
val cardWidth = shape.cardWidth(basePosterWidthDp = posterCardStyle.widthDp) val cardWidth = shape.cardWidth(basePosterWidthDp = posterCardStyle.widthDp)
val cardShape = RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp) 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( Column(
modifier = modifier.width(cardWidth), modifier = modifier.width(cardWidth),
@ -178,7 +182,7 @@ fun NuvioPosterCard(
.padding(6.dp), .padding(6.dp),
) )
} }
if (showTitleBelow) { if (shouldShowTitleBelow) {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@ -302,12 +306,24 @@ private data class CatalogLogoOverlaySize(
val textMaxWidth: Dp, val textMaxWidth: Dp,
) )
private fun catalogLogoOverlaySize(basePosterWidthDp: Int): CatalogLogoOverlaySize = private fun catalogLogoOverlaySize(
when { basePosterWidthDp: Int,
basePosterWidthDp <= 108 -> CatalogLogoOverlaySize(width = 72.dp, height = 18.dp, textMaxWidth = 92.dp) shape: NuvioPosterShape,
basePosterWidthDp <= 120 -> CatalogLogoOverlaySize(width = 80.dp, height = 20.dp, textMaxWidth = 104.dp) ): CatalogLogoOverlaySize =
basePosterWidthDp <= 132 -> CatalogLogoOverlaySize(width = 88.dp, height = 22.dp, textMaxWidth = 112.dp) if (shape == NuvioPosterShape.Landscape) {
else -> CatalogLogoOverlaySize(width = 96.dp, height = 24.dp, textMaxWidth = 124.dp) 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 = private fun NuvioPosterShape.cardWidth(basePosterWidthDp: Int): Dp =

View file

@ -18,6 +18,7 @@ private data class StoredPosterCardStylePreferences(
val heightDp: Int = DefaultPosterCardHeightDp, val heightDp: Int = DefaultPosterCardHeightDp,
val cornerRadiusDp: Int = DefaultPosterCardCornerRadiusDp, val cornerRadiusDp: Int = DefaultPosterCardCornerRadiusDp,
val catalogLandscapeModeEnabled: Boolean = false, val catalogLandscapeModeEnabled: Boolean = false,
val hideLabelsEnabled: Boolean = false,
) )
data class PosterCardStyleUiState( data class PosterCardStyleUiState(
@ -25,6 +26,7 @@ data class PosterCardStyleUiState(
val heightDp: Int = DefaultPosterCardHeightDp, val heightDp: Int = DefaultPosterCardHeightDp,
val cornerRadiusDp: Int = DefaultPosterCardCornerRadiusDp, val cornerRadiusDp: Int = DefaultPosterCardCornerRadiusDp,
val catalogLandscapeModeEnabled: Boolean = false, val catalogLandscapeModeEnabled: Boolean = false,
val hideLabelsEnabled: Boolean = false,
) )
object PosterCardStyleRepository { object PosterCardStyleRepository {
@ -78,6 +80,13 @@ object PosterCardStyleRepository {
persist() persist()
} }
fun setHideLabelsEnabled(enabled: Boolean) {
ensureLoaded()
if (_uiState.value.hideLabelsEnabled == enabled) return
_uiState.value = _uiState.value.copy(hideLabelsEnabled = enabled)
persist()
}
fun resetToDefaults() { fun resetToDefaults() {
ensureLoaded() ensureLoaded()
if (_uiState.value == PosterCardStyleUiState()) return if (_uiState.value == PosterCardStyleUiState()) return
@ -107,6 +116,7 @@ object PosterCardStyleRepository {
heightDp = heightDp, heightDp = heightDp,
cornerRadiusDp = cornerRadiusDp, cornerRadiusDp = cornerRadiusDp,
catalogLandscapeModeEnabled = stored.catalogLandscapeModeEnabled, catalogLandscapeModeEnabled = stored.catalogLandscapeModeEnabled,
hideLabelsEnabled = stored.hideLabelsEnabled,
) )
} else { } else {
PosterCardStyleUiState() PosterCardStyleUiState()
@ -121,6 +131,7 @@ object PosterCardStyleRepository {
heightDp = _uiState.value.heightDp, heightDp = _uiState.value.heightDp,
cornerRadiusDp = _uiState.value.cornerRadiusDp, cornerRadiusDp = _uiState.value.cornerRadiusDp,
catalogLandscapeModeEnabled = _uiState.value.catalogLandscapeModeEnabled, catalogLandscapeModeEnabled = _uiState.value.catalogLandscapeModeEnabled,
hideLabelsEnabled = _uiState.value.hideLabelsEnabled,
), ),
), ),
) )

View file

@ -179,6 +179,7 @@ fun CatalogScreen(
CatalogPosterTile( CatalogPosterTile(
item = item, item = item,
cornerRadiusDp = posterCardStyle.cornerRadiusDp, cornerRadiusDp = posterCardStyle.cornerRadiusDp,
hideLabels = posterCardStyle.hideLabelsEnabled,
onClick = onPosterClick?.let { { it(item) } }, onClick = onPosterClick?.let { { it(item) } },
) )
} }
@ -248,6 +249,7 @@ private fun CatalogHeader(
private fun CatalogPosterTile( private fun CatalogPosterTile(
item: MetaPreview, item: MetaPreview,
cornerRadiusDp: Int, cornerRadiusDp: Int,
hideLabels: Boolean,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
) { ) {
Column( Column(
@ -270,24 +272,26 @@ private fun CatalogPosterTile(
) )
} }
} }
Text( if (!hideLabels) {
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) {
Text( Text(
text = detail, text = item.name,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onBackground,
maxLines = 1, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} else { val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) }
Spacer(modifier = Modifier.height(8.dp)) 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))
}
} }
} }
} }

View file

@ -26,10 +26,10 @@ fun HomePosterCard(
imageUrl = if (isLandscapeMode) (item.banner ?: item.poster) else item.poster, imageUrl = if (isLandscapeMode) (item.banner ?: item.poster) else item.poster,
modifier = modifier, modifier = modifier,
shape = if (isLandscapeMode) NuvioPosterShape.Landscape else item.posterShape.toNuvioPosterShape(), shape = if (isLandscapeMode) NuvioPosterShape.Landscape else item.posterShape.toNuvioPosterShape(),
detailLine = if (isLandscapeMode) null else item.releaseInfo?.let { formatReleaseDateForDisplay(it) }, detailLine = if (isLandscapeMode || posterCardStyle.hideLabelsEnabled) null else item.releaseInfo?.let { formatReleaseDateForDisplay(it) },
showTitleBelow = !isLandscapeMode, showTitleBelow = !posterCardStyle.hideLabelsEnabled,
bottomLeftLogoUrl = if (isLandscapeMode) item.logo else null, 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, isWatched = isWatched,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,

View file

@ -350,6 +350,7 @@ private fun DiscoverGridRow(
DiscoverPosterTile( DiscoverPosterTile(
item = item, item = item,
cornerRadiusDp = posterCardStyle.cornerRadiusDp, cornerRadiusDp = posterCardStyle.cornerRadiusDp,
hideLabels = posterCardStyle.hideLabelsEnabled,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
isWatched = WatchingState.isPosterWatched( isWatched = WatchingState.isPosterWatched(
watchedKeys = watchedKeys, watchedKeys = watchedKeys,
@ -370,6 +371,7 @@ private fun DiscoverGridRow(
private fun DiscoverPosterTile( private fun DiscoverPosterTile(
item: MetaPreview, item: MetaPreview,
cornerRadiusDp: Int, cornerRadiusDp: Int,
hideLabels: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isWatched: Boolean = false, isWatched: Boolean = false,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
@ -402,24 +404,26 @@ private fun DiscoverPosterTile(
.padding(6.dp), .padding(6.dp),
) )
} }
Text( if (!hideLabels) {
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) {
Text( Text(
text = detail, text = item.name,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onBackground,
maxLines = 1, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} else { val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) }
Spacer(modifier = Modifier.height(8.dp)) 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))
}
} }
} }
} }

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.settings
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -54,9 +55,11 @@ internal fun LazyListScope.posterCustomizationSettingsContent(
widthDp = uiState.widthDp, widthDp = uiState.widthDp,
cornerRadiusDp = uiState.cornerRadiusDp, cornerRadiusDp = uiState.cornerRadiusDp,
catalogLandscapeModeEnabled = uiState.catalogLandscapeModeEnabled, catalogLandscapeModeEnabled = uiState.catalogLandscapeModeEnabled,
hideLabelsEnabled = uiState.hideLabelsEnabled,
onWidthSelected = PosterCardStyleRepository::setWidthDp, onWidthSelected = PosterCardStyleRepository::setWidthDp,
onCornerRadiusSelected = PosterCardStyleRepository::setCornerRadiusDp, onCornerRadiusSelected = PosterCardStyleRepository::setCornerRadiusDp,
onCatalogLandscapeModeChange = PosterCardStyleRepository::setCatalogLandscapeModeEnabled, onCatalogLandscapeModeChange = PosterCardStyleRepository::setCatalogLandscapeModeEnabled,
onHideLabelsChange = PosterCardStyleRepository::setHideLabelsEnabled,
) )
} }
} }
@ -70,9 +73,11 @@ private fun PosterCardStyleControls(
widthDp: Int, widthDp: Int,
cornerRadiusDp: Int, cornerRadiusDp: Int,
catalogLandscapeModeEnabled: Boolean, catalogLandscapeModeEnabled: Boolean,
hideLabelsEnabled: Boolean,
onWidthSelected: (Int) -> Unit, onWidthSelected: (Int) -> Unit,
onCornerRadiusSelected: (Int) -> Unit, onCornerRadiusSelected: (Int) -> Unit,
onCatalogLandscapeModeChange: (Boolean) -> Unit, onCatalogLandscapeModeChange: (Boolean) -> Unit,
onHideLabelsChange: (Boolean) -> Unit,
) { ) {
val widthOptions = listOf( val widthOptions = listOf(
PresetOption("Compact", 104), PresetOption("Compact", 104),
@ -121,6 +126,11 @@ private fun PosterCardStyleControls(
checked = catalogLandscapeModeEnabled, checked = catalogLandscapeModeEnabled,
onCheckedChange = onCatalogLandscapeModeChange, onCheckedChange = onCatalogLandscapeModeChange,
) )
PosterToggleRow(
title = "Hide labels",
checked = hideLabelsEnabled,
onCheckedChange = onHideLabelsChange,
)
} }
} }
@ -128,6 +138,19 @@ private fun PosterCardStyleControls(
private fun PosterLandscapeModeToggleRow( private fun PosterLandscapeModeToggleRow(
checked: Boolean, checked: Boolean,
onCheckedChange: (Boolean) -> Unit, 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -135,7 +158,7 @@ private fun PosterLandscapeModeToggleRow(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "Landscape mode for shelf posters", text = title,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@ -203,26 +226,13 @@ private fun PosterCardLivePreview(
.width(animatedWidth.value) .width(animatedWidth.value)
.height(animatedHeight.value) .height(animatedHeight.value)
.clip(RoundedCornerShape(animatedCornerRadius.value)) .clip(RoundedCornerShape(animatedCornerRadius.value))
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
) { .border(
Box( width = 1.dp,
modifier = Modifier color = MaterialTheme.colorScheme.outlineVariant,
.align(Alignment.TopStart) shape = RoundedCornerShape(animatedCornerRadius.value),
.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)),
)
}
} }
Column( Column(

View file

@ -32,6 +32,7 @@ object TmdbMetadataService {
private val entityBrowseCache = mutableMapOf<String, TmdbEntityBrowseData>() private val entityBrowseCache = mutableMapOf<String, TmdbEntityBrowseData>()
private val entityHeaderCache = mutableMapOf<String, TmdbEntityHeader>() private val entityHeaderCache = mutableMapOf<String, TmdbEntityHeader>()
private val entityRailCache = mutableMapOf<String, List<MetaPreview>>() private val entityRailCache = mutableMapOf<String, List<MetaPreview>>()
private val previewArtworkCache = mutableMapOf<String, TmdbPreviewArtwork>()
suspend fun fetchPersonDetail( suspend fun fetchPersonDetail(
personId: Int, personId: Int,
@ -75,16 +76,16 @@ object TmdbMetadataService {
val preferCrew = preferCrewCredits ?: shouldPreferCrewCredits(person.knownForDepartment) val preferCrew = preferCrewCredits ?: shouldPreferCrewCredits(person.knownForDepartment)
val castMovieCredits = mapPersonMovieCreditsFromCast(credits?.cast.orEmpty()) val castMovieCredits = mapPersonMovieCreditsFromCast(credits?.cast.orEmpty(), language)
val crewMovieCredits = mapPersonMovieCreditsFromCrew(credits?.crew.orEmpty()) val crewMovieCredits = mapPersonMovieCreditsFromCrew(credits?.crew.orEmpty(), language)
val movieCredits = when { val movieCredits = when {
preferCrew && crewMovieCredits.isNotEmpty() -> crewMovieCredits preferCrew && crewMovieCredits.isNotEmpty() -> crewMovieCredits
castMovieCredits.isNotEmpty() -> castMovieCredits castMovieCredits.isNotEmpty() -> castMovieCredits
else -> crewMovieCredits else -> crewMovieCredits
} }
val castTvCredits = mapPersonTvCreditsFromCast(credits?.cast.orEmpty()) val castTvCredits = mapPersonTvCreditsFromCast(credits?.cast.orEmpty(), language)
val crewTvCredits = mapPersonTvCreditsFromCrew(credits?.crew.orEmpty()) val crewTvCredits = mapPersonTvCreditsFromCrew(credits?.crew.orEmpty(), language)
val tvCredits = when { val tvCredits = when {
preferCrew && crewTvCredits.isNotEmpty() -> crewTvCredits preferCrew && crewTvCredits.isNotEmpty() -> crewTvCredits
castTvCredits.isNotEmpty() -> castTvCredits castTvCredits.isNotEmpty() -> castTvCredits
@ -116,88 +117,160 @@ object TmdbMetadataService {
return department.isNotBlank() && department != "acting" && department != "actors" return department.isNotBlank() && department != "acting" && department != "actors"
} }
private fun mapPersonMovieCreditsFromCast(cast: List<TmdbPersonCreditCast>): List<MetaPreview> { private suspend fun mapPersonMovieCreditsFromCast(
cast: List<TmdbPersonCreditCast>,
language: String,
): List<MetaPreview> = coroutineScope {
val seen = mutableSetOf<Int>() val seen = mutableSetOf<Int>()
return cast cast
.filter { it.mediaType == "movie" && it.posterPath != null } .filter { it.mediaType == "movie" && (it.posterPath != null || it.backdropPath != null) }
.sortedByDescending { it.voteAverage ?: 0.0 } .sortedByDescending { it.voteAverage ?: 0.0 }
.mapNotNull { credit -> .mapNotNull { credit ->
if (!seen.add(credit.id)) return@mapNotNull null if (!seen.add(credit.id)) return@mapNotNull null
val title = credit.title ?: credit.name ?: return@mapNotNull null val title = credit.title ?: credit.name ?: return@mapNotNull null
MetaPreview( async {
id = "tmdb:${credit.id}", val artwork = fetchPreviewArtwork(
type = "movie", tmdbId = credit.id,
name = title, mediaType = "movie",
poster = buildImageUrl(credit.posterPath, "w500"), language = language,
description = credit.overview?.takeIf { it.isNotBlank() }, )
releaseInfo = credit.releaseDate?.take(4), val poster = buildImageUrl(credit.posterPath, "w500")
rawReleaseDate = credit.releaseDate, ?: buildImageUrl(credit.backdropPath, "w780")
popularity = credit.popularity, ?: 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<TmdbPersonCreditCrew>): List<MetaPreview> { private suspend fun mapPersonMovieCreditsFromCrew(
crew: List<TmdbPersonCreditCrew>,
language: String,
): List<MetaPreview> = coroutineScope {
val seen = mutableSetOf<Int>() val seen = mutableSetOf<Int>()
return crew crew
.filter { it.mediaType == "movie" && it.posterPath != null } .filter { it.mediaType == "movie" && (it.posterPath != null || it.backdropPath != null) }
.sortedByDescending { it.voteAverage ?: 0.0 } .sortedByDescending { it.voteAverage ?: 0.0 }
.mapNotNull { credit -> .mapNotNull { credit ->
if (!seen.add(credit.id)) return@mapNotNull null if (!seen.add(credit.id)) return@mapNotNull null
val title = credit.title ?: credit.name ?: return@mapNotNull null val title = credit.title ?: credit.name ?: return@mapNotNull null
MetaPreview( async {
id = "tmdb:${credit.id}", val artwork = fetchPreviewArtwork(
type = "movie", tmdbId = credit.id,
name = title, mediaType = "movie",
poster = buildImageUrl(credit.posterPath, "w500"), language = language,
description = credit.overview?.takeIf { it.isNotBlank() }, )
releaseInfo = credit.releaseDate?.take(4), val poster = buildImageUrl(credit.posterPath, "w500")
rawReleaseDate = credit.releaseDate, ?: buildImageUrl(credit.backdropPath, "w780")
popularity = credit.popularity, ?: 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<TmdbPersonCreditCast>): List<MetaPreview> { private suspend fun mapPersonTvCreditsFromCast(
cast: List<TmdbPersonCreditCast>,
language: String,
): List<MetaPreview> = coroutineScope {
val seen = mutableSetOf<Int>() val seen = mutableSetOf<Int>()
return cast cast
.filter { it.mediaType == "tv" && it.posterPath != null } .filter { it.mediaType == "tv" && (it.posterPath != null || it.backdropPath != null) }
.sortedByDescending { it.voteAverage ?: 0.0 } .sortedByDescending { it.voteAverage ?: 0.0 }
.mapNotNull { credit -> .mapNotNull { credit ->
if (!seen.add(credit.id)) return@mapNotNull null if (!seen.add(credit.id)) return@mapNotNull null
val title = credit.name ?: credit.title ?: return@mapNotNull null val title = credit.name ?: credit.title ?: return@mapNotNull null
MetaPreview( async {
id = "tmdb:${credit.id}", val artwork = fetchPreviewArtwork(
type = "series", tmdbId = credit.id,
name = title, mediaType = "tv",
poster = buildImageUrl(credit.posterPath, "w500"), language = language,
description = credit.overview?.takeIf { it.isNotBlank() }, )
releaseInfo = credit.firstAirDate?.take(4), val poster = buildImageUrl(credit.posterPath, "w500")
rawReleaseDate = credit.firstAirDate, ?: buildImageUrl(credit.backdropPath, "w780")
popularity = credit.popularity, ?: 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<TmdbPersonCreditCrew>): List<MetaPreview> { private suspend fun mapPersonTvCreditsFromCrew(
crew: List<TmdbPersonCreditCrew>,
language: String,
): List<MetaPreview> = coroutineScope {
val seen = mutableSetOf<Int>() val seen = mutableSetOf<Int>()
return crew crew
.filter { it.mediaType == "tv" && it.posterPath != null } .filter { it.mediaType == "tv" && (it.posterPath != null || it.backdropPath != null) }
.sortedByDescending { it.voteAverage ?: 0.0 } .sortedByDescending { it.voteAverage ?: 0.0 }
.mapNotNull { credit -> .mapNotNull { credit ->
if (!seen.add(credit.id)) return@mapNotNull null if (!seen.add(credit.id)) return@mapNotNull null
val title = credit.name ?: credit.title ?: return@mapNotNull null val title = credit.name ?: credit.title ?: return@mapNotNull null
MetaPreview( async {
id = "tmdb:${credit.id}", val artwork = fetchPreviewArtwork(
type = "series", tmdbId = credit.id,
name = title, mediaType = "tv",
poster = buildImageUrl(credit.posterPath, "w500"), language = language,
description = credit.overview?.takeIf { it.isNotBlank() }, )
releaseInfo = credit.firstAirDate?.take(4), val poster = buildImageUrl(credit.posterPath, "w500")
rawReleaseDate = credit.firstAirDate, ?: buildImageUrl(credit.backdropPath, "w780")
popularity = credit.popularity, ?: 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( suspend fun fetchEntityBrowse(
@ -321,10 +394,16 @@ object TmdbMetadataService {
val results = response?.results.orEmpty() val results = response?.results.orEmpty()
val totalPages = response?.totalPages ?: page val totalPages = response?.totalPages ?: page
val mappedItems = results val mappedItems = coroutineScope {
.filter { it.id > 0 } results
.mapNotNull { item -> mapEntityDiscoverResult(item, mediaType) } .filter { it.id > 0 }
.take(ENTITY_RAIL_MAX_ITEMS) .map { item ->
async { mapEntityDiscoverResult(item, mediaType, language) }
}
.awaitAll()
.filterNotNull()
.take(ENTITY_RAIL_MAX_ITEMS)
}
TmdbEntityRailPageResult( TmdbEntityRailPageResult(
items = mappedItems, items = mappedItems,
@ -406,17 +485,26 @@ object TmdbMetadataService {
return header return header
} }
private fun mapEntityDiscoverResult( private suspend fun mapEntityDiscoverResult(
result: TmdbDiscoverResult, result: TmdbDiscoverResult,
mediaType: TmdbEntityMediaType, mediaType: TmdbEntityMediaType,
language: String,
): MetaPreview? { ): MetaPreview? {
val title = result.title?.takeIf { it.isNotBlank() } val title = result.title?.takeIf { it.isNotBlank() }
?: result.name?.takeIf { it.isNotBlank() } ?: result.name?.takeIf { it.isNotBlank() }
?: result.originalTitle?.takeIf { it.isNotBlank() } ?: result.originalTitle?.takeIf { it.isNotBlank() }
?: result.originalName?.takeIf { it.isNotBlank() } ?: result.originalName?.takeIf { it.isNotBlank() }
?: return null ?: return null
val artwork = fetchPreviewArtwork(
tmdbId = result.id,
mediaType = mediaType.value,
language = language,
)
val poster = buildImageUrl(result.posterPath, "w500") val poster = buildImageUrl(result.posterPath, "w500")
?: buildImageUrl(result.backdropPath, "w780") ?: buildImageUrl(result.backdropPath, "w780")
?: artwork?.backdrop
?: return null ?: return null
val releaseInfo = when (mediaType) { val releaseInfo = when (mediaType) {
TmdbEntityMediaType.MOVIE -> result.releaseDate?.take(4) TmdbEntityMediaType.MOVIE -> result.releaseDate?.take(4)
@ -427,11 +515,60 @@ object TmdbMetadataService {
type = if (mediaType == TmdbEntityMediaType.TV) "series" else "movie", type = if (mediaType == TmdbEntityMediaType.TV) "series" else "movie",
name = title, name = title,
poster = poster, poster = poster,
banner = buildImageUrl(result.backdropPath, "w780") ?: artwork?.backdrop,
logo = artwork?.logo,
description = result.overview?.takeIf { it.isNotBlank() }, description = result.overview?.takeIf { it.isNotBlank() },
releaseInfo = releaseInfo, 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<TmdbDetailsResponse>(
endpoint = "$mediaType/$tmdbId",
query = mapOf("language" to normalizedLanguage),
)
}
val images = async {
fetch<TmdbImagesResponse>(
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( private fun buildEntityMediaOrder(
entityKind: TmdbEntityKind, entityKind: TmdbEntityKind,
sourceType: String, sourceType: String,