diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index c080183d5..dd1455b64 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -7,6 +7,7 @@ import androidx.activity.enableEdgeToEdge import com.nuvio.app.features.addons.AddonStorage import com.nuvio.app.features.home.HomeCatalogSettingsStorage import com.nuvio.app.features.player.PlayerSettingsStorage +import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage import com.nuvio.app.features.watchprogress.WatchProgressStorage class MainActivity : ComponentActivity() { @@ -16,6 +17,7 @@ class MainActivity : ComponentActivity() { AddonStorage.initialize(applicationContext) HomeCatalogSettingsStorage.initialize(applicationContext) PlayerSettingsStorage.initialize(applicationContext) + ContinueWatchingPreferencesStorage.initialize(applicationContext) WatchProgressStorage.initialize(applicationContext) setContent { diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.android.kt new file mode 100644 index 000000000..b169b3dc5 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.android.kt @@ -0,0 +1,25 @@ +package com.nuvio.app.features.watchprogress + +import android.content.Context +import android.content.SharedPreferences + +actual object ContinueWatchingPreferencesStorage { + private const val preferencesName = "nuvio_continue_watching_preferences" + private const val payloadKey = "continue_watching_preferences_payload" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadPayload(): String? = + preferences?.getString(payloadKey, null) + + actual fun savePayload(payload: String) { + preferences + ?.edit() + ?.putString(payloadKey, payload) + ?.apply() + } +} 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 9f8b6f990..ec08d2bcf 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 @@ -49,6 +49,7 @@ fun NuvioShelfSection( modifier: Modifier = Modifier, headerHorizontalPadding: Dp = 0.dp, rowContentPadding: PaddingValues = PaddingValues(0.dp), + itemSpacing: Dp = 10.dp, onViewAllClick: (() -> Unit)? = null, viewAllPillSize: NuvioViewAllPillSize = NuvioViewAllPillSize.Default, key: ((T) -> Any)? = null, @@ -66,7 +67,7 @@ fun NuvioShelfSection( ) LazyRow( contentPadding = rowContentPadding, - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(itemSpacing), ) { if (key != null) { items( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index e6298e199..e6f078c3d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -15,6 +15,7 @@ import com.nuvio.app.features.home.components.HomeContinueWatchingSection import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomeHeroSection import com.nuvio.app.features.home.components.HomeSkeletonRow +import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.toContinueWatchingItem @@ -29,11 +30,13 @@ fun HomeScreen( ) { LaunchedEffect(Unit) { AddonRepository.initialize() + ContinueWatchingPreferencesRepository.ensureLoaded() WatchProgressRepository.ensureLoaded() } val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val homeUiState by HomeRepository.uiState.collectAsStateWithLifecycle() + val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle() val continueWatchingItems = remember(watchProgressUiState.entries) { watchProgressUiState.entries.take(20).map { it.toContinueWatchingItem() } @@ -64,10 +67,11 @@ fun HomeScreen( ) { when { addonsUiState.addons.none { it.manifest != null } -> { - if (continueWatchingItems.isNotEmpty()) { + if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { item { HomeContinueWatchingSection( items = continueWatchingItems, + style = continueWatchingPreferences.style, modifier = Modifier.padding(bottom = 12.dp), onItemClick = onContinueWatchingClick, onItemLongPress = onContinueWatchingLongPress, @@ -84,10 +88,11 @@ fun HomeScreen( } homeUiState.isLoading && homeUiState.sections.isEmpty() -> { - if (continueWatchingItems.isNotEmpty()) { + if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { item { HomeContinueWatchingSection( items = continueWatchingItems, + style = continueWatchingPreferences.style, modifier = Modifier.padding(bottom = 12.dp), onItemClick = onContinueWatchingClick, onItemLongPress = onContinueWatchingLongPress, @@ -99,7 +104,8 @@ fun HomeScreen( } } - homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() && continueWatchingItems.isEmpty() -> { + homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() && + (!continueWatchingPreferences.isVisible || continueWatchingItems.isEmpty()) -> { item { HomeEmptyStateCard( modifier = Modifier.padding(horizontal = 16.dp), @@ -120,10 +126,11 @@ fun HomeScreen( ) } } - if (continueWatchingItems.isNotEmpty()) { + if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { item { HomeContinueWatchingSection( items = continueWatchingItems, + style = continueWatchingPreferences.style, modifier = Modifier.padding(bottom = 12.dp), onItemClick = onContinueWatchingClick, onItemLongPress = onContinueWatchingLongPress, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index 6280d4e5c..621702e9a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -2,85 +2,303 @@ package com.nuvio.app.features.home.components import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable 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.Spacer import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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 import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.features.watchprogress.ContinueWatchingItem +import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle +import kotlin.math.roundToInt @Composable fun HomeContinueWatchingSection( items: List, + style: ContinueWatchingSectionStyle, modifier: Modifier = Modifier, onItemClick: ((ContinueWatchingItem) -> Unit)? = null, onItemLongPress: ((ContinueWatchingItem) -> Unit)? = null, ) { if (items.isEmpty()) return - NuvioShelfSection( - title = "Continue Watching", - entries = items, - modifier = modifier, - headerHorizontalPadding = 16.dp, - rowContentPadding = PaddingValues(horizontal = 16.dp), - key = { item -> item.videoId }, - ) { item -> - ContinueWatchingCard( - item = item, - onClick = onItemClick?.let { { it(item) } }, - onLongClick = onItemLongPress?.let { { it(item) } }, + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val layout = rememberContinueWatchingLayout(maxWidth.value) + NuvioShelfSection( + title = "Continue Watching", + entries = items, + modifier = Modifier.fillMaxWidth(), + headerHorizontalPadding = layout.sectionPadding, + rowContentPadding = PaddingValues(horizontal = layout.sectionPadding), + itemSpacing = layout.itemGap, + key = { item -> item.videoId }, + ) { item -> + when (style) { + ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( + item = item, + layout = layout, + onClick = onItemClick?.let { { it(item) } }, + onLongClick = onItemLongPress?.let { { it(item) } }, + ) + ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( + item = item, + layout = layout, + onClick = onItemClick?.let { { it(item) } }, + onLongClick = onItemLongPress?.let { { it(item) } }, + ) + } + } + } +} + +@Composable +fun ContinueWatchingStylePreview( + style: ContinueWatchingSectionStyle, + modifier: Modifier = Modifier, + isSelected: Boolean = false, +) { + 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) { + ContinueWatchingSectionStyle.Wide -> WideCardPreview() + ContinueWatchingSectionStyle.Poster -> PosterCardPreview() + } + } +} + +@Composable +private fun WideCardPreview() { + Row( + modifier = Modifier + .width(100.dp) + .height(60.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f)), + ) { + Box( + modifier = Modifier + .width(40.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.65f)), + ) + Column( + modifier = Modifier + .fillMaxHeight() + .weight(1f) + .padding(4.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.7f) + .height(8.dp) + .clip(RoundedCornerShape(2.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.20f)), + ) + Box( + modifier = Modifier + .fillMaxWidth(0.5f) + .height(6.dp) + .clip(RoundedCornerShape(2.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.14f)), + ) + NuvioProgressBar( + progress = 0.6f, + modifier = Modifier.fillMaxWidth(), + height = 4.dp, + trackColor = MaterialTheme.colorScheme.surfaceTint.copy(alpha = 0.16f), + ) + } + } +} + +@Composable +private fun PosterCardPreview() { + Box( + modifier = Modifier + .width(44.dp) + .height(60.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.65f)), + ) { + NuvioProgressBar( + progress = 0.45f, + modifier = Modifier.align(Alignment.BottomCenter), + height = 4.dp, + trackColor = MaterialTheme.colorScheme.surfaceTint.copy(alpha = 0.16f), ) } } @OptIn(ExperimentalFoundationApi::class) @Composable -private fun ContinueWatchingCard( +private fun ContinueWatchingWideCard( item: ContinueWatchingItem, + layout: ContinueWatchingLayout, + onClick: (() -> Unit)?, + onLongClick: (() -> Unit)?, +) { + Row( + modifier = Modifier + .width(layout.wideCardWidth) + .height(layout.wideCardHeight) + .clip(RoundedCornerShape(layout.cardRadius)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.92f)) + .border( + width = 1.5.dp, + color = Color.White.copy(alpha = 0.15f), + shape = RoundedCornerShape(layout.cardRadius), + ) + .combinedClickable( + enabled = onClick != null || onLongClick != null, + onClick = { onClick?.invoke() }, + onLongClick = onLongClick, + ), + ) { + ArtworkPanel( + imageUrl = item.imageUrl, + width = layout.widePosterStripWidth, + modifier = Modifier.fillMaxHeight(), + ) + Column( + modifier = Modifier + .fillMaxHeight() + .weight(1f) + .padding(layout.wideContentPadding), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Text( + text = item.title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleMedium.copy( + fontSize = layout.wideTitleSize, + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (item.progressFraction <= 0f && item.seasonNumber != null && item.episodeNumber != null) { + UpNextBadge(compact = false, textSize = layout.wideBadgeTextSize) + } + } + Text( + text = item.subtitle, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = layout.wideMetaSize, + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (item.progressFraction > 0f) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + NuvioProgressBar( + progress = item.progressFraction, + modifier = Modifier.fillMaxWidth(), + height = layout.progressHeight, + trackColor = Color.White.copy(alpha = 0.10f), + ) + Text( + text = "${(item.progressFraction * 100).roundToInt()}% watched", + style = MaterialTheme.typography.labelSmall.copy( + fontSize = layout.progressLabelSize, + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ContinueWatchingPosterCard( + item: ContinueWatchingItem, + layout: ContinueWatchingLayout, onClick: (() -> Unit)?, onLongClick: (() -> Unit)?, ) { Column( modifier = Modifier - .width(260.dp) - .clip(RoundedCornerShape(22.dp)) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.88f)) + .width(layout.posterCardWidth) .combinedClickable( enabled = onClick != null || onLongClick != null, onClick = { onClick?.invoke() }, onLongClick = onLongClick, - ) - .padding(10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + ), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Box( modifier = Modifier .fillMaxWidth() - .aspectRatio(1.72f) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), + .height(layout.posterCardHeight) + .clip(RoundedCornerShape(layout.cardRadius)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .border( + width = 1.5.dp, + color = Color.White.copy(alpha = 0.15f), + shape = RoundedCornerShape(layout.cardRadius), + ), ) { val imageUrl = item.imageUrl if (imageUrl != null) { @@ -91,44 +309,233 @@ private fun ContinueWatchingCard( contentScale = ContentScale.Crop, ) } - Box( modifier = Modifier - .align(Alignment.BottomStart) - .padding(10.dp) - .clip(RoundedCornerShape(999.dp)) - .background(MaterialTheme.colorScheme.background.copy(alpha = 0.78f)) - .padding(horizontal = 10.dp, vertical = 6.dp), - ) { - Text( - text = "Resume", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.SemiBold, + .fillMaxWidth() + .height(layout.posterCardHeight * 0.5f) + .align(Alignment.BottomCenter) + .background( + Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.8f)), + ), + ), + ) + if (item.seasonNumber != null && item.episodeNumber != null) { + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color.Black.copy(alpha = 0.7f)) + .padding(horizontal = 6.dp, vertical = 3.dp), + ) { + Text( + text = "S${item.seasonNumber} E${item.episodeNumber}", + style = MaterialTheme.typography.labelSmall.copy( + fontSize = layout.posterMetaSize, + fontWeight = FontWeight.SemiBold, + ), + color = Color.White, + ) + } + } + if (item.progressFraction <= 0f && item.seasonNumber != null && item.episodeNumber != null) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + ) { + UpNextBadge(compact = true, textSize = layout.posterBadgeTextSize) + } + } + if (item.progressFraction > 0f) { + NuvioProgressBar( + progress = item.progressFraction, + modifier = Modifier.align(Alignment.BottomCenter), + height = 4.dp, + trackColor = Color.White.copy(alpha = 0.3f), ) } } - Text( - text = item.title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = item.subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - NuvioProgressBar( - progress = item.progressFraction, - modifier = Modifier.fillMaxWidth(), - height = 5.dp, - ) - Spacer(modifier = Modifier.height(2.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Text( + text = item.title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = layout.posterTitleSize, + fontWeight = FontWeight.SemiBold, + lineHeight = 18.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (item.progressFraction > 0f) { + Text( + text = "${(item.progressFraction * 100).roundToInt()}%", + modifier = Modifier.padding(start = 6.dp), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = layout.progressLabelSize, + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } } + +@Composable +private fun ArtworkPanel( + imageUrl: String?, + width: Dp, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .width(width) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + if (imageUrl != null) { + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + } +} + +@Composable +private fun UpNextBadge( + compact: Boolean, + textSize: androidx.compose.ui.unit.TextUnit, +) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(if (compact) 4.dp else 12.dp)) + .background(MaterialTheme.colorScheme.primary) + .padding( + horizontal = if (compact) 6.dp else 8.dp, + vertical = if (compact) 3.dp else 4.dp, + ), + ) { + Text( + text = "Up next", + style = MaterialTheme.typography.labelSmall.copy( + fontSize = textSize, + fontWeight = FontWeight.Bold, + ), + color = Color.White, + maxLines = 1, + ) + } +} + +private data class ContinueWatchingLayout( + val sectionPadding: Dp, + val itemGap: Dp, + val wideCardWidth: Dp, + val wideCardHeight: Dp, + val widePosterStripWidth: Dp, + val wideContentPadding: Dp, + val posterCardWidth: Dp, + val posterCardHeight: Dp, + val cardRadius: Dp, + val progressHeight: Dp, + val wideTitleSize: androidx.compose.ui.unit.TextUnit, + val wideMetaSize: androidx.compose.ui.unit.TextUnit, + val posterTitleSize: androidx.compose.ui.unit.TextUnit, + val posterMetaSize: androidx.compose.ui.unit.TextUnit, + val progressLabelSize: androidx.compose.ui.unit.TextUnit, + val wideBadgeTextSize: androidx.compose.ui.unit.TextUnit, + val posterBadgeTextSize: androidx.compose.ui.unit.TextUnit, +) + +private fun rememberContinueWatchingLayout(maxWidthDp: Float): ContinueWatchingLayout = + when { + maxWidthDp >= 1440f -> ContinueWatchingLayout( + sectionPadding = 32.dp, + itemGap = 20.dp, + wideCardWidth = 400.dp, + wideCardHeight = 160.dp, + widePosterStripWidth = 100.dp, + wideContentPadding = 16.dp, + posterCardWidth = 180.dp, + posterCardHeight = 270.dp, + cardRadius = 18.dp, + progressHeight = 6.dp, + wideTitleSize = 20.sp, + wideMetaSize = 16.sp, + posterTitleSize = 16.sp, + posterMetaSize = 14.sp, + progressLabelSize = 14.sp, + wideBadgeTextSize = 14.sp, + posterBadgeTextSize = 12.sp, + ) + maxWidthDp >= 1024f -> ContinueWatchingLayout( + sectionPadding = 28.dp, + itemGap = 18.dp, + wideCardWidth = 350.dp, + wideCardHeight = 140.dp, + widePosterStripWidth = 90.dp, + wideContentPadding = 14.dp, + posterCardWidth = 160.dp, + posterCardHeight = 240.dp, + cardRadius = 16.dp, + progressHeight = 5.dp, + wideTitleSize = 18.sp, + wideMetaSize = 15.sp, + posterTitleSize = 15.sp, + posterMetaSize = 13.sp, + progressLabelSize = 13.sp, + wideBadgeTextSize = 13.sp, + posterBadgeTextSize = 10.sp, + ) + maxWidthDp >= 768f -> ContinueWatchingLayout( + sectionPadding = 24.dp, + itemGap = 16.dp, + wideCardWidth = 320.dp, + wideCardHeight = 130.dp, + widePosterStripWidth = 85.dp, + wideContentPadding = 12.dp, + posterCardWidth = 140.dp, + posterCardHeight = 210.dp, + cardRadius = 16.dp, + progressHeight = 4.dp, + wideTitleSize = 17.sp, + wideMetaSize = 14.sp, + posterTitleSize = 14.sp, + posterMetaSize = 12.sp, + progressLabelSize = 12.sp, + wideBadgeTextSize = 12.sp, + posterBadgeTextSize = 10.sp, + ) + else -> ContinueWatchingLayout( + sectionPadding = 16.dp, + itemGap = 16.dp, + wideCardWidth = 280.dp, + wideCardHeight = 120.dp, + widePosterStripWidth = 80.dp, + wideContentPadding = 12.dp, + posterCardWidth = 120.dp, + posterCardHeight = 180.dp, + cardRadius = 16.dp, + progressHeight = 4.dp, + wideTitleSize = 16.sp, + wideMetaSize = 13.sp, + posterTitleSize = 14.sp, + posterMetaSize = 12.sp, + progressLabelSize = 11.sp, + wideBadgeTextSize = 12.sp, + posterBadgeTextSize = 10.sp, + ) + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt new file mode 100644 index 000000000..6b4a32bde --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt @@ -0,0 +1,25 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Style + +internal fun LazyListScope.appearanceSettingsContent( + isTablet: Boolean, + onContinueWatchingClick: () -> Unit, +) { + item { + SettingsSection( + title = "HOME", + isTablet = isTablet, + ) { + SettingsNavigationRow( + title = "Continue Watching", + description = "Show, hide, and style the Continue Watching shelf.", + icon = Icons.Rounded.Style, + isTablet = isTablet, + onClick = onContinueWatchingClick, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt new file mode 100644 index 000000000..26b150928 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt @@ -0,0 +1,146 @@ +package com.nuvio.app.features.settings + +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.home.components.ContinueWatchingStylePreview +import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository +import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle + +internal fun LazyListScope.continueWatchingSettingsContent( + isTablet: Boolean, + isVisible: Boolean, + style: ContinueWatchingSectionStyle, +) { + item { + SettingsSection( + title = "VISIBILITY", + isTablet = isTablet, + ) { + SettingsSwitchRow( + title = "Show Continue Watching", + description = "Display the Continue Watching shelf on the Home screen.", + checked = isVisible, + isTablet = isTablet, + onCheckedChange = ContinueWatchingPreferencesRepository::setVisible, + ) + } + } + item { + SettingsSection( + title = "CARD STYLE", + isTablet = isTablet, + ) { + ContinueWatchingStyleSelector( + isTablet = isTablet, + selectedStyle = style, + onStyleSelected = ContinueWatchingPreferencesRepository::setStyle, + ) + } + } +} + +@Composable +private fun ContinueWatchingStyleSelector( + isTablet: Boolean, + selectedStyle: ContinueWatchingSectionStyle, + onStyleSelected: (ContinueWatchingSectionStyle) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + ContinueWatchingSectionStyle.entries.forEach { style -> + Box(modifier = Modifier.weight(1f)) { + ContinueWatchingStyleOption( + style = style, + selected = selectedStyle == style, + isTablet = isTablet, + onClick = { onStyleSelected(style) }, + ) + } + } + } +} + +@Composable +private fun ContinueWatchingStyleOption( + style: ContinueWatchingSectionStyle, + 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 = androidx.compose.foundation.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), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + if (selected) { + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + ContinueWatchingStylePreview( + style = style, + isSelected = selected, + ) + Text( + text = style.name.lowercase().replaceFirstChar(Char::uppercase), + style = MaterialTheme.typography.bodyMedium, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = if (style == ContinueWatchingSectionStyle.Wide) { + "Info-dense horizontal card" + } else { + "Artwork-first poster card" + }, + style = if (isTablet) MaterialTheme.typography.bodySmall else MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index 1e190e2b6..45a0812a5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -1,6 +1,8 @@ package com.nuvio.app.features.settings import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material.icons.rounded.Style import androidx.compose.material.icons.rounded.Settings import androidx.compose.ui.graphics.vector.ImageVector @@ -16,6 +18,8 @@ internal enum class SettingsPage( ) { Root("Settings"), Playback("Playback"), + Appearance("Appearance"), + ContinueWatching("Continue Watching"), ContentDiscovery("Content & Discovery"), Addons("Addons"), Homescreen("Homescreen"), @@ -25,6 +29,8 @@ internal fun SettingsPage.previousPage(): SettingsPage? = when (this) { SettingsPage.Root -> null SettingsPage.Playback -> SettingsPage.Root + SettingsPage.Appearance -> SettingsPage.Root + SettingsPage.ContinueWatching -> SettingsPage.Appearance SettingsPage.ContentDiscovery -> SettingsPage.Root SettingsPage.Addons -> SettingsPage.ContentDiscovery SettingsPage.Homescreen -> SettingsPage.ContentDiscovery diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index 0a93d89de..53a73d545 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -3,11 +3,13 @@ package com.nuvio.app.features.settings import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Extension +import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.PlayArrow internal fun LazyListScope.settingsRootContent( isTablet: Boolean, onPlaybackClick: () -> Unit, + onAppearanceClick: () -> Unit, onContentDiscoveryClick: () -> Unit, ) { item { @@ -22,6 +24,13 @@ internal fun LazyListScope.settingsRootContent( isTablet = isTablet, onClick = onPlaybackClick, ) + SettingsNavigationRow( + title = "Appearance", + description = "Tune home presentation and visual preferences.", + icon = Icons.Rounded.Palette, + isTablet = isTablet, + onClick = onAppearanceClick, + ) SettingsNavigationRow( title = "Content & Discovery", description = "Manage addons and discovery sources.", diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 33f09fbf3..46589275a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -40,6 +40,8 @@ import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.home.HomeCatalogSettingsItem import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.player.PlayerSettingsRepository +import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository +import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle @Composable fun SettingsScreen( @@ -60,6 +62,10 @@ fun SettingsScreen( PlayerSettingsRepository.ensureLoaded() PlayerSettingsRepository.uiState }.collectAsStateWithLifecycle() + val continueWatchingPreferencesUiState by remember { + ContinueWatchingPreferencesRepository.ensureLoaded() + ContinueWatchingPreferencesRepository.uiState + }.collectAsStateWithLifecycle() LaunchedEffect(addonsUiState.addons) { HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons) @@ -81,6 +87,8 @@ fun SettingsScreen( heroEnabled = homescreenSettingsUiState.heroEnabled, homescreenSettings = homescreenSettingsUiState.items, showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, + continueWatchingVisible = continueWatchingPreferencesUiState.isVisible, + continueWatchingStyle = continueWatchingPreferencesUiState.style, ) } else { MobileSettingsScreen( @@ -89,6 +97,8 @@ fun SettingsScreen( heroEnabled = homescreenSettingsUiState.heroEnabled, homescreenSettings = homescreenSettingsUiState.items, showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, + continueWatchingVisible = continueWatchingPreferencesUiState.isVisible, + continueWatchingStyle = continueWatchingPreferencesUiState.style, ) } } @@ -101,6 +111,8 @@ private fun MobileSettingsScreen( heroEnabled: Boolean, homescreenSettings: List, showLoadingOverlay: Boolean, + continueWatchingVisible: Boolean, + continueWatchingStyle: ContinueWatchingSectionStyle, ) { NuvioScreen { stickyHeader { @@ -115,12 +127,22 @@ private fun MobileSettingsScreen( SettingsPage.Root -> settingsRootContent( isTablet = false, onPlaybackClick = { onPageChange(SettingsPage.Playback) }, + onAppearanceClick = { onPageChange(SettingsPage.Appearance) }, onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) }, ) SettingsPage.Playback -> playbackSettingsContent( isTablet = false, showLoadingOverlay = showLoadingOverlay, ) + SettingsPage.Appearance -> appearanceSettingsContent( + isTablet = false, + onContinueWatchingClick = { onPageChange(SettingsPage.ContinueWatching) }, + ) + SettingsPage.ContinueWatching -> continueWatchingSettingsContent( + isTablet = false, + isVisible = continueWatchingVisible, + style = continueWatchingStyle, + ) SettingsPage.ContentDiscovery -> contentDiscoveryContent( isTablet = false, onAddonsClick = { onPageChange(SettingsPage.Addons) }, @@ -143,6 +165,8 @@ private fun TabletSettingsScreen( heroEnabled: Boolean, homescreenSettings: List, showLoadingOverlay: Boolean, + continueWatchingVisible: Boolean, + continueWatchingStyle: ContinueWatchingSectionStyle, ) { var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) } val activeCategory = SettingsCategory.valueOf(selectedCategory) @@ -206,12 +230,22 @@ private fun TabletSettingsScreen( SettingsPage.Root -> settingsRootContent( isTablet = true, onPlaybackClick = { onPageChange(SettingsPage.Playback) }, + onAppearanceClick = { onPageChange(SettingsPage.Appearance) }, onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) }, ) SettingsPage.Playback -> playbackSettingsContent( isTablet = true, showLoadingOverlay = showLoadingOverlay, ) + SettingsPage.Appearance -> appearanceSettingsContent( + isTablet = true, + onContinueWatchingClick = { onPageChange(SettingsPage.ContinueWatching) }, + ) + SettingsPage.ContinueWatching -> continueWatchingSettingsContent( + isTablet = true, + isVisible = continueWatchingVisible, + style = continueWatchingStyle, + ) SettingsPage.ContentDiscovery -> contentDiscoveryContent( isTablet = true, onAddonsClick = { onPageChange(SettingsPage.Addons) }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt new file mode 100644 index 000000000..e9ef71161 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt @@ -0,0 +1,67 @@ +package com.nuvio.app.features.watchprogress + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +private data class StoredContinueWatchingPreferences( + val isVisible: Boolean = true, + val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide, +) + +object ContinueWatchingPreferencesRepository { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val _uiState = MutableStateFlow(ContinueWatchingPreferencesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var hasLoaded = false + + fun ensureLoaded() { + if (hasLoaded) return + hasLoaded = true + + val payload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim() + if (payload.isEmpty()) return + + val stored = runCatching { + json.decodeFromString(payload) + }.getOrNull() ?: return + + _uiState.value = ContinueWatchingPreferencesUiState( + isVisible = stored.isVisible, + style = stored.style, + ) + } + + fun setVisible(isVisible: Boolean) { + ensureLoaded() + _uiState.value = _uiState.value.copy(isVisible = isVisible) + persist() + } + + fun setStyle(style: ContinueWatchingSectionStyle) { + ensureLoaded() + _uiState.value = _uiState.value.copy(style = style) + persist() + } + + private fun persist() { + ContinueWatchingPreferencesStorage.savePayload( + json.encodeToString( + StoredContinueWatchingPreferences( + isVisible = _uiState.value.isVisible, + style = _uiState.value.style, + ), + ), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.kt new file mode 100644 index 000000000..55b55226c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.watchprogress + +internal expect object ContinueWatchingPreferencesStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index d78212ff4..73cf66ae1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -2,6 +2,12 @@ package com.nuvio.app.features.watchprogress import kotlinx.serialization.Serializable +@Serializable +enum class ContinueWatchingSectionStyle { + Wide, + Poster, +} + @Serializable data class WatchProgressEntry( val contentType: String, @@ -82,6 +88,11 @@ data class ContinueWatchingItem( val progressFraction: Float, ) +data class ContinueWatchingPreferencesUiState( + val isVisible: Boolean = true, + val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide, +) + internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { val subtitle = if (seasonNumber != null && episodeNumber != null) { buildString { diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.ios.kt new file mode 100644 index 000000000..c9b99e5ab --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.ios.kt @@ -0,0 +1,14 @@ +package com.nuvio.app.features.watchprogress + +import platform.Foundation.NSUserDefaults + +actual object ContinueWatchingPreferencesStorage { + private const val payloadKey = "continue_watching_preferences_payload" + + actual fun loadPayload(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(payloadKey) + + actual fun savePayload(payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = payloadKey) + } +}