From 3a277a5b990cdeb39d436c87748389a85632663e Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:14:22 +0530 Subject: [PATCH] feat: Add Watched feature with storage, repository, and UI components --- .../kotlin/com/nuvio/app/MainActivity.kt | 2 + .../features/watched/WatchedClock.android.kt | 6 + .../watched/WatchedStorage.android.kt | 26 ++ .../commonMain/kotlin/com/nuvio/app/App.kt | 48 ++++ .../app/core/ui/NuvioPosterActionSheet.kt | 259 ++++++++++++++++++ .../nuvio/app/core/ui/NuvioShelfComponents.kt | 7 + .../com/nuvio/app/features/home/HomeScreen.kt | 6 + .../home/components/HomeCatalogSection.kt | 4 + .../home/components/HomePosterCard.kt | 2 + .../app/features/library/LibraryModels.kt | 16 ++ .../features/search/SearchDiscoverContent.kt | 32 ++- .../nuvio/app/features/search/SearchScreen.kt | 8 + .../app/features/watched/WatchedClock.kt | 6 + .../app/features/watched/WatchedModels.kt | 33 +++ .../app/features/watched/WatchedRepository.kt | 93 +++++++ .../app/features/watched/WatchedStorage.kt | 7 + .../features/watched/WatchedRepositoryTest.kt | 17 ++ .../app/features/watched/WatchedClock.ios.kt | 10 + .../features/watched/WatchedStorage.ios.kt | 15 + 19 files changed, 596 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/watched/WatchedClock.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedClock.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/watched/WatchedClock.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 41b9b5695..9734a2387 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watched/WatchedClock.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watched/WatchedClock.android.kt new file mode 100644 index 000000000..a7547d687 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watched/WatchedClock.android.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.watched + +actual object WatchedClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() +} + diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.android.kt new file mode 100644 index 000000000..37d2d7891 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.android.kt @@ -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() + } +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 23f51883d..62265ca3c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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(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)) + } + }, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt new file mode 100644 index 000000000..46ffdac0c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt @@ -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)) + } +} 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 6b67a4418..6b3fc57d2 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 @@ -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, 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 e6f078c3d..ff9bb9798 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.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, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt index 2ac167f5a..e98b405f9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt @@ -13,8 +13,10 @@ fun HomeCatalogRowSection( section: HomeCatalogSection, modifier: Modifier = Modifier, entries: List = section.items, + watchedKeys: Set = 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) } }, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt index 5f87ccd0e..863ba7a1a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt @@ -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, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt index 9e5726eea..2d0414237 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index b38122291..38bb0d2fa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -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 = 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, modifier: Modifier = Modifier, + watchedKeys: Set = 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index 0e4ed475f..7fc45d679 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -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, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedClock.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedClock.kt new file mode 100644 index 000000000..7ba77aced --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedClock.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.watched + +expect object WatchedClock { + fun nowEpochMs(): Long +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt new file mode 100644 index 000000000..be85279cc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt @@ -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 = emptyList(), + val watchedKeys: Set = 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()}" + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt new file mode 100644 index 000000000..aac4e4452 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -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 = emptyList(), +) + +object WatchedRepository { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val _uiState = MutableStateFlow(WatchedUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var hasLoaded = false + private var itemsByKey: MutableMap = mutableMapOf() + + fun ensureLoaded() { + if (hasLoaded) return + hasLoaded = true + + val payload = WatchedStorage.loadPayload().orEmpty().trim() + if (payload.isNotEmpty()) { + val items = runCatching { + json.decodeFromString(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 }, + ), + ), + ) + } +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.kt new file mode 100644 index 000000000..0eb37da7b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.features.watched + +expect object WatchedStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} + diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt new file mode 100644 index 000000000..4ff957454 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt @@ -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 ")) + } +} + diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watched/WatchedClock.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watched/WatchedClock.ios.kt new file mode 100644 index 000000000..993fc79b2 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watched/WatchedClock.ios.kt @@ -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 +} + diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.ios.kt new file mode 100644 index 000000000..71c6d965c --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.ios.kt @@ -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) + } +} +