feat: Add Watched feature with storage, repository, and UI components

This commit is contained in:
tapframe 2026-03-28 18:14:22 +05:30
parent 7f7f7e2bae
commit 3a277a5b99
19 changed files with 596 additions and 1 deletions

View file

@ -8,6 +8,7 @@ import com.nuvio.app.features.addons.AddonStorage
import com.nuvio.app.features.library.LibraryStorage
import com.nuvio.app.features.home.HomeCatalogSettingsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage
import com.nuvio.app.features.watched.WatchedStorage
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
import com.nuvio.app.features.watchprogress.WatchProgressStorage
@ -17,6 +18,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
AddonStorage.initialize(applicationContext)
LibraryStorage.initialize(applicationContext)
WatchedStorage.initialize(applicationContext)
HomeCatalogSettingsStorage.initialize(applicationContext)
PlayerSettingsStorage.initialize(applicationContext)
ContinueWatchingPreferencesStorage.initialize(applicationContext)

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.watched
actual object WatchedClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis()
}

View file

@ -0,0 +1,26 @@
package com.nuvio.app.features.watched
import android.content.Context
import android.content.SharedPreferences
actual object WatchedStorage {
private const val preferencesName = "nuvio_watched"
private const val payloadKey = "watched_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

@ -10,6 +10,7 @@ import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.VideoLibrary
import androidx.compose.runtime.remember
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -23,8 +24,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@ -33,6 +37,7 @@ import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory
import coil3.request.crossfade
import com.nuvio.app.core.ui.nuvioBottomNavigationBarInsets
import com.nuvio.app.core.ui.NuvioPosterActionSheet
import com.nuvio.app.core.ui.NuvioTheme
import com.nuvio.app.features.catalog.CatalogRepository
import com.nuvio.app.features.catalog.CatalogScreen
@ -42,13 +47,18 @@ import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.HomeScreen
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.library.LibraryItem
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.LibraryScreen
import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.player.PlayerRoute
import com.nuvio.app.features.player.PlayerScreen
import com.nuvio.app.features.search.SearchScreen
import com.nuvio.app.features.settings.SettingsScreen
import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.streams.StreamsScreen
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watched.toWatchedItem
import com.nuvio.app.features.watched.watchedItemKey
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import kotlinx.serialization.Serializable
@ -99,6 +109,7 @@ fun AppScreen(
modifier: Modifier = Modifier,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
onLibraryPosterClick: ((LibraryItem) -> Unit)? = null,
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null,
onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null,
@ -108,12 +119,14 @@ fun AppScreen(
modifier = modifier,
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
)
AppScreenTab.Search -> SearchScreen(
modifier = modifier,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
)
AppScreenTab.Library -> LibraryScreen(
modifier = modifier,
@ -136,7 +149,17 @@ fun App() {
}
NuvioTheme {
val navController = rememberNavController()
val hapticFeedback = LocalHapticFeedback.current
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
var selectedPosterForActions by remember { mutableStateOf<MetaPreview?>(null) }
val libraryUiState by remember {
LibraryRepository.ensureLoaded()
LibraryRepository.uiState
}.collectAsStateWithLifecycle()
val watchedUiState by remember {
WatchedRepository.ensureLoaded()
WatchedRepository.uiState
}.collectAsStateWithLifecycle()
val onPlay: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, Long?) -> Unit =
{ type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, resumePositionMs ->
@ -262,6 +285,10 @@ fun App() {
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
},
onPosterLongClick = { meta ->
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
selectedPosterForActions = meta
},
onLibraryPosterClick = { item ->
navController.navigate(DetailRoute(type = item.type, id = item.id))
},
@ -387,6 +414,27 @@ fun App() {
)
}
}
NuvioPosterActionSheet(
item = selectedPosterForActions,
isSaved = selectedPosterForActions?.let { preview ->
LibraryRepository.isSaved(preview.id)
} == true,
isWatched = selectedPosterForActions?.let { preview ->
watchedUiState.watchedKeys.contains(watchedItemKey(preview.type, preview.id))
} == true,
onDismiss = { selectedPosterForActions = null },
onToggleLibrary = {
selectedPosterForActions?.let { preview ->
LibraryRepository.toggleSaved(preview.toLibraryItem(savedAtEpochMs = 0L))
}
},
onToggleWatched = {
selectedPosterForActions?.let { preview ->
WatchedRepository.toggleWatched(preview.toWatchedItem(markedAtEpochMs = 0L))
}
},
)
}
}
}

View file

