From 810fe1b2536518bf3a8a9058ba5ea2361e64483f Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:08:36 +0530 Subject: [PATCH] feat: implement continue watching enrichment storage and caching logic --- .../kotlin/com/nuvio/app/MainActivity.kt | 2 + ...ntinueWatchingEnrichmentStorage.android.kt | 24 +++ .../com/nuvio/app/features/home/HomeScreen.kt | 139 +++++++++++++++--- .../ContinueWatchingEnrichmentCache.kt | 88 +++++++++++ .../ContinueWatchingEnrichmentStorage.kt | 6 + .../ContinueWatchingEnrichmentStorage.ios.kt | 12 ++ 6 files changed, 254 insertions(+), 17 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.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 a2ad56a9..1db04fab 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -29,6 +29,7 @@ import com.nuvio.app.features.trakt.TraktCommentsStorage import com.nuvio.app.features.tmdb.TmdbSettingsStorage import com.nuvio.app.features.watched.WatchedStorage import com.nuvio.app.features.streams.StreamLinkCacheStorage +import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentStorage import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage import com.nuvio.app.features.watchprogress.WatchProgressStorage @@ -58,6 +59,7 @@ class MainActivity : ComponentActivity() { TraktAuthStorage.initialize(applicationContext) TraktCommentsStorage.initialize(applicationContext) ContinueWatchingPreferencesStorage.initialize(applicationContext) + ContinueWatchingEnrichmentStorage.initialize(applicationContext) EpisodeReleaseNotificationsStorage.initialize(applicationContext) WatchProgressStorage.initialize(applicationContext) StreamLinkCacheStorage.initialize(applicationContext) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.android.kt new file mode 100644 index 00000000..0fc4827d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.android.kt @@ -0,0 +1,24 @@ +package com.nuvio.app.features.watchprogress + +import android.content.Context +import android.content.SharedPreferences + +actual object ContinueWatchingEnrichmentStorage { + private const val preferencesName = "nuvio_cw_enrichment" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadPayload(key: String): String? = + preferences?.getString(key, null) + + actual fun savePayload(key: String, payload: String) { + preferences + ?.edit() + ?.putString(key, payload) + ?.apply() + } +} 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 e7da572f..90d31c00 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 @@ -23,6 +23,9 @@ import com.nuvio.app.features.home.components.HomeSkeletonHero import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.watched.WatchedRepository +import com.nuvio.app.features.watchprogress.CachedInProgressItem +import com.nuvio.app.features.watchprogress.CachedNextUpItem +import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem @@ -35,6 +38,10 @@ import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.buildPlaybackVideoId import com.nuvio.app.features.watching.domain.isReleasedBy +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit @Composable fun HomeScreen( @@ -104,13 +111,26 @@ fun HomeScreen( ) } var nextUpItemsBySeries by remember { mutableStateOf>>(emptyMap()) } + + val cachedSnapshots = remember { ContinueWatchingEnrichmentCache.getSnapshots() } + val cachedNextUpItems = remember(cachedSnapshots.first) { + cachedSnapshots.first.mapNotNull { cached -> + val item = cached.toContinueWatchingItem() ?: return@mapNotNull null + cached.contentId to (cached.sortTimestamp to item) + }.toMap() + } + + val effectivNextUpItems = remember(nextUpItemsBySeries, cachedNextUpItems) { + if (nextUpItemsBySeries.isNotEmpty()) nextUpItemsBySeries else cachedNextUpItems + } + val continueWatchingItems = remember( visibleContinueWatchingEntries, - nextUpItemsBySeries, + effectivNextUpItems, ) { buildHomeContinueWatchingItems( visibleEntries = visibleContinueWatchingEntries, - nextUpItemsBySeries = nextUpItemsBySeries, + nextUpItemsBySeries = effectivNextUpItems, ) } val allManifestsSettled = addonsUiState.addons.isNotEmpty() && @@ -156,22 +176,71 @@ fun HomeScreen( if (metaProviderKey.isEmpty()) return@LaunchedEffect val todayIsoDate = CurrentDateProvider.todayIsoDate() - val resolvedItems = mutableMapOf>() - completedSeriesCandidates.forEach { completedEntry -> - val meta = MetaDetailsRepository.fetch( - type = completedEntry.content.type, - id = completedEntry.content.id, - ) ?: return@forEach - val nextEpisode = meta.nextReleasedEpisodeAfter( - seasonNumber = completedEntry.seasonNumber, - episodeNumber = completedEntry.episodeNumber, - todayIsoDate = todayIsoDate, - showUnairedNextUp = isTraktAuthenticated, - ) ?: return@forEach - resolvedItems[completedEntry.content.id] = - completedEntry.markedAtEpochMs to completedEntry.toContinueWatchingSeed(meta).toUpNextContinueWatchingItem(nextEpisode) + val semaphore = Semaphore(4) + val results = completedSeriesCandidates.map { completedEntry -> + async { + semaphore.withPermit { + val meta = MetaDetailsRepository.fetch( + type = completedEntry.content.type, + id = completedEntry.content.id, + ) ?: return@withPermit null + val nextEpisode = meta.nextReleasedEpisodeAfter( + seasonNumber = completedEntry.seasonNumber, + episodeNumber = completedEntry.episodeNumber, + todayIsoDate = todayIsoDate, + showUnairedNextUp = isTraktAuthenticated, + ) ?: return@withPermit null + val item = completedEntry.toContinueWatchingSeed(meta) + .toUpNextContinueWatchingItem(nextEpisode) + completedEntry.content.id to (completedEntry.markedAtEpochMs to item) + } + } + }.awaitAll().filterNotNull().toMap() + nextUpItemsBySeries = results + + val nextUpCache = results.mapNotNull { (contentId, pair) -> + val item = pair.second + CachedNextUpItem( + contentId = contentId, + contentType = item.parentMetaType, + name = item.title, + poster = item.poster, + backdrop = item.background, + logo = item.logo, + videoId = item.videoId, + season = item.seasonNumber, + episode = item.episodeNumber, + episodeTitle = item.episodeTitle, + episodeThumbnail = item.episodeThumbnail, + pauseDescription = item.pauseDescription, + lastWatched = pair.first, + sortTimestamp = pair.first, + ) } - nextUpItemsBySeries = resolvedItems + val inProgressCache = visibleContinueWatchingEntries.map { entry -> + CachedInProgressItem( + contentId = entry.parentMetaId, + contentType = entry.contentType, + name = entry.title, + poster = entry.poster, + backdrop = entry.background, + logo = entry.logo, + videoId = entry.videoId, + season = entry.seasonNumber, + episode = entry.episodeNumber, + episodeTitle = entry.episodeTitle, + episodeThumbnail = entry.episodeThumbnail, + pauseDescription = entry.pauseDescription, + position = entry.lastPositionMs, + duration = entry.durationMs, + lastWatched = entry.lastUpdatedEpochMs, + progressPercent = entry.progressPercent, + ) + } + ContinueWatchingEnrichmentCache.saveSnapshots( + nextUp = nextUpCache, + inProgress = inProgressCache, + ) } val hasActiveAddons = addonsUiState.addons.any { it.manifest != null } @@ -397,3 +466,39 @@ private fun com.nuvio.app.features.details.MetaDetails.nextReleasedEpisodeAfter( isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.released) } } + +private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { + val subtitle = buildString { + append("Up Next") + if (season != null && episode != null) { + append(" • S") + append(season) + append("E") + append(episode) + } + episodeTitle?.takeIf { it.isNotBlank() }?.let { + append(" • ") + append(it) + } + } + return ContinueWatchingItem( + parentMetaId = contentId, + parentMetaType = contentType, + videoId = videoId, + title = name, + subtitle = subtitle, + imageUrl = episodeThumbnail ?: backdrop ?: poster, + logo = logo, + poster = poster, + background = backdrop, + seasonNumber = season, + episodeNumber = episode, + episodeTitle = episodeTitle, + episodeThumbnail = episodeThumbnail, + pauseDescription = pauseDescription, + resumePositionMs = 0L, + resumeProgressFraction = null, + durationMs = 0L, + progressFraction = 0f, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt new file mode 100644 index 00000000..38276cea --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt @@ -0,0 +1,88 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.core.storage.ProfileScopedKey +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +data class CachedNextUpItem( + val contentId: String, + val contentType: String, + val name: String, + val poster: String? = null, + val backdrop: String? = null, + val logo: String? = null, + val videoId: String, + val season: Int? = null, + val episode: Int? = null, + val episodeTitle: String? = null, + val episodeThumbnail: String? = null, + val pauseDescription: String? = null, + val lastWatched: Long, + val sortTimestamp: Long, +) + +@Serializable +data class CachedInProgressItem( + val contentId: String, + val contentType: String, + val name: String, + val poster: String? = null, + val backdrop: String? = null, + val logo: String? = null, + val videoId: String, + val season: Int? = null, + val episode: Int? = null, + val episodeTitle: String? = null, + val episodeThumbnail: String? = null, + val pauseDescription: String? = null, + val position: Long, + val duration: Long, + val lastWatched: Long, + val progressPercent: Float? = null, +) + +@Serializable +private data class CachedEnrichmentPayload( + val nextUp: List = emptyList(), + val inProgress: List = emptyList(), +) + +internal object ContinueWatchingEnrichmentCache { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private const val storageKey = "cw_enrichment_cache" + + fun getNextUpSnapshot(): List = + loadPayload()?.nextUp ?: emptyList() + + fun getInProgressSnapshot(): List = + loadPayload()?.inProgress ?: emptyList() + + fun getSnapshots(): Pair, List> { + val payload = loadPayload() + return (payload?.nextUp ?: emptyList()) to (payload?.inProgress ?: emptyList()) + } + + fun saveSnapshots( + nextUp: List, + inProgress: List, + ) { + val encoded = runCatching { + json.encodeToString(CachedEnrichmentPayload(nextUp = nextUp, inProgress = inProgress)) + }.getOrNull() ?: return + ContinueWatchingEnrichmentStorage.savePayload(ProfileScopedKey.of(storageKey), encoded) + } + + private fun loadPayload(): CachedEnrichmentPayload? { + val raw = ContinueWatchingEnrichmentStorage.loadPayload(ProfileScopedKey.of(storageKey)) + ?: return null + return runCatching { + json.decodeFromString(raw) + }.getOrNull() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.kt new file mode 100644 index 00000000..24d6791d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.watchprogress + +internal expect object ContinueWatchingEnrichmentStorage { + fun loadPayload(key: String): String? + fun savePayload(key: String, payload: String) +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.ios.kt new file mode 100644 index 00000000..7ce272e4 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.ios.kt @@ -0,0 +1,12 @@ +package com.nuvio.app.features.watchprogress + +import platform.Foundation.NSUserDefaults + +actual object ContinueWatchingEnrichmentStorage { + actual fun loadPayload(key: String): String? = + NSUserDefaults.standardUserDefaults.stringForKey(key) + + actual fun savePayload(key: String, payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = key) + } +}