feat: Add Continue Watching feature with customizable settings

- Introduced ContinueWatchingPreferencesStorage for managing user preferences.
- Added ContinueWatchingPreferencesRepository to handle loading and saving preferences.
- Created UI components for Continue Watching settings, including visibility toggle and style selection.
- Implemented new settings pages for Appearance and Continue Watching.
- Updated MainActivity to initialize ContinueWatchingPreferencesStorage.
- Enhanced HomeScreen to display Continue Watching section based on user preferences.
- Refactored NuvioShelfSection to allow customizable item spacing.
- Added new styles for Continue Watching cards: Wide and Poster.
This commit is contained in:
tapframe 2026-03-28 17:41:28 +05:30
parent 2e7da22c25
commit bb00cde10a
14 changed files with 820 additions and 60 deletions

View file

@ -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 {

View file

@ -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()
}
}

View file

@ -49,6 +49,7 @@ fun <T> 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 <T> NuvioShelfSection(
)
LazyRow(
contentPadding = rowContentPadding,
horizontalArrangement = Arrangement.spacedBy(10.dp),
horizontalArrangement = Arrangement.spacedBy(itemSpacing),
) {
if (key != null) {
items(

View file

@ -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,

View file

@ -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<ContinueWatchingItem>,
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,
)
}

View file

@ -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,
)
}
}
}

View file

@ -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,
)
}
}
}

View file

@ -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

View file

@ -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.",

View file

@ -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<HomeCatalogSettingsItem>,
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<HomeCatalogSettingsItem>,
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) },

View file

@ -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<ContinueWatchingPreferencesUiState> = _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<StoredContinueWatchingPreferences>(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,
),
),
)
}
}

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.watchprogress
internal expect object ContinueWatchingPreferencesStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

@ -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 {

View file

@ -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)
}
}