mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
feat: integrate SeasonViewMode functionality in DetailSeriesContent for enhanced season display options
This commit is contained in:
parent
21d35774f5
commit
828167f9ae
5 changed files with 316 additions and 99 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue