feat: implement continue watching enrichment storage and caching logic

This commit is contained in:
tapframe 2026-04-06 21:08:36 +05:30
parent 805ad6c93e
commit 810fe1b253
6 changed files with 254 additions and 17 deletions

View file

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

View file

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

View file

@ -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<Map<String, Pair<Long, ContinueWatchingItem>>>(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<String, Pair<Long, ContinueWatchingItem>>()
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,
)
}

View file

@ -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<CachedNextUpItem> = emptyList(),
val inProgress: List<CachedInProgressItem> = emptyList(),
)
internal object ContinueWatchingEnrichmentCache {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private const val storageKey = "cw_enrichment_cache"
fun getNextUpSnapshot(): List<CachedNextUpItem> =
loadPayload()?.nextUp ?: emptyList()
fun getInProgressSnapshot(): List<CachedInProgressItem> =
loadPayload()?.inProgress ?: emptyList()
fun getSnapshots(): Pair<List<CachedNextUpItem>, List<CachedInProgressItem>> {
val payload = loadPayload()
return (payload?.nextUp ?: emptyList()) to (payload?.inProgress ?: emptyList())
}
fun saveSnapshots(
nextUp: List<CachedNextUpItem>,
inProgress: List<CachedInProgressItem>,
) {
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<CachedEnrichmentPayload>(raw)
}.getOrNull()
}
}

View file

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

View file

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