mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
feat: Add Watched feature with storage, repository, and UI components
This commit is contained in:
parent
7f7f7e2bae
commit
3a277a5b99
19 changed files with 596 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package com.nuvio.app.features.watched
|
||||
|
||||
actual object WatchedClock {
|
||||
actual fun nowEpochMs(): Long = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package com.nuvio.app.features.watched
|
||||
|
||||
expect object WatchedClock {
|
||||
fun nowEpochMs(): Long
|
||||
}
|
||||
|
||||
|
|
@ -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()}"
|
||||
|
||||
|
|
@ -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 },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.nuvio.app.features.watched
|
||||
|
||||
expect object WatchedStorage {
|
||||
fun loadPayload(): String?
|
||||
fun savePayload(payload: String)
|
||||
}
|
||||
|
||||
|
|
@ -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 "))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in a new issue