@ -0,0 +1,259 @@
package com.nuvio.app.core.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
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.Row
import androidx.compose.foundation.layout.Spacer
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.CheckCircleOutline
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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 coil3.compose.AsyncImage
import com.nuvio.app.features.home.MetaPreview
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NuvioPosterActionSheet(
item: MetaPreview?,
isSaved: Boolean,
isWatched: Boolean,
onDismiss: () -> Unit,
onToggleLibrary: () -> Unit,
onToggleWatched: () -> Unit,
) {
if (item == null) return
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
ModalBottomSheet(
onDismissRequest = {
coroutineScope.launch {
dismissPosterActionSheet(
sheetState = sheetState,
onDismiss = onDismiss,
)
}
},
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
dragHandle = {
Box(
modifier = Modifier
.padding(top = 10.dp, bottom = 6.dp)
.size(width = 54.dp, height = 5.dp)
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.65f)),
)
},
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp + nuvioPlatformExtraBottomPadding),
) {
PosterSheetHeader(item = item)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f))
PosterSheetActionRow(
icon = if (isSaved) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
title = if (isSaved) "Remove from Library" else "Add to Library",
onClick = {
onToggleLibrary()
coroutineScope.launch {
dismissPosterActionSheet(
sheetState = sheetState,
onDismiss = onDismiss,
)
}
},
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f))
PosterSheetActionRow(
icon = if (isWatched) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline,
title = if (isWatched) "Mark as Unwatched" else "Mark as Watched",
onClick = {
onToggleWatched()
coroutineScope.launch {
dismissPosterActionSheet(
sheetState = sheetState,
onDismiss = onDismiss,
)
}
},
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
private suspend fun dismissPosterActionSheet(
sheetState: SheetState,
onDismiss: () -> Unit,
) {
if (sheetState.isVisible) {
sheetState.hide()
}
onDismiss()
}
@Composable
fun NuvioWatchedBadge(
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.size(22.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Watched",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(12.dp),
)
}
}
@Composable
fun NuvioAnimatedWatchedBadge(
isVisible: Boolean,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = isVisible,
enter = fadeIn(),
exit = fadeOut(),
modifier = modifier,
) {
NuvioWatchedBadge()
}
}
@Composable
private fun PosterSheetHeader(
item: MetaPreview,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(width = 64.dp, height = 92.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (item.poster != null) {
AsyncImage(
model = item.poster,
contentDescription = item.name,
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop,
)
} else {
Text(
text = item.name,
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = item.name,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = item.releaseInfo?.takeIf { it.isNotBlank() }
?: item.type.replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase() else char.toString()
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun PosterSheetActionRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Column {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.weight(1f))
}
}

View file

@ -94,6 +94,7 @@ fun NuvioPosterCard(
modifier: Modifier = Modifier,
shape: NuvioPosterShape = NuvioPosterShape.Poster,
detailLine: String? = null,
isWatched: Boolean = false,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
) {
@ -135,6 +136,12 @@ fun NuvioPosterCard(
overflow = TextOverflow.Ellipsis,
)
}
NuvioAnimatedWatchedBadge(
isVisible = isWatched,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp),
)
}
Text(
text = title,

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.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.WatchProgressRepository
@ -25,18 +26,21 @@ fun HomeScreen(
modifier: Modifier = Modifier,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null,
onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null,
) {
LaunchedEffect(Unit) {
AddonRepository.initialize()
ContinueWatchingPreferencesRepository.ensureLoaded()
WatchedRepository.ensureLoaded()
WatchProgressRepository.ensureLoaded()
}
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val homeUiState by HomeRepository.uiState.collectAsStateWithLifecycle()
val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
val continueWatchingItems = remember(watchProgressUiState.entries) {
watchProgressUiState.entries.take(20).map { it.toContinueWatchingItem() }
@ -151,7 +155,9 @@ fun HomeScreen(
} else {
null
},
watchedKeys = watchedUiState.watchedKeys,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
)
}
}

View file

