mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-03 16:59:08 +00:00
feat: tab layout customization to metascreen
This commit is contained in:
parent
e83bdc2d53
commit
5e9b7c07a0
14 changed files with 444 additions and 159 deletions
|
|
@ -50,6 +50,7 @@ import androidx.compose.ui.graphics.Shape
|
|||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
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
|
||||
import androidx.compose.material.icons.Icons
|
||||
|
|
@ -318,6 +319,8 @@ fun NuvioInfoBadge(
|
|||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,13 +62,15 @@ fun <T> NuvioShelfSection(
|
|||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
NuvioShelfSectionHeader(
|
||||
title = title,
|
||||
modifier = Modifier.padding(horizontal = headerHorizontalPadding),
|
||||
showAccent = showHeaderAccent,
|
||||
onViewAllClick = onViewAllClick,
|
||||
viewAllPillSize = viewAllPillSize,
|
||||
)
|
||||
if (title.isNotBlank()) {
|
||||
NuvioShelfSectionHeader(
|
||||
title = title,
|
||||
modifier = Modifier.padding(horizontal = headerHorizontalPadding),
|
||||
showAccent = showHeaderAccent,
|
||||
onViewAllClick = onViewAllClick,
|
||||
viewAllPillSize = viewAllPillSize,
|
||||
)
|
||||
}
|
||||
LazyRow(
|
||||
contentPadding = rowContentPadding,
|
||||
horizontalArrangement = Arrangement.spacedBy(itemSpacing),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import androidx.compose.foundation.background
|
|||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
|
@ -368,9 +369,10 @@ private fun InstalledAddonCard(
|
|||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
Row(
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
NuvioInfoBadge(
|
||||
text = when {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
package com.nuvio.app.features.details
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -46,6 +48,7 @@ import androidx.compose.ui.layout.ContentScale
|
|||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
|
|
@ -810,106 +813,208 @@ private fun ConfiguredMetaSections(
|
|||
onCastClick: ((MetaPerson) -> Unit)?,
|
||||
onCompanyClick: ((MetaCompany, String) -> Unit)?,
|
||||
) {
|
||||
settings.items
|
||||
.filter { it.enabled }
|
||||
.forEach { section ->
|
||||
when (section.key) {
|
||||
MetaScreenSectionKey.ACTIONS -> {
|
||||
DetailActionButtons(
|
||||
playLabel = playButtonLabel,
|
||||
saveLabel = if (isSaved) "Saved" else "Save",
|
||||
isSaved = isSaved,
|
||||
isTablet = isTablet,
|
||||
onPlayClick = onPrimaryPlayClick,
|
||||
onSaveClick = onSaveClick,
|
||||
val enabledItems = settings.items.filter { it.enabled }
|
||||
|
||||
// Helper to check if a section actually has content to show
|
||||
val sectionHasContent: (MetaScreenSectionKey) -> Boolean = { key ->
|
||||
when (key) {
|
||||
MetaScreenSectionKey.ACTIONS -> true
|
||||
MetaScreenSectionKey.OVERVIEW -> true
|
||||
MetaScreenSectionKey.PRODUCTION -> hasProductionSection
|
||||
MetaScreenSectionKey.CAST -> meta.cast.isNotEmpty()
|
||||
MetaScreenSectionKey.COMMENTS -> shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank())
|
||||
MetaScreenSectionKey.TRAILERS -> hasTrailersSection
|
||||
MetaScreenSectionKey.EPISODES -> hasEpisodes
|
||||
MetaScreenSectionKey.DETAILS -> hasAdditionalInfoSection
|
||||
MetaScreenSectionKey.COLLECTION -> !hasEpisodes && hasCollectionSection
|
||||
MetaScreenSectionKey.MORE_LIKE_THIS -> hasMoreLikeThisSection
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderSection(key: MetaScreenSectionKey, showHeader: Boolean = true) {
|
||||
when (key) {
|
||||
MetaScreenSectionKey.ACTIONS -> {
|
||||
DetailActionButtons(
|
||||
playLabel = playButtonLabel,
|
||||
saveLabel = if (isSaved) "Saved" else "Save",
|
||||
isSaved = isSaved,
|
||||
isTablet = isTablet,
|
||||
onPlayClick = onPrimaryPlayClick,
|
||||
onSaveClick = onSaveClick,
|
||||
)
|
||||
}
|
||||
MetaScreenSectionKey.OVERVIEW -> {
|
||||
DetailMetaInfo(meta = meta)
|
||||
}
|
||||
MetaScreenSectionKey.PRODUCTION -> {
|
||||
if (hasProductionSection) {
|
||||
DetailProductionSection(meta = meta, showHeader = showHeader, onCompanyClick = onCompanyClick)
|
||||
}
|
||||
}
|
||||
MetaScreenSectionKey.CAST -> {
|
||||
DetailCastSection(cast = meta.cast, showHeader = showHeader, onCastClick = onCastClick)
|
||||
}
|
||||
MetaScreenSectionKey.COMMENTS -> {
|
||||
if (shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank())) {
|
||||
DetailCommentsSection(
|
||||
comments = comments,
|
||||
isLoading = isCommentsLoading,
|
||||
isLoadingMore = isCommentsLoadingMore,
|
||||
canLoadMore = commentsCurrentPage < commentsPageCount,
|
||||
error = commentsError,
|
||||
onRetry = onRetryComments,
|
||||
onLoadMore = onLoadMoreComments,
|
||||
onCommentClick = onCommentClick,
|
||||
showHeader = showHeader,
|
||||
)
|
||||
}
|
||||
|
||||
MetaScreenSectionKey.OVERVIEW -> {
|
||||
DetailMetaInfo(meta = meta)
|
||||
}
|
||||
MetaScreenSectionKey.TRAILERS -> {
|
||||
if (hasTrailersSection) {
|
||||
DetailTrailersSection(trailers = meta.trailers, onTrailerClick = onTrailerClick, showHeader = showHeader)
|
||||
}
|
||||
|
||||
MetaScreenSectionKey.PRODUCTION -> {
|
||||
if (hasProductionSection) {
|
||||
DetailProductionSection(
|
||||
meta = meta,
|
||||
onCompanyClick = onCompanyClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MetaScreenSectionKey.CAST -> {
|
||||
DetailCastSection(
|
||||
cast = meta.cast,
|
||||
onCastClick = onCastClick,
|
||||
}
|
||||
MetaScreenSectionKey.EPISODES -> {
|
||||
if (hasEpisodes) {
|
||||
DetailSeriesContent(
|
||||
meta = meta,
|
||||
showHeader = showHeader,
|
||||
progressByVideoId = progressByVideoId,
|
||||
watchedKeys = watchedKeys,
|
||||
onEpisodeClick = onEpisodeClick,
|
||||
onEpisodeLongPress = onEpisodeLongPress,
|
||||
)
|
||||
}
|
||||
|
||||
MetaScreenSectionKey.COMMENTS -> {
|
||||
if (shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank())) {
|
||||
DetailCommentsSection(
|
||||
comments = comments,
|
||||
isLoading = isCommentsLoading,
|
||||
isLoadingMore = isCommentsLoadingMore,
|
||||
canLoadMore = commentsCurrentPage < commentsPageCount,
|
||||
error = commentsError,
|
||||
onRetry = onRetryComments,
|
||||
onLoadMore = onLoadMoreComments,
|
||||
onCommentClick = onCommentClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
MetaScreenSectionKey.DETAILS -> {
|
||||
if (hasAdditionalInfoSection) {
|
||||
DetailAdditionalInfoSection(meta = meta, showHeader = showHeader)
|
||||
}
|
||||
|
||||
MetaScreenSectionKey.TRAILERS -> {
|
||||
if (hasTrailersSection) {
|
||||
DetailTrailersSection(
|
||||
trailers = meta.trailers,
|
||||
onTrailerClick = onTrailerClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
MetaScreenSectionKey.COLLECTION -> {
|
||||
if (!hasEpisodes && hasCollectionSection) {
|
||||
DetailPosterRailSection(
|
||||
title = meta.collectionName.orEmpty(),
|
||||
items = meta.collectionItems,
|
||||
watchedKeys = watchedKeys,
|
||||
showHeader = showHeader,
|
||||
onPosterClick = onOpenMeta,
|
||||
)
|
||||
}
|
||||
|
||||
MetaScreenSectionKey.EPISODES -> {
|
||||
if (hasEpisodes) {
|
||||
DetailSeriesContent(
|
||||
meta = meta,
|
||||
progressByVideoId = progressByVideoId,
|
||||
watchedKeys = watchedKeys,
|
||||
onEpisodeClick = onEpisodeClick,
|
||||
onEpisodeLongPress = onEpisodeLongPress,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MetaScreenSectionKey.DETAILS -> {
|
||||
if (hasAdditionalInfoSection) {
|
||||
DetailAdditionalInfoSection(meta = meta)
|
||||
}
|
||||
}
|
||||
|
||||
MetaScreenSectionKey.COLLECTION -> {
|
||||
if (!hasEpisodes && hasCollectionSection) {
|
||||
DetailPosterRailSection(
|
||||
title = meta.collectionName.orEmpty(),
|
||||
items = meta.collectionItems,
|
||||
watchedKeys = watchedKeys,
|
||||
onPosterClick = onOpenMeta,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MetaScreenSectionKey.MORE_LIKE_THIS -> {
|
||||
if (hasMoreLikeThisSection) {
|
||||
DetailPosterRailSection(
|
||||
title = "More Like This",
|
||||
items = meta.moreLikeThis,
|
||||
watchedKeys = watchedKeys,
|
||||
onPosterClick = onOpenMeta,
|
||||
)
|
||||
}
|
||||
}
|
||||
MetaScreenSectionKey.MORE_LIKE_THIS -> {
|
||||
if (hasMoreLikeThisSection) {
|
||||
DetailPosterRailSection(
|
||||
title = "More Like This",
|
||||
items = meta.moreLikeThis,
|
||||
watchedKeys = watchedKeys,
|
||||
showHeader = showHeader,
|
||||
onPosterClick = onOpenMeta,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!settings.tabLayout) {
|
||||
// Standard mode: render sections individually in order
|
||||
enabledItems.forEach { section -> RenderSection(section.key) }
|
||||
} else {
|
||||
// Tab layout mode: group sections by tabGroup, render grouped ones as tabs
|
||||
val processedGroups = mutableSetOf<Int>()
|
||||
|
||||
enabledItems.forEach { section ->
|
||||
val groupId = section.tabGroup
|
||||
if (groupId == null) {
|
||||
// Standalone section
|
||||
RenderSection(section.key)
|
||||
} else if (groupId !in processedGroups) {
|
||||
// First encounter of this group — render the whole tabbed group
|
||||
processedGroups.add(groupId)
|
||||
val groupMembers = enabledItems
|
||||
.filter { it.tabGroup == groupId && sectionHasContent(it.key) }
|
||||
if (groupMembers.isEmpty()) return@forEach
|
||||
if (groupMembers.size == 1) {
|
||||
// Only one member with content — render standalone
|
||||
RenderSection(groupMembers.first().key)
|
||||
} else {
|
||||
TabbedSectionGroup(
|
||||
tabs = groupMembers.map { it.key to it.title },
|
||||
) { activeKey ->
|
||||
RenderSection(activeKey, showHeader = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
// else: already processed as part of group, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabbedSectionGroup(
|
||||
tabs: List<Pair<MetaScreenSectionKey, String>>,
|
||||
content: @Composable (MetaScreenSectionKey) -> Unit,
|
||||
) {
|
||||
if (tabs.isEmpty()) return
|
||||
|
||||
var selectedIndex by remember { mutableIntStateOf(0) }
|
||||
val clampedIndex = selectedIndex.coerceIn(0, tabs.lastIndex)
|
||||
if (clampedIndex != selectedIndex) selectedIndex = clampedIndex
|
||||
|
||||
val headerColor = MaterialTheme.colorScheme.onBackground
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
// Tab row using the same style as DetailSectionTitle
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val titleSize = if (maxWidth >= 720.dp) 22.sp else 20.sp
|
||||
val headerStyle = MaterialTheme.typography.titleLarge.copy(
|
||||
fontSize = titleSize,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
tabs.forEachIndexed { index, (_, title) ->
|
||||
if (index > 0) {
|
||||
Text(
|
||||
text = "|",
|
||||
style = headerStyle,
|
||||
color = headerColor.copy(alpha = 0.45f),
|
||||
modifier = Modifier.padding(horizontal = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = headerStyle,
|
||||
color = if (index == selectedIndex) {
|
||||
headerColor
|
||||
} else {
|
||||
headerColor.copy(alpha = 0.55f)
|
||||
},
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) { selectedIndex = index },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content with crossfade
|
||||
Crossfade(
|
||||
targetState = tabs[selectedIndex].first,
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "tabbedSectionCrossfade",
|
||||
) { activeKey ->
|
||||
content(activeKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp =
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.details
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
|
@ -19,6 +20,11 @@ enum class MetaScreenSectionKey {
|
|||
DETAILS,
|
||||
COLLECTION,
|
||||
MORE_LIKE_THIS,
|
||||
;
|
||||
|
||||
|
||||
val canBeTabbed: Boolean
|
||||
get() = this != ACTIONS && this != OVERVIEW
|
||||
}
|
||||
|
||||
data class MetaScreenSectionItem(
|
||||
|
|
@ -27,11 +33,13 @@ data class MetaScreenSectionItem(
|
|||
val description: String,
|
||||
val enabled: Boolean,
|
||||
val order: Int,
|
||||
val tabGroup: Int? = null,
|
||||
)
|
||||
|
||||
data class MetaScreenSettingsUiState(
|
||||
val items: List<MetaScreenSectionItem> = emptyList(),
|
||||
val cinematicBackground: Boolean = false,
|
||||
val tabLayout: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
|
@ -39,12 +47,15 @@ private data class StoredMetaScreenSectionPreference(
|
|||
val key: String,
|
||||
val enabled: Boolean = true,
|
||||
val order: Int = 0,
|
||||
val tabGroup: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class StoredMetaScreenSettingsPayload(
|
||||
val items: List<StoredMetaScreenSectionPreference> = emptyList(),
|
||||
val cinematicBackground: Boolean = false,
|
||||
@SerialName("tvStyleLayout")
|
||||
val tabLayout: Boolean = false,
|
||||
)
|
||||
|
||||
private data class MetaScreenSectionDefinition(
|
||||
|
|
@ -118,6 +129,7 @@ object MetaScreenSettingsRepository {
|
|||
private var hasLoaded = false
|
||||
private var preferences: MutableMap<MetaScreenSectionKey, StoredMetaScreenSectionPreference> = mutableMapOf()
|
||||
private var cinematicBackground: Boolean = false
|
||||
private var tabLayout: Boolean = false
|
||||
|
||||
fun ensureLoaded() {
|
||||
if (hasLoaded) return
|
||||
|
|
@ -130,6 +142,7 @@ object MetaScreenSettingsRepository {
|
|||
}.getOrNull()
|
||||
if (parsed != null) {
|
||||
cinematicBackground = parsed.cinematicBackground
|
||||
tabLayout = parsed.tabLayout
|
||||
preferences = parsed.items.mapNotNull { item ->
|
||||
val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null
|
||||
key to item
|
||||
|
|
@ -146,6 +159,7 @@ object MetaScreenSettingsRepository {
|
|||
hasLoaded = false
|
||||
preferences.clear()
|
||||
cinematicBackground = false
|
||||
tabLayout = false
|
||||
_uiState.value = MetaScreenSettingsUiState()
|
||||
ensureLoaded()
|
||||
}
|
||||
|
|
@ -157,10 +171,31 @@ object MetaScreenSettingsRepository {
|
|||
persist()
|
||||
}
|
||||
|
||||
fun setTabLayout(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
tabLayout = enabled
|
||||
publish()
|
||||
persist()
|
||||
}
|
||||
|
||||
fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) {
|
||||
ensureLoaded()
|
||||
if (!key.canBeTabbed) return
|
||||
if (groupId != null) {
|
||||
// Enforce max 3 sections per group
|
||||
val currentGroupCount = preferences.count { it.value.tabGroup == groupId && it.key != key }
|
||||
if (currentGroupCount >= 3) return
|
||||
}
|
||||
updatePreference(key) { preference ->
|
||||
preference.copy(tabGroup = groupId)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearLocalState() {
|
||||
hasLoaded = false
|
||||
preferences.clear()
|
||||
cinematicBackground = false
|
||||
tabLayout = false
|
||||
_uiState.value = MetaScreenSettingsUiState()
|
||||
}
|
||||
|
||||
|
|
@ -174,6 +209,7 @@ object MetaScreenSettingsRepository {
|
|||
ensureLoaded()
|
||||
preferences.clear()
|
||||
cinematicBackground = false
|
||||
tabLayout = false
|
||||
normalizePreferences()
|
||||
publish()
|
||||
persist()
|
||||
|
|
@ -216,6 +252,7 @@ object MetaScreenSettingsRepository {
|
|||
key = definition.key.name,
|
||||
enabled = stored?.enabled ?: true,
|
||||
order = index,
|
||||
tabGroup = stored?.tabGroup,
|
||||
)
|
||||
}
|
||||
preferences = normalized
|
||||
|
|
@ -233,9 +270,11 @@ object MetaScreenSettingsRepository {
|
|||
description = definition.description,
|
||||
enabled = preference?.enabled ?: true,
|
||||
order = preference?.order ?: 0,
|
||||
tabGroup = preference?.tabGroup,
|
||||
)
|
||||
},
|
||||
cinematicBackground = cinematicBackground,
|
||||
tabLayout = tabLayout,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -245,6 +284,7 @@ object MetaScreenSettingsRepository {
|
|||
StoredMetaScreenSettingsPayload(
|
||||
items = preferences.values.sortedBy { it.order },
|
||||
cinematicBackground = cinematicBackground,
|
||||
tabLayout = tabLayout,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import com.nuvio.app.features.details.MetaDetails
|
|||
fun DetailAdditionalInfoSection(
|
||||
meta: MetaDetails,
|
||||
modifier: Modifier = Modifier,
|
||||
showHeader: Boolean = true,
|
||||
) {
|
||||
val isSeriesLike = meta.type == "series" || meta.videos.any { it.season != null || it.episode != null }
|
||||
val title = if (isSeriesLike) "Show Details" else "Movie Details"
|
||||
|
|
@ -39,6 +40,7 @@ fun DetailAdditionalInfoSection(
|
|||
DetailSection(
|
||||
title = title,
|
||||
modifier = modifier,
|
||||
showHeader = showHeader,
|
||||
) {
|
||||
rows.forEachIndexed { index, (label, value) ->
|
||||
DetailInfoRow(
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import com.nuvio.app.features.details.MetaPerson
|
|||
fun DetailCastSection(
|
||||
cast: List<MetaPerson>,
|
||||
modifier: Modifier = Modifier,
|
||||
showHeader: Boolean = true,
|
||||
onCastClick: ((MetaPerson) -> Unit)? = null,
|
||||
) {
|
||||
if (cast.isEmpty()) return
|
||||
|
|
@ -39,6 +40,7 @@ fun DetailCastSection(
|
|||
DetailSection(
|
||||
title = "Cast",
|
||||
modifier = modifier,
|
||||
showHeader = showHeader,
|
||||
) {
|
||||
BoxWithConstraints {
|
||||
val sizing = castSectionSizing(maxWidth.value)
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ fun DetailCommentsSection(
|
|||
onLoadMore: () -> Unit,
|
||||
onCommentClick: (TraktCommentReview) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
showHeader: Boolean = true,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
|
|
@ -68,8 +69,10 @@ fun DetailCommentsSection(
|
|||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
CommentsHeader()
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
if (showHeader) {
|
||||
CommentsHeader()
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
when {
|
||||
isLoading -> {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ fun DetailPosterRailSection(
|
|||
items: List<MetaPreview>,
|
||||
watchedKeys: Set<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
showHeader: Boolean = true,
|
||||
headerHorizontalPadding: Dp = 0.dp,
|
||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
||||
|
|
@ -24,7 +25,7 @@ fun DetailPosterRailSection(
|
|||
if (items.isEmpty()) return
|
||||
|
||||
NuvioShelfSection(
|
||||
title = title,
|
||||
title = if (showHeader) title else "",
|
||||
entries = items,
|
||||
modifier = modifier,
|
||||
headerHorizontalPadding = headerHorizontalPadding,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import com.nuvio.app.features.details.MetaDetails
|
|||
fun DetailProductionSection(
|
||||
meta: MetaDetails,
|
||||
modifier: Modifier = Modifier,
|
||||
showHeader: Boolean = true,
|
||||
onCompanyClick: ((MetaCompany, String) -> Unit)? = null,
|
||||
) {
|
||||
val isSeriesLike = meta.type == "series" || meta.videos.any { it.season != null || it.episode != null }
|
||||
|
|
@ -55,6 +56,7 @@ fun DetailProductionSection(
|
|||
DetailSection(
|
||||
title = if (isSeriesLike) "Network" else "Production",
|
||||
modifier = modifier,
|
||||
showHeader = showHeader,
|
||||
) {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val chipHeight = when {
|
||||
|
|
|
|||
|
|
@ -16,13 +16,16 @@ import androidx.compose.ui.unit.sp
|
|||
fun DetailSection(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
showHeader: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
DetailSectionTitle(title = title)
|
||||
if (showHeader) {
|
||||
DetailSectionTitle(title = title)
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ private val log = Logger.withTag("SeriesContent")
|
|||
fun DetailSeriesContent(
|
||||
meta: MetaDetails,
|
||||
modifier: Modifier = Modifier,
|
||||
showHeader: Boolean = true,
|
||||
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
|
||||
watchedKeys: Set<String> = emptySet(),
|
||||
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
|
||||
|
|
@ -81,6 +82,7 @@ fun DetailSeriesContent(
|
|||
DetailSection(
|
||||
title = "Episodes",
|
||||
modifier = modifier,
|
||||
showHeader = showHeader,
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
|
|
@ -120,6 +122,7 @@ fun DetailSeriesContent(
|
|||
DetailSection(
|
||||
title = "Episodes",
|
||||
modifier = modifier,
|
||||
showHeader = showHeader,
|
||||
) {
|
||||
Text(
|
||||
text = "This addon returned videos for the series, but none included season or episode numbers.",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ fun DetailTrailersSection(
|
|||
trailers: List<MetaTrailer>,
|
||||
onTrailerClick: (MetaTrailer) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
showHeader: Boolean = true,
|
||||
) {
|
||||
if (trailers.isEmpty()) return
|
||||
|
||||
|
|
@ -79,10 +80,12 @@ fun DetailTrailersSection(
|
|||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
DetailSectionTitle(
|
||||
title = "Trailers",
|
||||
fullWidth = false,
|
||||
)
|
||||
if (showHeader) {
|
||||
DetailSectionTitle(
|
||||
title = "Trailers",
|
||||
fullWidth = false,
|
||||
)
|
||||
}
|
||||
|
||||
Box {
|
||||
Surface(
|
||||
|
|
|
|||
|
|
@ -1,18 +1,30 @@
|
|||
package com.nuvio.app.features.settings
|
||||
|
||||
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.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Menu
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -25,6 +37,7 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
|
@ -55,6 +68,14 @@ internal fun LazyListScope.metaScreenSettingsContent(
|
|||
isTablet = isTablet,
|
||||
onCheckedChange = { MetaScreenSettingsRepository.setCinematicBackground(it) },
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = "Tab Layout",
|
||||
description = "Group sections into tabs like the TV app. Assign up to 3 sections per tab group.",
|
||||
checked = uiState.tabLayout,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = { MetaScreenSettingsRepository.setTabLayout(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -73,6 +94,7 @@ internal fun LazyListScope.metaScreenSettingsContent(
|
|||
MetaSectionReorderableList(
|
||||
items = uiState.items,
|
||||
isTablet = isTablet,
|
||||
tabLayout = uiState.tabLayout,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -83,6 +105,7 @@ internal fun LazyListScope.metaScreenSettingsContent(
|
|||
private fun MetaSectionReorderableList(
|
||||
items: List<MetaScreenSectionItem>,
|
||||
isTablet: Boolean,
|
||||
tabLayout: Boolean,
|
||||
) {
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
|
@ -93,6 +116,13 @@ private fun MetaSectionReorderableList(
|
|||
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
}
|
||||
|
||||
// Count members per group for enforcing max 3
|
||||
val groupCounts: Map<Int, Int> = if (tabLayout) {
|
||||
items.filter { it.tabGroup != null }.groupBy { it.tabGroup!! }.mapValues { it.value.size }
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -111,7 +141,10 @@ private fun MetaSectionReorderableList(
|
|||
MetaSectionRow(
|
||||
item = item,
|
||||
isTablet = isTablet,
|
||||
tabLayout = tabLayout,
|
||||
groupCounts = groupCounts,
|
||||
onEnabledChange = { MetaScreenSettingsRepository.setEnabled(item.key, it) },
|
||||
onTabGroupChange = { MetaScreenSettingsRepository.setTabGroup(item.key, it) },
|
||||
dragHandleScope = this@ReorderableItem,
|
||||
)
|
||||
}
|
||||
|
|
@ -121,77 +154,158 @@ private fun MetaSectionReorderableList(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun MetaSectionRow(
|
||||
item: MetaScreenSectionItem,
|
||||
isTablet: Boolean,
|
||||
tabLayout: Boolean,
|
||||
groupCounts: Map<Int, Int>,
|
||||
onEnabledChange: (Boolean) -> Unit,
|
||||
onTabGroupChange: (Int?) -> Unit,
|
||||
dragHandleScope: ReorderableCollectionItemScope,
|
||||
) {
|
||||
val horizontalPadding = if (isTablet) 20.dp else 16.dp
|
||||
val verticalPadding = if (isTablet) 18.dp else 16.dp
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
|
||||
Row(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = item.description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = if (item.enabled) "Visible" else "Hidden",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = item.enabled,
|
||||
onCheckedChange = onEnabledChange,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primary,
|
||||
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
)
|
||||
IconButton(
|
||||
modifier = with(dragHandleScope) {
|
||||
Modifier.draggableHandle(
|
||||
onDragStarted = {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
},
|
||||
onDragStopped = {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
},
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.Menu,
|
||||
contentDescription = "Reorder",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
Text(
|
||||
text = item.description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = if (item.enabled) "Visible" else "Hidden",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (tabLayout && item.tabGroup != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(4.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)),
|
||||
)
|
||||
Text(
|
||||
text = "Tab Group ${item.tabGroup}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = item.enabled,
|
||||
onCheckedChange = onEnabledChange,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primary,
|
||||
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
)
|
||||
IconButton(
|
||||
modifier = with(dragHandleScope) {
|
||||
Modifier.draggableHandle(
|
||||
onDragStarted = {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
},
|
||||
onDragStopped = {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.Menu,
|
||||
contentDescription = "Reorder",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = tabLayout && item.enabled && item.key.canBeTabbed,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TabGroupChip(
|
||||
label = "None",
|
||||
selected = item.tabGroup == null,
|
||||
onClick = { onTabGroupChange(null) },
|
||||
)
|
||||
for (groupId in 1..3) {
|
||||
val currentCount = groupCounts[groupId] ?: 0
|
||||
val isSelected = item.tabGroup == groupId
|
||||
val isFull = currentCount >= 3 && !isSelected
|
||||
TabGroupChip(
|
||||
label = "Group $groupId",
|
||||
selected = isSelected,
|
||||
enabled = !isFull,
|
||||
onClick = { onTabGroupChange(groupId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabGroupChip(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
label = {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue