diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt index b7455071d..962443c2e 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt @@ -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) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt index 1fe107860..a1f46ea8c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index 5be07635c..79a93117a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -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"), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index 14f23d5e1..517f4d072 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt index 795fa364b..06997147e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt @@ -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", 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 575292646..172b8adf0 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 @@ -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 = emptyList(), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt index fd37d4816..661b0bae5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt @@ -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, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt index 20098dd9e..8ede16a33 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt @@ -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, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt index a061f96f2..bdf6dcce2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt @@ -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? diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt index f05f18c32..e9387d896 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt @@ -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) {