@ -13,8 +13,10 @@ fun HomeCatalogRowSection(
section: HomeCatalogSection,
modifier: Modifier = Modifier,
entries: List<MetaPreview> = section.items,
watchedKeys: Set<String> = emptySet(),
onViewAllClick: (() -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
) {
NuvioShelfSection(
title = section.title,
@ -28,7 +30,9 @@ fun HomeCatalogRowSection(
) { item ->
HomePosterCard(
item = item,
isWatched = watchedKeys.contains("${item.type}:${item.id}"),
onClick = onPosterClick?.let { { it(item) } },
onLongClick = onPosterLongClick?.let { { it(item) } },
)
}
}

View file

@ -11,6 +11,7 @@ import com.nuvio.app.features.home.PosterShape
fun HomePosterCard(
item: MetaPreview,
modifier: Modifier = Modifier,
isWatched: Boolean = false,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
) {
@ -20,6 +21,7 @@ fun HomePosterCard(
modifier = modifier,
shape = item.posterShape.toNuvioPosterShape(),
detailLine = item.releaseInfo,
isWatched = isWatched,
onClick = onClick,
onLongClick = onLongClick,
)

View file

@ -49,6 +49,22 @@ fun MetaDetails.toLibraryItem(savedAtEpochMs: Long): LibraryItem =
savedAtEpochMs = savedAtEpochMs,
)
fun MetaPreview.toLibraryItem(savedAtEpochMs: Long): LibraryItem =
LibraryItem(
id = id,
type = type,
name = name,
poster = poster,
banner = banner,
logo = logo,
description = description,
releaseInfo = releaseInfo,
imdbRating = imdbRating,
genres = genres,
posterShape = posterShape,
savedAtEpochMs = savedAtEpochMs,
)
fun LibraryItem.toMetaPreview(): MetaPreview =
MetaPreview(
id = id,

View file

@ -1,7 +1,9 @@
package com.nuvio.app.features.search
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -40,16 +42,20 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.home.components.HomeEmptyStateCard
import com.nuvio.app.features.watched.watchedItemKey
internal fun LazyListScope.discoverContent(
state: DiscoverUiState,
onTypeSelected: (String) -> Unit,
onCatalogSelected: (String) -> Unit,
onGenreSelected: (String?) -> Unit,
watchedKeys: Set<String> = emptySet(),
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
) {
item {
DiscoverSectionHeader(modifier = Modifier.padding(horizontal = 16.dp))
@ -99,7 +105,9 @@ internal fun LazyListScope.discoverContent(
DiscoverGridRow(
items = rowItems,
modifier = Modifier.padding(horizontal = 16.dp),
watchedKeys = watchedKeys,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
)
}
if (state.isLoading) {
@ -226,7 +234,9 @@ private fun DiscoverDropdownChip(
private fun DiscoverGridRow(
items: List<MetaPreview>,
modifier: Modifier = Modifier,
watchedKeys: Set<String> = emptySet(),
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
) {
Row(
modifier = modifier.fillMaxWidth(),
@ -237,7 +247,9 @@ private fun DiscoverGridRow(
DiscoverPosterTile(
item = item,
modifier = Modifier.weight(1f),
isWatched = watchedKeys.contains(watchedItemKey(item.type, item.id)),
onClick = onPosterClick?.let { { it(item) } },
onLongClick = onPosterLongClick?.let { { it(item) } },
)
}
repeat(3 - items.size) {
@ -246,14 +258,26 @@ private fun DiscoverGridRow(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DiscoverPosterTile(
item: MetaPreview,
modifier: Modifier = Modifier,
isWatched: Boolean = false,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
) {
Column(
modifier = modifier.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
modifier = modifier.then(
if (onClick != null || onLongClick != null) {
Modifier.combinedClickable(
onClick = { onClick?.invoke() },
onLongClick = onLongClick,
)
} else {
Modifier
},
),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
@ -271,6 +295,12 @@ private fun DiscoverPosterTile(
contentScale = ContentScale.Crop,
)
}
NuvioAnimatedWatchedBadge(
isVisible = isWatched,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp),
)
}
Text(
text = item.name,

View file

@ -36,6 +36,7 @@ import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.components.HomeCatalogRowSection
import com.nuvio.app.features.home.components.HomeEmptyStateCard
import com.nuvio.app.features.home.components.HomeSkeletonRow
import com.nuvio.app.features.watched.WatchedRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
@ -45,14 +46,17 @@ import kotlinx.coroutines.flow.map
fun SearchScreen(
modifier: Modifier = Modifier,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
) {
LaunchedEffect(Unit) {
AddonRepository.initialize()
WatchedRepository.ensureLoaded()
}
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val uiState by SearchRepository.uiState.collectAsStateWithLifecycle()
val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle()
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
var query by rememberSaveable { mutableStateOf("") }
var headerHeightPx by remember { mutableIntStateOf(0) }
val listState = rememberLazyListState()
@ -146,7 +150,9 @@ fun SearchScreen(
onTypeSelected = SearchRepository::selectDiscoverType,
onCatalogSelected = SearchRepository::selectDiscoverCatalog,
onGenreSelected = SearchRepository::selectDiscoverGenre,
watchedKeys = watchedUiState.watchedKeys,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
)
} else {
when {
@ -174,7 +180,9 @@ fun SearchScreen(
HomeCatalogRowSection(
section = section,
modifier = Modifier.padding(bottom = 12.dp),
watchedKeys = watchedUiState.watchedKeys,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
)
}
}

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.watched
expect object WatchedClock {
fun nowEpochMs(): Long
}

View file

@ -0,0 +1,33 @@
package com.nuvio.app.features.watched
import com.nuvio.app.features.home.MetaPreview
import kotlinx.serialization.Serializable
@Serializable
data class WatchedItem(
val id: String,
val type: String,
val name: String,
val poster: String? = null,
val releaseInfo: String? = null,
val markedAtEpochMs: Long,
)
data class WatchedUiState(
val items: List<WatchedItem> = emptyList(),
val watchedKeys: Set<String> = emptySet(),
val isLoaded: Boolean = false,
)
fun MetaPreview.toWatchedItem(markedAtEpochMs: Long): WatchedItem =
WatchedItem(
id = id,
type = type,
name = name,
poster = poster,
releaseInfo = releaseInfo,
markedAtEpochMs = markedAtEpochMs,
)
fun watchedItemKey(type: String, id: String): String = "${type.trim()}:${id.trim()}"

View file

@ -0,0 +1,93 @@
package com.nuvio.app.features.watched
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 StoredWatchedPayload(
val items: List<WatchedItem> = emptyList(),
)
object WatchedRepository {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val _uiState = MutableStateFlow(WatchedUiState())
val uiState: StateFlow<WatchedUiState> = _uiState.asStateFlow()
private var hasLoaded = false
private var itemsByKey: MutableMap<String, WatchedItem> = mutableMapOf()
fun ensureLoaded() {
if (hasLoaded) return
hasLoaded = true
val payload = WatchedStorage.loadPayload().orEmpty().trim()
if (payload.isNotEmpty()) {
val items = runCatching {
json.decodeFromString<StoredWatchedPayload>(payload).items
}.getOrDefault(emptyList())
itemsByKey = items.associateBy { watchedItemKey(it.type, it.id) }.toMutableMap()
}
publish()
}
fun toggleWatched(item: WatchedItem) {
ensureLoaded()
val key = watchedItemKey(item.type, item.id)
if (itemsByKey.containsKey(key)) {
unmarkWatched(item.id, item.type)
} else {
markWatched(item)
}
}
fun markWatched(item: WatchedItem) {
ensureLoaded()
val key = watchedItemKey(item.type, item.id)
itemsByKey[key] = item.copy(markedAtEpochMs = WatchedClock.nowEpochMs())
publish()
persist()
}
fun unmarkWatched(id: String, type: String) {
ensureLoaded()
if (itemsByKey.remove(watchedItemKey(type, id)) != null) {
publish()
persist()
}
}
fun isWatched(id: String, type: String): Boolean {
ensureLoaded()
return itemsByKey.containsKey(watchedItemKey(type, id))
}
private fun publish() {
val items = itemsByKey.values.sortedByDescending { it.markedAtEpochMs }
_uiState.value = WatchedUiState(
items = items,
watchedKeys = items.mapTo(linkedSetOf()) { watchedItemKey(it.type, it.id) },
isLoaded = true,
)
}
private fun persist() {
WatchedStorage.savePayload(
json.encodeToString(
StoredWatchedPayload(
items = itemsByKey.values.sortedByDescending { it.markedAtEpochMs },
),
),
)
}
}

View file

@ -0,0 +1,7 @@
package com.nuvio.app.features.watched
expect object WatchedStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

@ -0,0 +1,17 @@
package com.nuvio.app.features.watched
import kotlin.test.Test
import kotlin.test.assertEquals
class WatchedRepositoryTest {
@Test
fun watchedItemKey_isTypeAware() {
assertEquals("movie:tt1", watchedItemKey(type = "movie", id = "tt1"))
}
@Test
fun watchedItemKey_trimsValues() {
assertEquals("series:abc", watchedItemKey(type = " series ", id = " abc "))
}
}

View file

@ -0,0 +1,10 @@
package com.nuvio.app.features.watched
import kotlinx.cinterop.ExperimentalForeignApi
import platform.posix.time
actual object WatchedClock {
@OptIn(ExperimentalForeignApi::class)
actual fun nowEpochMs(): Long = time(null) * 1000L
}

View file

@ -0,0 +1,15 @@
package com.nuvio.app.features.watched
import platform.Foundation.NSUserDefaults
actual object WatchedStorage {
private const val payloadKey = "watched_payload"
actual fun loadPayload(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(payloadKey)
actual fun savePayload(payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = payloadKey)
}
}