diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt index c831d3b6..f4afb525 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt @@ -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, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt index 8cb7d3b1..4ea7d965 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt @@ -62,13 +62,15 @@ fun 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), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt index 77448ac7..4704f2fe 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt @@ -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 { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 2d77010d..e90bc459 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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() + + 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>, + 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 = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt index 00a50d0a..1c6268dd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt @@ -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 = 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 = 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 = 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, ), ), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailAdditionalInfoSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailAdditionalInfoSection.kt index f2b0ed80..7b796d57 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailAdditionalInfoSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailAdditionalInfoSection.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt index d04aa9bd..3515b1ee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt @@ -32,6 +32,7 @@ import com.nuvio.app.features.details.MetaPerson fun DetailCastSection( cast: List, 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) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt index c689b68a..fe4176d1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt @@ -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 -> { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt index f182d736..856f442c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt @@ -17,6 +17,7 @@ fun DetailPosterRailSection( items: List, watchedKeys: Set, 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt index b9155053..84072c94 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt @@ -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 { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSection.kt index debc6f75..385e4fdb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSection.kt @@ -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() } } 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 ebf89442..2cef26bb 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 @@ -69,6 +69,7 @@ private val log = Logger.withTag("SeriesContent") fun DetailSeriesContent( meta: MetaDetails, modifier: Modifier = Modifier, + showHeader: Boolean = true, progressByVideoId: Map = emptyMap(), watchedKeys: Set = 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.", diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt index 67f40765..e4e02355 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt @@ -44,6 +44,7 @@ fun DetailTrailersSection( trailers: List, 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( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt index 55954dd6..68884a0b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt @@ -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, 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 = 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, 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, + ), + ) } \ No newline at end of file