mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-31 16:49:28 +00:00
feat; horizontal episde card
This commit is contained in:
parent
5288278789
commit
39c93aaeb5
4 changed files with 629 additions and 26 deletions
|
|
@ -999,6 +999,7 @@ private fun ConfiguredMetaSections(
|
|||
meta = meta,
|
||||
showHeader = showHeader,
|
||||
preferredSeasonNumber = preferredEpisodeSeasonNumber,
|
||||
episodeCardStyle = settings.episodeCardStyle,
|
||||
progressByVideoId = progressByVideoId,
|
||||
watchedKeys = watchedKeys,
|
||||
onEpisodeClick = onEpisodeClick,
|
||||
|
|
|
|||
|
|
@ -40,8 +40,28 @@ data class MetaScreenSettingsUiState(
|
|||
val items: List<MetaScreenSectionItem> = emptyList(),
|
||||
val cinematicBackground: Boolean = false,
|
||||
val tabLayout: Boolean = false,
|
||||
val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||
)
|
||||
|
||||
enum class MetaEpisodeCardStyle {
|
||||
Horizontal,
|
||||
List,
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun parse(raw: String?): MetaEpisodeCardStyle? = when (raw?.lowercase()) {
|
||||
"horizontal" -> Horizontal
|
||||
"list" -> List
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun persist(style: MetaEpisodeCardStyle): String = when (style) {
|
||||
Horizontal -> "horizontal"
|
||||
List -> "list"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class StoredMetaScreenSectionPreference(
|
||||
val key: String,
|
||||
|
|
@ -56,6 +76,7 @@ private data class StoredMetaScreenSettingsPayload(
|
|||
val cinematicBackground: Boolean = false,
|
||||
@SerialName("tvStyleLayout")
|
||||
val tabLayout: Boolean = false,
|
||||
val episodeCardStyle: String = "horizontal",
|
||||
)
|
||||
|
||||
private data class MetaScreenSectionDefinition(
|
||||
|
|
@ -130,6 +151,7 @@ object MetaScreenSettingsRepository {
|
|||
private var preferences: MutableMap<MetaScreenSectionKey, StoredMetaScreenSectionPreference> = mutableMapOf()
|
||||
private var cinematicBackground: Boolean = false
|
||||
private var tabLayout: Boolean = false
|
||||
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||
|
||||
fun ensureLoaded() {
|
||||
if (hasLoaded) return
|
||||
|
|
@ -143,6 +165,8 @@ object MetaScreenSettingsRepository {
|
|||
if (parsed != null) {
|
||||
cinematicBackground = parsed.cinematicBackground
|
||||
tabLayout = parsed.tabLayout
|
||||
episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle)
|
||||
?: MetaEpisodeCardStyle.Horizontal
|
||||
preferences = parsed.items.mapNotNull { item ->
|
||||
val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null
|
||||
key to item
|
||||
|
|
@ -160,6 +184,7 @@ object MetaScreenSettingsRepository {
|
|||
preferences.clear()
|
||||
cinematicBackground = false
|
||||
tabLayout = false
|
||||
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||
_uiState.value = MetaScreenSettingsUiState()
|
||||
ensureLoaded()
|
||||
}
|
||||
|
|
@ -178,6 +203,13 @@ object MetaScreenSettingsRepository {
|
|||
persist()
|
||||
}
|
||||
|
||||
fun setEpisodeCardStyle(style: MetaEpisodeCardStyle) {
|
||||
ensureLoaded()
|
||||
episodeCardStyle = style
|
||||
publish()
|
||||
persist()
|
||||
}
|
||||
|
||||
fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) {
|
||||
ensureLoaded()
|
||||
if (!key.canBeTabbed) return
|
||||
|
|
@ -203,10 +235,12 @@ object MetaScreenSettingsRepository {
|
|||
items: List<MetaScreenSectionItem>,
|
||||
cinematicBackground: Boolean,
|
||||
tabLayout: Boolean,
|
||||
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||
) {
|
||||
ensureLoaded()
|
||||
this.cinematicBackground = cinematicBackground
|
||||
this.tabLayout = tabLayout
|
||||
this.episodeCardStyle = episodeCardStyle
|
||||
preferences = items.associate { item ->
|
||||
item.key to StoredMetaScreenSectionPreference(
|
||||
key = item.key.name,
|
||||
|
|
@ -231,6 +265,7 @@ object MetaScreenSettingsRepository {
|
|||
preferences.clear()
|
||||
cinematicBackground = false
|
||||
tabLayout = false
|
||||
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||
normalizePreferences()
|
||||
publish()
|
||||
persist()
|
||||
|
|
@ -296,6 +331,7 @@ object MetaScreenSettingsRepository {
|
|||
},
|
||||
cinematicBackground = cinematicBackground,
|
||||
tabLayout = tabLayout,
|
||||
episodeCardStyle = episodeCardStyle,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -306,6 +342,7 @@ object MetaScreenSettingsRepository {
|
|||
items = preferences.values.sortedBy { it.order },
|
||||
cinematicBackground = cinematicBackground,
|
||||
tabLayout = tabLayout,
|
||||
episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.Arrangement
|
|||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
|
@ -44,6 +45,7 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
|
@ -58,6 +60,7 @@ import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
|||
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.MetaEpisodeCardStyle
|
||||
import com.nuvio.app.features.details.MetaVideo
|
||||
import com.nuvio.app.features.details.SeasonViewMode
|
||||
import com.nuvio.app.features.details.SeasonViewModeStorage
|
||||
|
|
@ -76,6 +79,7 @@ fun DetailSeriesContent(
|
|||
modifier: Modifier = Modifier,
|
||||
showHeader: Boolean = true,
|
||||
preferredSeasonNumber: Int? = null,
|
||||
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
|
||||
watchedKeys: Set<String> = emptySet(),
|
||||
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
|
||||
|
|
@ -155,6 +159,7 @@ fun DetailSeriesContent(
|
|||
|
||||
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
|
||||
val sizing = seriesContentSizing(maxWidth.value)
|
||||
val containerWidthDp = maxWidth.value
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
|
|
@ -253,31 +258,46 @@ fun DetailSeriesContent(
|
|||
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) },
|
||||
)
|
||||
val seasonEpisodes = groupedEpisodes.getValue(seasonForContent)
|
||||
if (episodeCardStyle == MetaEpisodeCardStyle.Horizontal) {
|
||||
EpisodeHorizontalRow(
|
||||
episodes = seasonEpisodes,
|
||||
maxWidthDp = containerWidthDp,
|
||||
parentMetaId = meta.id,
|
||||
metaType = meta.type,
|
||||
watchedKeys = watchedKeys,
|
||||
fallbackImage = meta.background ?: meta.poster,
|
||||
progressByVideoId = progressByVideoId,
|
||||
onEpisodeClick = onEpisodeClick,
|
||||
onEpisodeLongPress = onEpisodeLongPress,
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(sizing.cardGap),
|
||||
) {
|
||||
seasonEpisodes.forEach { episode ->
|
||||
val episodeVideoId = buildPlaybackVideoId(
|
||||
parentMetaId = meta.id,
|
||||
seasonNumber = episode.season,
|
||||
episodeNumber = episode.episode,
|
||||
fallbackVideoId = episode.id,
|
||||
)
|
||||
EpisodeListCard(
|
||||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -512,7 +532,330 @@ private fun SeasonPosterButton(
|
|||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun EpisodeCard(
|
||||
private fun EpisodeHorizontalRow(
|
||||
episodes: List<MetaVideo>,
|
||||
maxWidthDp: Float,
|
||||
parentMetaId: String,
|
||||
metaType: String,
|
||||
watchedKeys: Set<String>,
|
||||
fallbackImage: String?,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
onEpisodeClick: ((MetaVideo) -> Unit)?,
|
||||
onEpisodeLongPress: ((MetaVideo) -> Unit)?,
|
||||
) {
|
||||
val rowMetrics = rememberEpisodeHorizontalCardMetrics(maxWidthDp)
|
||||
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = rowMetrics.rowHorizontalPadding, vertical = rowMetrics.rowVerticalPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(rowMetrics.itemSpacing),
|
||||
) {
|
||||
items(episodes, key = { it.id }) { episode ->
|
||||
val episodeVideoId = buildPlaybackVideoId(
|
||||
parentMetaId = parentMetaId,
|
||||
seasonNumber = episode.season,
|
||||
episodeNumber = episode.episode,
|
||||
fallbackVideoId = episode.id,
|
||||
)
|
||||
EpisodeHorizontalCard(
|
||||
video = episode,
|
||||
fallbackImage = fallbackImage,
|
||||
progressEntry = progressByVideoId[episodeVideoId],
|
||||
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
|
||||
WatchingState.isEpisodeWatched(
|
||||
watchedKeys = watchedKeys,
|
||||
metaType = metaType,
|
||||
metaId = parentMetaId,
|
||||
episode = episode,
|
||||
),
|
||||
metrics = rowMetrics,
|
||||
onClick = { onEpisodeClick?.invoke(episode) },
|
||||
onLongPress = { onEpisodeLongPress?.invoke(episode) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun EpisodeHorizontalCard(
|
||||
video: MetaVideo,
|
||||
fallbackImage: String?,
|
||||
progressEntry: WatchProgressEntry?,
|
||||
isWatched: Boolean,
|
||||
metrics: EpisodeHorizontalCardMetrics,
|
||||
onClick: (() -> Unit)? = null,
|
||||
onLongPress: (() -> Unit)? = null,
|
||||
) {
|
||||
val cardShape = RoundedCornerShape(metrics.cornerRadius)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(metrics.cardWidth)
|
||||
.height(metrics.cardHeight)
|
||||
.clip(cardShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = Color.White.copy(alpha = 0.12f),
|
||||
shape = cardShape,
|
||||
)
|
||||
.combinedClickable(
|
||||
enabled = onClick != null || onLongPress != null,
|
||||
onClick = { onClick?.invoke() },
|
||||
onLongClick = onLongPress,
|
||||
),
|
||||
) {
|
||||
val imageUrl = video.thumbnail ?: fallbackImage
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = video.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.10f),
|
||||
Color.Black.copy(alpha = 0.42f),
|
||||
Color.Black.copy(alpha = 0.78f),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(start = metrics.contentPadding, top = metrics.contentPadding)
|
||||
.clip(RoundedCornerShape(metrics.badgeRadius))
|
||||
.background(Color.Black.copy(alpha = 0.75f))
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = Color.White.copy(alpha = 0.18f),
|
||||
shape = RoundedCornerShape(metrics.badgeRadius),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = video.episodeBadge(),
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = metrics.badgeTextSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
NuvioAnimatedWatchedBadge(
|
||||
isVisible = isWatched,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(metrics.contentPadding),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = metrics.contentPadding,
|
||||
end = metrics.contentPadding,
|
||||
top = metrics.contentPadding,
|
||||
bottom = metrics.contentBottomPadding,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = video.title,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontSize = metrics.titleTextSize,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
lineHeight = metrics.titleLineHeight,
|
||||
),
|
||||
color = Color.White,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
if (!video.overview.isNullOrBlank()) {
|
||||
Text(
|
||||
text = video.overview,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontSize = metrics.bodyTextSize,
|
||||
lineHeight = metrics.bodyLineHeight,
|
||||
),
|
||||
color = Color.White.copy(alpha = 0.86f),
|
||||
maxLines = metrics.overviewMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
video.runtime?.takeIf { it > 0 }?.let { runtimeMinutes ->
|
||||
Text(
|
||||
text = formatEpisodeRuntime(runtimeMinutes),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate ->
|
||||
Text(
|
||||
text = formattedDate,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progressEntry
|
||||
?.takeIf { it.durationMs > 0L && !it.isCompleted }
|
||||
?.let { entry ->
|
||||
NuvioProgressBar(
|
||||
progress = entry.progressFraction,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = metrics.contentPadding, vertical = 8.dp),
|
||||
height = 4.dp,
|
||||
trackColor = Color.White.copy(alpha = 0.22f),
|
||||
fillColor = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class EpisodeHorizontalCardMetrics(
|
||||
val rowHorizontalPadding: Dp,
|
||||
val rowVerticalPadding: Dp,
|
||||
val itemSpacing: Dp,
|
||||
val cardWidth: Dp,
|
||||
val cardHeight: Dp,
|
||||
val cornerRadius: Dp,
|
||||
val contentPadding: Dp,
|
||||
val contentBottomPadding: Dp,
|
||||
val titleTextSize: androidx.compose.ui.unit.TextUnit,
|
||||
val titleLineHeight: androidx.compose.ui.unit.TextUnit,
|
||||
val bodyTextSize: androidx.compose.ui.unit.TextUnit,
|
||||
val bodyLineHeight: androidx.compose.ui.unit.TextUnit,
|
||||
val overviewMaxLines: Int,
|
||||
val metaTextSize: androidx.compose.ui.unit.TextUnit,
|
||||
val badgeTextSize: androidx.compose.ui.unit.TextUnit,
|
||||
val badgeRadius: Dp,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHorizontalCardMetrics {
|
||||
return remember(maxWidthDp) {
|
||||
when {
|
||||
maxWidthDp >= 1300f -> EpisodeHorizontalCardMetrics(
|
||||
rowHorizontalPadding = 0.dp,
|
||||
rowVerticalPadding = 0.dp,
|
||||
itemSpacing = 18.dp,
|
||||
cardWidth = 420.dp,
|
||||
cardHeight = 256.dp,
|
||||
cornerRadius = 18.dp,
|
||||
contentPadding = 16.dp,
|
||||
contentBottomPadding = 18.dp,
|
||||
titleTextSize = 18.sp,
|
||||
titleLineHeight = 24.sp,
|
||||
bodyTextSize = 14.sp,
|
||||
bodyLineHeight = 20.sp,
|
||||
overviewMaxLines = 3,
|
||||
metaTextSize = 12.sp,
|
||||
badgeTextSize = 11.sp,
|
||||
badgeRadius = 6.dp,
|
||||
)
|
||||
|
||||
maxWidthDp >= 1000f -> EpisodeHorizontalCardMetrics(
|
||||
rowHorizontalPadding = 0.dp,
|
||||
rowVerticalPadding = 0.dp,
|
||||
itemSpacing = 16.dp,
|
||||
cardWidth = 384.dp,
|
||||
cardHeight = 236.dp,
|
||||
cornerRadius = 16.dp,
|
||||
contentPadding = 14.dp,
|
||||
contentBottomPadding = 16.dp,
|
||||
titleTextSize = 17.sp,
|
||||
titleLineHeight = 22.sp,
|
||||
bodyTextSize = 13.sp,
|
||||
bodyLineHeight = 18.sp,
|
||||
overviewMaxLines = 3,
|
||||
metaTextSize = 12.sp,
|
||||
badgeTextSize = 10.sp,
|
||||
badgeRadius = 6.dp,
|
||||
)
|
||||
|
||||
maxWidthDp >= 760f -> EpisodeHorizontalCardMetrics(
|
||||
rowHorizontalPadding = 0.dp,
|
||||
rowVerticalPadding = 0.dp,
|
||||
itemSpacing = 14.dp,
|
||||
cardWidth = 340.dp,
|
||||
cardHeight = 212.dp,
|
||||
cornerRadius = 14.dp,
|
||||
contentPadding = 12.dp,
|
||||
contentBottomPadding = 14.dp,
|
||||
titleTextSize = 16.sp,
|
||||
titleLineHeight = 21.sp,
|
||||
bodyTextSize = 12.sp,
|
||||
bodyLineHeight = 17.sp,
|
||||
overviewMaxLines = 2,
|
||||
metaTextSize = 11.sp,
|
||||
badgeTextSize = 10.sp,
|
||||
badgeRadius = 5.dp,
|
||||
)
|
||||
|
||||
else -> EpisodeHorizontalCardMetrics(
|
||||
rowHorizontalPadding = 0.dp,
|
||||
rowVerticalPadding = 0.dp,
|
||||
itemSpacing = 12.dp,
|
||||
cardWidth = 296.dp,
|
||||
cardHeight = 184.dp,
|
||||
cornerRadius = 14.dp,
|
||||
contentPadding = 10.dp,
|
||||
contentBottomPadding = 12.dp,
|
||||
titleTextSize = 14.sp,
|
||||
titleLineHeight = 19.sp,
|
||||
bodyTextSize = 11.sp,
|
||||
bodyLineHeight = 15.sp,
|
||||
overviewMaxLines = 2,
|
||||
metaTextSize = 10.sp,
|
||||
badgeTextSize = 9.sp,
|
||||
badgeRadius = 5.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatEpisodeRuntime(runtimeMinutes: Int): String {
|
||||
if (runtimeMinutes <= 0) return ""
|
||||
val hours = runtimeMinutes / 60
|
||||
val minutes = runtimeMinutes % 60
|
||||
return when {
|
||||
hours > 0 && minutes > 0 -> "${hours}h ${minutes}m"
|
||||
hours > 0 -> "${hours}h"
|
||||
else -> "${minutes}m"
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun EpisodeListCard(
|
||||
video: MetaVideo,
|
||||
fallbackImage: String?,
|
||||
progressEntry: WatchProgressEntry?,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import androidx.compose.animation.AnimatedVisibility
|
|||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -13,9 +15,11 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
|
|
@ -44,6 +48,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.core.ui.NuvioActionLabel
|
||||
import com.nuvio.app.features.details.MetaEpisodeCardStyle
|
||||
import com.nuvio.app.features.details.MetaScreenSectionItem
|
||||
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||
import com.nuvio.app.features.details.MetaScreenSettingsUiState
|
||||
|
|
@ -76,6 +81,12 @@ internal fun LazyListScope.metaScreenSettingsContent(
|
|||
isTablet = isTablet,
|
||||
onCheckedChange = { MetaScreenSettingsRepository.setTabLayout(it) },
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
MetaEpisodeCardStyleSelector(
|
||||
isTablet = isTablet,
|
||||
selectedStyle = uiState.episodeCardStyle,
|
||||
onStyleSelected = MetaScreenSettingsRepository::setEpisodeCardStyle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -308,4 +319,215 @@ private fun TabGroupChip(
|
|||
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetaEpisodeCardStyleSelector(
|
||||
isTablet: Boolean,
|
||||
selectedStyle: MetaEpisodeCardStyle,
|
||||
onStyleSelected: (MetaEpisodeCardStyle) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = if (isTablet) 18.dp else 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Episode Cards",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = "Choose how episodes are rendered on the metadata screen.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
MetaEpisodeCardStyle.entries.forEach { style ->
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
MetaEpisodeCardStyleOption(
|
||||
style = style,
|
||||
selected = selectedStyle == style,
|
||||
isTablet = isTablet,
|
||||
onClick = { onStyleSelected(style) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetaEpisodeCardStyleOption(
|
||||
style: MetaEpisodeCardStyle,
|
||||
selected: Boolean,
|
||||
isTablet: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.08f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (selected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(148.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
MetaEpisodeCardStylePreview(
|
||||
style = style,
|
||||
isSelected = selected,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = if (style == MetaEpisodeCardStyle.Horizontal) "Horizontal" else "List",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = if (style == MetaEpisodeCardStyle.Horizontal) {
|
||||
"Backdrop-style row cards"
|
||||
} else {
|
||||
"Detail-first stacked cards"
|
||||
},
|
||||
style = if (isTablet) MaterialTheme.typography.bodySmall else MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetaEpisodeCardStylePreview(
|
||||
style: MetaEpisodeCardStyle,
|
||||
isSelected: Boolean,
|
||||
) {
|
||||
val borderColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)
|
||||
}
|
||||
val backgroundColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.10f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(backgroundColor)
|
||||
.border(1.dp, borderColor, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
when (style) {
|
||||
MetaEpisodeCardStyle.Horizontal -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(128.dp)
|
||||
.height(80.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.85f)),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(26.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.36f)),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(42.dp)
|
||||
.height(7.dp)
|
||||
.align(Alignment.TopStart)
|
||||
.padding(start = 6.dp, top = 6.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.26f)),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(72.dp)
|
||||
.height(6.dp)
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = 8.dp, bottom = 8.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.18f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MetaEpisodeCardStyle.List -> {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.width(132.dp)
|
||||
.height(78.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.78f)),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(48.dp)
|
||||
.height(78.dp)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.65f)),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.82f)
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.20f)),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.52f)
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.16f)),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.55f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue