feat: add season poster functionality to TMDB settings and metadata handling for enhanced series display

This commit is contained in:
tapframe 2026-03-30 20:52:27 +05:30
parent fb1fa766c6
commit 21d35774f5
10 changed files with 226 additions and 40 deletions

View file

@ -15,6 +15,7 @@ actual object TmdbSettingsStorage {
private const val useProductionsKey = "tmdb_use_productions"
private const val useNetworksKey = "tmdb_use_networks"
private const val useEpisodesKey = "tmdb_use_episodes"
private const val useSeasonPostersKey = "tmdb_use_season_posters"
private const val useMoreLikeThisKey = "tmdb_use_more_like_this"
private const val useCollectionsKey = "tmdb_use_collections"
@ -82,6 +83,12 @@ actual object TmdbSettingsStorage {
saveBoolean(useEpisodesKey, enabled)
}
actual fun loadUseSeasonPosters(): Boolean? = loadBoolean(useSeasonPostersKey)
actual fun saveUseSeasonPosters(enabled: Boolean) {
saveBoolean(useSeasonPostersKey, enabled)
}
actual fun loadUseMoreLikeThis(): Boolean? = loadBoolean(useMoreLikeThisKey)
actual fun saveUseMoreLikeThis(enabled: Boolean) {

View file

@ -57,6 +57,7 @@ data class MetaVideo(
val title: String,
val released: String? = null,
val thumbnail: String? = null,
val seasonPoster: String? = null,
val season: Int? = null,
val episode: Int? = null,
val overview: String? = null,

View file

@ -221,6 +221,7 @@ internal object MetaDetailsParser {
title = title,
released = video.string("released"),
thumbnail = video.string("thumbnail"),
seasonPoster = video.string("seasonPoster") ?: video.string("season_poster_path"),
season = video.int("season"),
episode = video.int("episode"),
overview = video.string("overview") ?: video.string("description"),

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -34,6 +35,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -132,6 +134,11 @@ fun DetailSeriesContent(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (seasons.size > 1) {
val hasSeasonPosters = seasons.any { season ->
groupedEpisodes[season]
.orEmpty()
.any { !it.seasonPoster.isNullOrBlank() }
}
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
@ -144,43 +151,66 @@ fun DetailSeriesContent(
color = MaterialTheme.colorScheme.onBackground,
)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(sizing.seasonChipGap),
) {
seasons.forEach { season ->
val isSelected = season == currentSeason
Box(
modifier = Modifier
.clip(RoundedCornerShape(sizing.seasonChipRadius))
.background(
if (isSelected) {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)
if (hasSeasonPosters) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(sizing.seasonChipGap),
) {
seasons.forEach { season ->
SeasonPosterButton(
label = season.label(),
imageUrl = groupedEpisodes[season]
.orEmpty()
.firstNotNullOfOrNull { episode -> episode.seasonPoster }
?: meta.poster
?: meta.background,
isSelected = season == currentSeason,
sizing = sizing,
onClick = { selectedSeason = season },
)
}
}
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(sizing.seasonChipGap),
) {
seasons.forEach { season ->
val isSelected = season == currentSeason
Box(
modifier = Modifier
.clip(RoundedCornerShape(sizing.seasonChipRadius))
.background(
if (isSelected) {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)
} else {
Color.Transparent
},
)
.clickable { selectedSeason = season }
.padding(
horizontal = sizing.seasonChipHorizontalPadding,
vertical = sizing.seasonChipVerticalPadding,
),
contentAlignment = Alignment.Center,
) {
Text(
text = season.label(),
style = MaterialTheme.typography.bodyLarge.copy(
fontSize = sizing.seasonChipTextSize,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold,
),
color = if (isSelected) {
MaterialTheme.colorScheme.onBackground
} else {
Color.Transparent
MaterialTheme.colorScheme.onSurfaceVariant
},
)
.clickable { selectedSeason = season }
.padding(
horizontal = sizing.seasonChipHorizontalPadding,
vertical = sizing.seasonChipVerticalPadding,
),
contentAlignment = Alignment.Center,
) {
Text(
text = season.label(),
style = MaterialTheme.typography.bodyLarge.copy(
fontSize = sizing.seasonChipTextSize,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold,
),
color = if (isSelected) {
MaterialTheme.colorScheme.onBackground
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
@ -227,6 +257,81 @@ fun DetailSeriesContent(
}
}
@Composable
private fun SeasonPosterButton(
label: String,
imageUrl: String?,
isSelected: Boolean,
sizing: SeriesContentSizing,
onClick: () -> Unit,
) {
Column(
modifier = Modifier
.width(sizing.seasonPosterWidth)
.clickable(onClick = onClick),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(sizing.seasonPosterHeight)
.clip(RoundedCornerShape(sizing.seasonPosterRadius))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.border(
width = if (isSelected) 2.dp else 1.dp,
color = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
Color.White.copy(alpha = 0.1f)
},
shape = RoundedCornerShape(sizing.seasonPosterRadius),
),
) {
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = label,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 12.dp),
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
Text(
text = label,
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = sizing.seasonChipTextSize,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold,
),
color = if (isSelected) {
MaterialTheme.colorScheme.onBackground
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun EpisodeCard(
@ -395,6 +500,9 @@ private data class SeriesContentSizing(
val seasonChipHorizontalPadding: Dp,
val seasonChipVerticalPadding: Dp,
val seasonChipTextSize: androidx.compose.ui.unit.TextUnit,
val seasonPosterWidth: Dp,
val seasonPosterHeight: Dp,
val seasonPosterRadius: Dp,
val cardHeight: Dp,
val imageWidth: Dp,
val cardRadius: Dp,
@ -424,6 +532,9 @@ private fun seriesContentSizing(maxWidthDp: Float): SeriesContentSizing =
seasonChipHorizontalPadding = 20.dp,
seasonChipVerticalPadding = 16.dp,
seasonChipTextSize = 16.sp,
seasonPosterWidth = 140.dp,
seasonPosterHeight = 210.dp,
seasonPosterRadius = 16.dp,
cardHeight = 200.dp,
imageWidth = 200.dp,
cardRadius = 20.dp,
@ -450,6 +561,9 @@ private fun seriesContentSizing(maxWidthDp: Float): SeriesContentSizing =
seasonChipHorizontalPadding = 18.dp,
seasonChipVerticalPadding = 14.dp,
seasonChipTextSize = 15.sp,
seasonPosterWidth = 130.dp,
seasonPosterHeight = 195.dp,
seasonPosterRadius = 14.dp,
cardHeight = 180.dp,
imageWidth = 180.dp,
cardRadius = 18.dp,
@ -476,6 +590,9 @@ private fun seriesContentSizing(maxWidthDp: Float): SeriesContentSizing =
seasonChipHorizontalPadding = 16.dp,
seasonChipVerticalPadding = 12.dp,
seasonChipTextSize = 17.sp,
seasonPosterWidth = 120.dp,
seasonPosterHeight = 180.dp,
seasonPosterRadius = 12.dp,
cardHeight = 160.dp,
imageWidth = 160.dp,
cardRadius = 16.dp,
@ -502,6 +619,9 @@ private fun seriesContentSizing(maxWidthDp: Float): SeriesContentSizing =
seasonChipHorizontalPadding = 16.dp,
seasonChipVerticalPadding = 12.dp,
seasonChipTextSize = 15.sp,
seasonPosterWidth = 100.dp,
seasonPosterHeight = 150.dp,
seasonPosterRadius = 8.dp,
cardHeight = 120.dp,
imageWidth = 120.dp,
cardRadius = 16.dp,

View file

@ -127,6 +127,15 @@ internal fun LazyListScope.tmdbSettingsContent(
onCheckedChange = TmdbSettingsRepository::setUseEpisodes,
)
SettingsGroupDivider(isTablet = isTablet)
TmdbToggleRow(
isTablet = isTablet,
title = "Season posters",
description = "Use TMDB season posters in the metadata screen season selector for series.",
checked = settings.useSeasonPosters,
enabled = settings.enabled,
onCheckedChange = TmdbSettingsRepository::setUseSeasonPosters,
)
SettingsGroupDivider(isTablet = isTablet)
TmdbToggleRow(
isTablet = isTablet,
title = "More like this",

View file

@ -38,7 +38,7 @@ object TmdbMetadataService {
?: TmdbService.ensureTmdbId(fallbackItemId, tmdbType)
?: return meta
val needsEpisodes = settings.useEpisodes && tmdbType == "tv"
val needsEpisodes = (settings.useEpisodes || settings.useSeasonPosters) && tmdbType == "tv"
val (enrichment, episodeMap) = coroutineScope {
val enrichmentDeferred = async {
fetchEnrichment(
@ -136,11 +136,36 @@ object TmdbMetadataService {
video
} else {
video.copy(
title = enrichmentForEpisode.title ?: video.title,
overview = enrichmentForEpisode.overview ?: video.overview,
released = enrichmentForEpisode.airDate ?: video.released,
thumbnail = enrichmentForEpisode.thumbnail ?: video.thumbnail,
runtime = enrichmentForEpisode.runtimeMinutes ?: video.runtime,
title = if (settings.useEpisodes) {
enrichmentForEpisode.title ?: video.title
} else {
video.title
},
overview = if (settings.useEpisodes) {
enrichmentForEpisode.overview ?: video.overview
} else {
video.overview
},
released = if (settings.useEpisodes) {
enrichmentForEpisode.airDate ?: video.released
} else {
video.released
},
thumbnail = if (settings.useEpisodes) {
enrichmentForEpisode.thumbnail ?: video.thumbnail
} else {
video.thumbnail
},
seasonPoster = if (settings.useSeasonPosters) {
enrichmentForEpisode.seasonPoster ?: video.seasonPoster
} else {
video.seasonPoster
},
runtime = if (settings.useEpisodes) {
enrichmentForEpisode.runtimeMinutes ?: video.runtime
} else {
video.runtime
},
)
}
},
@ -304,6 +329,7 @@ object TmdbMetadataService {
title = episode.name?.trim()?.takeIf(String::isNotBlank),
overview = episode.overview?.trim()?.takeIf(String::isNotBlank),
thumbnail = buildImageUrl(episode.stillPath, "w500"),
seasonPoster = buildImageUrl(details.posterPath, "w500"),
airDate = episode.airDate?.trim()?.takeIf(String::isNotBlank),
runtimeMinutes = episode.runtime,
)
@ -466,6 +492,7 @@ internal data class TmdbEpisodeEnrichment(
val title: String?,
val overview: String?,
val thumbnail: String?,
val seasonPoster: String? = null,
val airDate: String?,
val runtimeMinutes: Int?,
)
@ -845,6 +872,7 @@ private data class TmdbCollectionPart(
@Serializable
private data class TmdbSeasonDetailsResponse(
@SerialName("poster_path") val posterPath: String? = null,
val episodes: List<TmdbEpisodeResponse> = emptyList(),
)

View file

@ -10,6 +10,7 @@ data class TmdbSettings(
val useProductions: Boolean = true,
val useNetworks: Boolean = true,
val useEpisodes: Boolean = true,
val useSeasonPosters: Boolean = true,
val useMoreLikeThis: Boolean = true,
val useCollections: Boolean = true,
)

View file

@ -19,6 +19,7 @@ object TmdbSettingsRepository {
private var useProductions = true
private var useNetworks = true
private var useEpisodes = true
private var useSeasonPosters = true
private var useMoreLikeThis = true
private var useCollections = true
@ -102,6 +103,13 @@ object TmdbSettingsRepository {
persist = TmdbSettingsStorage::saveUseEpisodes,
)
fun setUseSeasonPosters(value: Boolean) = setBoolean(
current = useSeasonPosters,
next = value,
update = { useSeasonPosters = it },
persist = TmdbSettingsStorage::saveUseSeasonPosters,
)
fun setUseMoreLikeThis(value: Boolean) = setBoolean(
current = useMoreLikeThis,
next = value,
@ -140,6 +148,7 @@ object TmdbSettingsRepository {
useProductions = TmdbSettingsStorage.loadUseProductions() ?: true
useNetworks = TmdbSettingsStorage.loadUseNetworks() ?: true
useEpisodes = TmdbSettingsStorage.loadUseEpisodes() ?: true
useSeasonPosters = TmdbSettingsStorage.loadUseSeasonPosters() ?: true
useMoreLikeThis = TmdbSettingsStorage.loadUseMoreLikeThis() ?: true
useCollections = TmdbSettingsStorage.loadUseCollections() ?: true
publish()
@ -156,6 +165,7 @@ object TmdbSettingsRepository {
useProductions = useProductions,
useNetworks = useNetworks,
useEpisodes = useEpisodes,
useSeasonPosters = useSeasonPosters,
useMoreLikeThis = useMoreLikeThis,
useCollections = useCollections,
)

View file

@ -19,6 +19,8 @@ internal expect object TmdbSettingsStorage {
fun saveUseNetworks(enabled: Boolean)
fun loadUseEpisodes(): Boolean?
fun saveUseEpisodes(enabled: Boolean)
fun loadUseSeasonPosters(): Boolean?
fun saveUseSeasonPosters(enabled: Boolean)
fun loadUseMoreLikeThis(): Boolean?
fun saveUseMoreLikeThis(enabled: Boolean)
fun loadUseCollections(): Boolean?

View file

@ -13,6 +13,7 @@ actual object TmdbSettingsStorage {
private const val useProductionsKey = "tmdb_use_productions"
private const val useNetworksKey = "tmdb_use_networks"
private const val useEpisodesKey = "tmdb_use_episodes"
private const val useSeasonPostersKey = "tmdb_use_season_posters"
private const val useMoreLikeThisKey = "tmdb_use_more_like_this"
private const val useCollectionsKey = "tmdb_use_collections"
@ -71,6 +72,12 @@ actual object TmdbSettingsStorage {
saveBoolean(useEpisodesKey, enabled)
}
actual fun loadUseSeasonPosters(): Boolean? = loadBoolean(useSeasonPostersKey)
actual fun saveUseSeasonPosters(enabled: Boolean) {
saveBoolean(useSeasonPostersKey, enabled)
}
actual fun loadUseMoreLikeThis(): Boolean? = loadBoolean(useMoreLikeThisKey)
actual fun saveUseMoreLikeThis(enabled: Boolean) {