From 828167f9ae92bdd80efc16ff0fbb12fa222f140a Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:54:28 +0530 Subject: [PATCH] feat: integrate SeasonViewMode functionality in DetailSeriesContent for enhanced season display options --- .../kotlin/com/nuvio/app/MainActivity.kt | 2 + .../details/SeasonViewModeStorage.android.kt | 26 ++ .../app/features/details/SeasonViewMode.kt | 30 ++ .../details/components/DetailSeriesContent.kt | 338 +++++++++++++----- .../details/SeasonViewModeStorage.ios.kt | 19 + 5 files changed, 316 insertions(+), 99 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeasonViewMode.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 3aefe1ada..17c202baf 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.android.kt new file mode 100644 index 000000000..49c9b3336 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.android.kt @@ -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() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeasonViewMode.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeasonViewMode.kt new file mode 100644 index 000000000..04d9fbf88 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeasonViewMode.kt @@ -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) +} 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 517f4d072..fe4decb0f 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 @@ -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, + 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, + groupedEpisodes: Map>, + 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, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.ios.kt new file mode 100644 index 000000000..4ac4e6717 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.ios.kt @@ -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), + ) + } +}