mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
feat: add season poster functionality to TMDB settings and metadata handling for enhanced series display
This commit is contained in:
parent
fb1fa766c6
commit
21d35774f5
10 changed files with 226 additions and 40 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue