feat: integrate SeasonViewMode functionality in DetailSeriesContent for enhanced season display options

This commit is contained in:
tapframe 2026-03-30 20:54:28 +05:30
parent 21d35774f5
commit 828167f9ae
5 changed files with 316 additions and 99 deletions

View file

@ -12,6 +12,7 @@ import com.nuvio.app.features.library.LibraryStorage
import com.nuvio.app.features.home.HomeCatalogSettingsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage
import com.nuvio.app.features.profiles.ProfileStorage
import com.nuvio.app.features.details.SeasonViewModeStorage
import com.nuvio.app.features.search.SearchHistoryStorage
import com.nuvio.app.features.settings.ThemeSettingsStorage
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
@ -39,6 +40,7 @@ class MainActivity : ComponentActivity() {
PlayerSettingsStorage.initialize(applicationContext)
ProfileStorage.initialize(applicationContext)
SearchHistoryStorage.initialize(applicationContext)
SeasonViewModeStorage.initialize(applicationContext)
ThemeSettingsStorage.initialize(applicationContext)
TmdbSettingsStorage.initialize(applicationContext)
ContinueWatchingPreferencesStorage.initialize(applicationContext)

View file

@ -0,0 +1,26 @@
package com.nuvio.app.features.details
import android.content.Context
import android.content.SharedPreferences
import com.nuvio.app.core.storage.ProfileScopedKey
actual object SeasonViewModeStorage {
private const val preferencesName = "nuvio_season_view_mode"
private const val key = "season_view_mode"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun load(): SeasonViewMode? =
preferences?.getString(ProfileScopedKey.of(key), null)?.let(SeasonViewMode::parse)
actual fun save(mode: SeasonViewMode) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(key), SeasonViewMode.persist(mode))
?.apply()
}
}

View file

@ -0,0 +1,30 @@
package com.nuvio.app.features.details
enum class SeasonViewMode {
Posters,
Text,
;
fun toggled(): SeasonViewMode = when (this) {
Posters -> Text
Text -> Posters
}
companion object {
fun parse(raw: String?): SeasonViewMode? = when (raw?.lowercase()) {
"posters" -> Posters
"text" -> Text
else -> null
}
fun persist(mode: SeasonViewMode): String = when (mode) {
Posters -> "posters"
Text -> "text"
}
}
}
internal expect object SeasonViewModeStorage {
fun load(): SeasonViewMode?
fun save(mode: SeasonViewMode)
}

View file

@ -1,5 +1,13 @@
package com.nuvio.app.features.details.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@ -11,7 +19,6 @@ 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
@ -46,6 +53,8 @@ import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge
import com.nuvio.app.core.ui.NuvioProgressBar
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.details.SeasonViewMode
import com.nuvio.app.features.details.SeasonViewModeStorage
import com.nuvio.app.features.details.metaVideoSeasonEpisodeComparator
import com.nuvio.app.features.details.normalizeSeasonNumber
import com.nuvio.app.features.details.seasonSortKey
@ -125,7 +134,10 @@ fun DetailSeriesContent(
val defaultSeason = seasons.first()
var selectedSeason by rememberSaveable(meta.id) { mutableStateOf(defaultSeason) }
val currentSeason = selectedSeason.takeIf { it in groupedEpisodes } ?: defaultSeason
val episodes = groupedEpisodes.getValue(currentSeason)
var seasonViewMode by remember {
mutableStateOf(SeasonViewModeStorage.load() ?: SeasonViewMode.Posters)
}
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val sizing = seriesContentSizing(maxWidth.value)
@ -142,121 +154,244 @@ fun DetailSeriesContent(
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Seasons",
style = MaterialTheme.typography.titleLarge.copy(
fontSize = sizing.seasonHeaderSize,
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onBackground,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Seasons",
style = MaterialTheme.typography.titleLarge.copy(
fontSize = sizing.seasonHeaderSize,
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onBackground,
)
if (hasSeasonPosters) {
SeasonViewModeToggle(
mode = seasonViewMode,
sizing = sizing,
onClick = {
val next = seasonViewMode.toggled()
seasonViewMode = next
SeasonViewModeStorage.save(next)
},
)
}
}
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,
Crossfade(
targetState = seasonViewMode,
animationSpec = tween(280),
label = "season_selector_layout",
) { mode ->
when (mode) {
SeasonViewMode.Posters -> SeasonPosterScrollRow(
seasons = seasons,
groupedEpisodes = groupedEpisodes,
meta = meta,
currentSeason = currentSeason,
sizing = sizing,
onClick = { selectedSeason = season },
onSelect = { selectedSeason = it },
)
SeasonViewMode.Text -> SeasonTextChipScrollRow(
seasons = seasons,
currentSeason = currentSeason,
sizing = sizing,
onSelect = { selectedSeason = it },
)
}
}
} 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 {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
SeasonTextChipScrollRow(
seasons = seasons,
currentSeason = currentSeason,
sizing = sizing,
onSelect = { selectedSeason = it },
)
}
}
}
val sectionTitle = if (meta.type != "series" && seasons.size == 1 && currentSeason <= 0) {
"Videos"
} else {
currentSeason.label()
}
DetailSectionTitle(
title = sectionTitle,
)
Column(
verticalArrangement = Arrangement.spacedBy(sizing.cardGap),
) {
episodes.forEach { episode ->
val episodeVideoId = buildPlaybackVideoId(
parentMetaId = meta.id,
seasonNumber = episode.season,
episodeNumber = episode.episode,
fallbackVideoId = episode.id,
)
EpisodeCard(
video = episode,
fallbackImage = meta.background ?: meta.poster,
progressEntry = progressByVideoId[episodeVideoId],
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
WatchingState.isEpisodeWatched(
watchedKeys = watchedKeys,
metaType = meta.type,
metaId = meta.id,
episode = episode,
),
sizing = sizing,
onClick = { onEpisodeClick?.invoke(episode) },
onLongPress = { onEpisodeLongPress?.invoke(episode) },
AnimatedContent(
targetState = currentSeason,
transitionSpec = {
val fromIdx = seasons.indexOf(initialState).takeIf { it >= 0 } ?: 0
val toIdx = seasons.indexOf(targetState).takeIf { it >= 0 } ?: 0
val dir = if (toIdx >= fromIdx) 1 else -1
(fadeIn(tween(220)) + slideInHorizontally(tween(220)) { dir * it / 5 })
.togetherWith(
fadeOut(tween(170)) + slideOutHorizontally(tween(170)) { -dir * it / 5 },
)
},
label = "season_episodes",
) { seasonForContent ->
val sectionTitle = if (meta.type != "series" && seasons.size == 1 && seasonForContent <= 0) {
"Videos"
} else {
seasonForContent.label()
}
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
DetailSectionTitle(
title = sectionTitle,
)
Column(
verticalArrangement = Arrangement.spacedBy(sizing.cardGap),
) {
groupedEpisodes.getValue(seasonForContent).forEach { episode ->
val episodeVideoId = buildPlaybackVideoId(
parentMetaId = meta.id,
seasonNumber = episode.season,
episodeNumber = episode.episode,
fallbackVideoId = episode.id,
)
EpisodeCard(
video = episode,
fallbackImage = meta.background ?: meta.poster,
progressEntry = progressByVideoId[episodeVideoId],
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
WatchingState.isEpisodeWatched(
watchedKeys = watchedKeys,
metaType = meta.type,
metaId = meta.id,
episode = episode,
),
sizing = sizing,
onClick = { onEpisodeClick?.invoke(episode) },
onLongPress = { onEpisodeLongPress?.invoke(episode) },
)
}
}
}
}
}
}
}
@Composable
private fun SeasonViewModeToggle(
mode: SeasonViewMode,
sizing: SeriesContentSizing,
onClick: () -> Unit,
) {
val isPosters = mode == SeasonViewMode.Posters
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(
if (isPosters) {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f)
},
)
.border(
width = 1.dp,
color = Color.White.copy(alpha = if (isPosters) 0.2f else 0.3f),
shape = RoundedCornerShape(8.dp),
)
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 6.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = if (isPosters) "Posters" else "Text",
style = MaterialTheme.typography.labelLarge.copy(
fontSize = sizing.seasonToggleTextSize,
fontWeight = FontWeight.SemiBold,
),
color = if (isPosters) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onBackground
},
)
}
}
@Composable
private fun SeasonTextChipScrollRow(
seasons: List<Int>,
currentSeason: Int,
sizing: SeriesContentSizing,
onSelect: (Int) -> Unit,
) {
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 { onSelect(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
},
)
}
}
}
}
@Composable
private fun SeasonPosterScrollRow(
seasons: List<Int>,
groupedEpisodes: Map<Int, List<MetaVideo>>,
meta: MetaDetails,
currentSeason: Int,
sizing: SeriesContentSizing,
onSelect: (Int) -> Unit,
) {
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 = { onSelect(season) },
)
}
}
}
@Composable
private fun SeasonPosterButton(
label: String,
@ -495,6 +630,7 @@ private fun EpisodeCard(
private data class SeriesContentSizing(
val seasonHeaderSize: androidx.compose.ui.unit.TextUnit,
val seasonToggleTextSize: androidx.compose.ui.unit.TextUnit,
val seasonChipGap: Dp,
val seasonChipRadius: Dp,
val seasonChipHorizontalPadding: Dp,
@ -527,6 +663,7 @@ private fun seriesContentSizing(maxWidthDp: Float): SeriesContentSizing =
when {
maxWidthDp >= 1440f -> SeriesContentSizing(
seasonHeaderSize = 28.sp,
seasonToggleTextSize = 16.sp,
seasonChipGap = 20.dp,
seasonChipRadius = 16.dp,
seasonChipHorizontalPadding = 20.dp,
@ -556,6 +693,7 @@ private fun seriesContentSizing(maxWidthDp: Float): SeriesContentSizing =
)
maxWidthDp >= 1024f -> SeriesContentSizing(
seasonHeaderSize = 26.sp,
seasonToggleTextSize = 15.sp,
seasonChipGap = 18.dp,
seasonChipRadius = 14.dp,
seasonChipHorizontalPadding = 18.dp,
@ -585,6 +723,7 @@ private fun seriesContentSizing(maxWidthDp: Float): SeriesContentSizing =
)
maxWidthDp >= 768f -> SeriesContentSizing(
seasonHeaderSize = 24.sp,
seasonToggleTextSize = 14.sp,
seasonChipGap = 16.dp,
seasonChipRadius = 12.dp,
seasonChipHorizontalPadding = 16.dp,
@ -614,6 +753,7 @@ private fun seriesContentSizing(maxWidthDp: Float): SeriesContentSizing =
)
else -> SeriesContentSizing(
seasonHeaderSize = 18.sp,
seasonToggleTextSize = 12.sp,
seasonChipGap = 16.dp,
seasonChipRadius = 12.dp,
seasonChipHorizontalPadding = 16.dp,

View file

@ -0,0 +1,19 @@
package com.nuvio.app.features.details
import com.nuvio.app.core.storage.ProfileScopedKey
import platform.Foundation.NSUserDefaults
actual object SeasonViewModeStorage {
private const val key = "season_view_mode"
actual fun load(): SeasonViewMode? =
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(key))
?.let(SeasonViewMode::parse)
actual fun save(mode: SeasonViewMode) {
NSUserDefaults.standardUserDefaults.setObject(
SeasonViewMode.persist(mode),
forKey = ProfileScopedKey.of(key),
)
}
}