feat: Implement CurrentDateProvider for date handling across platforms and enhance watch progress features

This commit is contained in:
tapframe 2026-03-28 18:35:30 +05:30
parent 3a277a5b99
commit 7a263e5181
12 changed files with 348 additions and 83 deletions

View file

@ -0,0 +1,8 @@
package com.nuvio.app.features.watchprogress
import java.time.LocalDate
actual object CurrentDateProvider {
actual fun todayIsoDate(): String = LocalDate.now().toString()
}

View file

@ -18,12 +18,19 @@ object MetaDetailsRepository {
private val _uiState = MutableStateFlow(MetaDetailsUiState())
val uiState: StateFlow<MetaDetailsUiState> = _uiState.asStateFlow()
private var activeRequestKey: String? = null
private val cachedMetaByRequestKey = mutableMapOf<String, MetaDetails>()
fun load(type: String, id: String) {
log.d { "load() called — type=$type id=$id" }
val requestKey = "$type:$id"
val currentState = _uiState.value
cachedMetaByRequestKey[requestKey]?.let { cachedMeta ->
_uiState.value = MetaDetailsUiState(meta = cachedMeta)
activeRequestKey = requestKey
return
}
if (currentState.meta?.type == type && currentState.meta.id == id && !currentState.isLoading) {
log.d { "Skipping reload for cached meta — type=$type id=$id" }
activeRequestKey = requestKey
@ -61,6 +68,7 @@ object MetaDetailsRepository {
for (manifest in manifests) {
val result = tryFetchMeta(manifest, type, id)
if (result != null) {
cachedMetaByRequestKey[requestKey] = result
_uiState.value = MetaDetailsUiState(meta = result)
activeRequestKey = requestKey
return@launch
@ -79,6 +87,31 @@ object MetaDetailsRepository {
_uiState.value = MetaDetailsUiState()
}
suspend fun fetch(type: String, id: String): MetaDetails? {
val requestKey = "$type:$id"
cachedMetaByRequestKey[requestKey]?.let { return it }
val manifests = AddonRepository.uiState.value.addons
.mapNotNull { it.manifest }
.filter { manifest ->
manifest.resources.any { resource ->
resource.name == "meta" &&
resource.types.contains(type) &&
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) })
}
}
for (manifest in manifests) {
val result = tryFetchMeta(manifest, type, id)
if (result != null) {
cachedMetaByRequestKey[requestKey] = result
return result
}
}
return null
}
private suspend fun tryFetchMeta(
manifest: AddonManifest,
type: String,

View file

@ -45,7 +45,7 @@ import com.nuvio.app.features.details.components.DetailMetaInfo
import com.nuvio.app.features.details.components.DetailSeriesContent
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
@ -124,16 +124,17 @@ fun MetaDetailsScreen(
libraryUiState.items.any { it.id == meta.id }
}
val movieProgress = watchProgressUiState.byVideoId[meta.id]
val seriesResumeEntry = watchProgressUiState.entries
.filter { it.parentMetaId == meta.id }
.maxByOrNull { it.lastUpdatedEpochMs }
val firstEpisode = remember(meta.videos) { meta.firstPlayableEpisode() }
val playButtonLabel = remember(movieProgress, seriesResumeEntry, firstEpisode, meta.type) {
?.takeUnless { it.isCompleted }
val seriesAction = remember(watchProgressUiState.entries, meta) {
meta.seriesPrimaryAction(
entries = watchProgressUiState.entries,
todayIsoDate = CurrentDateProvider.todayIsoDate(),
)
}
val playButtonLabel = remember(movieProgress, seriesAction, meta.type) {
when {
meta.type == "series" && seriesResumeEntry != null ->
seriesResumeEntry.resumeLabel()
meta.type == "series" && firstEpisode != null ->
firstEpisode.playLabel()
meta.type == "series" && seriesAction != null ->
seriesAction.label
meta.type != "series" && movieProgress != null ->
"Resume"
else -> "Play"
@ -158,44 +159,21 @@ fun MetaDetailsScreen(
saveLabel = if (isSaved) "Saved" else "Save",
onPlayClick = {
when {
meta.type == "series" && seriesResumeEntry != null -> {
meta.type == "series" && seriesAction != null -> {
onPlay?.invoke(
meta.type,
seriesResumeEntry.videoId,
seriesAction.videoId,
meta.id,
meta.type,
meta.name,
meta.logo,
meta.poster,
meta.background,
seriesResumeEntry.seasonNumber,
seriesResumeEntry.episodeNumber,
seriesResumeEntry.episodeTitle,
seriesResumeEntry.episodeThumbnail,
seriesResumeEntry.lastPositionMs,
)
}
meta.type == "series" && firstEpisode != null -> {
onPlay?.invoke(
meta.type,
buildPlaybackVideoId(
parentMetaId = meta.id,
seasonNumber = firstEpisode.season,
episodeNumber = firstEpisode.episode,
fallbackVideoId = firstEpisode.id,
),
meta.id,
meta.type,
meta.name,
meta.logo,
meta.poster,
meta.background,
firstEpisode.season,
firstEpisode.episode,
firstEpisode.title,
firstEpisode.thumbnail,
null,
seriesAction.seasonNumber,
seriesAction.episodeNumber,
seriesAction.episodeTitle,
seriesAction.episodeThumbnail,
seriesAction.resumePositionMs,
)
}
@ -242,6 +220,7 @@ fun MetaDetailsScreen(
fallbackVideoId = video.id,
)
val savedProgress = watchProgressUiState.byVideoId[playbackVideoId]
?.takeUnless { it.isCompleted }
onPlay?.invoke(
meta.type,
playbackVideoId,
@ -288,30 +267,3 @@ fun MetaDetailsScreen(
}
}
}
private fun MetaDetails.firstPlayableEpisode(): MetaVideo? =
videos
.filter { it.season != null || it.episode != null }
.sortedWith(
compareBy<MetaVideo>(
{ it.season ?: Int.MAX_VALUE },
{ it.episode ?: Int.MAX_VALUE },
{ it.released ?: "" },
{ it.title },
),
)
.firstOrNull()
private fun MetaVideo.playLabel(): String =
if (season != null && episode != null) {
"Play S${season}E${episode}"
} else {
"Play"
}
private fun WatchProgressEntry.resumeLabel(): String =
if (seasonNumber != null && episodeNumber != null) {
"Resume S${seasonNumber}E${episodeNumber}"
} else {
"Resume"
}

View file

@ -0,0 +1,129 @@
package com.nuvio.app.features.details
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.features.watchprogress.resumeEntryForSeries
internal fun MetaDetails.sortedPlayableEpisodes(): List<MetaVideo> =
videos
.filter { it.season != null || it.episode != null }
.sortedWith(
compareBy<MetaVideo>(
{ it.season ?: Int.MAX_VALUE },
{ it.episode ?: Int.MAX_VALUE },
{ it.released ?: "" },
{ it.title },
),
)
internal fun MetaDetails.firstPlayableEpisode(): MetaVideo? =
sortedPlayableEpisodes().firstOrNull()
internal fun MetaDetails.firstReleasedPlayableEpisode(todayIsoDate: String): MetaVideo? =
sortedPlayableEpisodes().firstOrNull { it.isReleasedBy(todayIsoDate) }
internal fun MetaDetails.nextReleasedEpisodeAfter(
completedEntry: WatchProgressEntry,
todayIsoDate: String,
): MetaVideo? {
val sortedEpisodes = sortedPlayableEpisodes()
return sortedEpisodes
.dropWhile { episode ->
buildPlaybackVideoId(
parentMetaId = id,
seasonNumber = episode.season,
episodeNumber = episode.episode,
fallbackVideoId = episode.id,
) != completedEntry.videoId
}
.drop(1)
.firstOrNull { it.isReleasedBy(todayIsoDate) }
}
internal data class SeriesPrimaryAction(
val label: String,
val videoId: String,
val seasonNumber: Int?,
val episodeNumber: Int?,
val episodeTitle: String?,
val episodeThumbnail: String?,
val resumePositionMs: Long?,
)
internal fun MetaDetails.seriesPrimaryAction(
entries: List<WatchProgressEntry>,
todayIsoDate: String,
): SeriesPrimaryAction? {
val resumeEntry = entries.resumeEntryForSeries(id)
if (resumeEntry != null) {
return SeriesPrimaryAction(
label = resumeEntry.resumeLabel(),
videoId = resumeEntry.videoId,
seasonNumber = resumeEntry.seasonNumber,
episodeNumber = resumeEntry.episodeNumber,
episodeTitle = resumeEntry.episodeTitle,
episodeThumbnail = resumeEntry.episodeThumbnail,
resumePositionMs = resumeEntry.lastPositionMs,
)
}
val latestCompleted = entries
.filter { it.parentMetaId == id && it.isCompleted }
.maxByOrNull { it.lastUpdatedEpochMs }
val nextEpisode = if (latestCompleted != null) {
nextReleasedEpisodeAfter(
completedEntry = latestCompleted,
todayIsoDate = todayIsoDate,
)
} else {
firstReleasedPlayableEpisode(todayIsoDate)
}
return nextEpisode?.let { episode ->
SeriesPrimaryAction(
label = if (latestCompleted != null) episode.upNextLabel() else episode.playLabel(),
videoId = buildPlaybackVideoId(
parentMetaId = id,
seasonNumber = episode.season,
episodeNumber = episode.episode,
fallbackVideoId = episode.id,
),
seasonNumber = episode.season,
episodeNumber = episode.episode,
episodeTitle = episode.title,
episodeThumbnail = episode.thumbnail,
resumePositionMs = null,
)
}
}
internal fun MetaVideo.playLabel(): String =
if (season != null && episode != null) {
"Play S${season}E${episode}"
} else {
"Play"
}
internal fun MetaVideo.upNextLabel(): String =
if (season != null && episode != null) {
"Up Next S${season}E${episode}"
} else {
"Up Next"
}
internal fun WatchProgressEntry.resumeLabel(): String =
if (seasonNumber != null && episodeNumber != null) {
"Resume S${seasonNumber}E${episodeNumber}"
} else {
"Resume"
}
private fun MetaVideo.isReleasedBy(todayIsoDate: String): Boolean {
val releaseDate = released
?.substringBefore('T')
?.takeIf { it.length == 10 }
?: return true
return releaseDate <= todayIsoDate
}

View file

@ -20,6 +20,7 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
@ -54,7 +55,9 @@ fun DetailActionButtons(
Spacer(modifier = Modifier.width(6.dp))
Text(
text = playLabel,
style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@ -77,6 +80,8 @@ fun DetailActionButtons(
text = saveLabel,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}

View file

@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import co.touchlab.kermit.Logger
import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge
import com.nuvio.app.core.ui.NuvioProgressBar
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaVideo
@ -244,6 +245,13 @@ private fun EpisodeCard(
color = Color.White,
)
}
NuvioAnimatedWatchedBadge(
isVisible = progressEntry?.isCompleted == true,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp),
)
}
Column(
@ -300,7 +308,7 @@ private fun EpisodeCard(
}
progressEntry
?.takeIf { it.durationMs > 0L }
?.takeIf { it.durationMs > 0L && !it.isCompleted }
?.let { entry ->
NuvioProgressBar(
progress = entry.progressFraction,

View file

@ -5,21 +5,27 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.nextReleasedEpisodeAfter
import com.nuvio.app.features.home.components.HomeCatalogRowSection
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.CurrentDateProvider
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
@Composable
fun HomeScreen(
@ -42,8 +48,36 @@ fun HomeScreen(
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() }
val completedSeriesCandidates = remember(watchProgressUiState.entries) {
watchProgressUiState.entries
.filter { it.isCompleted && it.isEpisode }
.groupBy { it.parentMetaId }
.mapNotNull { (parentMetaId, entries) ->
val hasResumableEntry = watchProgressUiState.entries.any {
it.parentMetaId == parentMetaId && !it.isCompleted
}
if (hasResumableEntry) {
null
} else {
entries.maxByOrNull { it.lastUpdatedEpochMs }
}
}
}
var nextUpItemsBySeries by remember { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
val continueWatchingItems = remember(
watchProgressUiState.continueWatchingEntries,
nextUpItemsBySeries,
) {
buildList {
addAll(
watchProgressUiState.continueWatchingEntries.map { entry ->
entry.lastUpdatedEpochMs to entry.toContinueWatchingItem()
},
)
addAll(nextUpItemsBySeries.values)
}
.sortedByDescending { it.first }
.map { it.second }
}
val catalogRefreshKey = remember(addonsUiState.addons) {
@ -64,6 +98,33 @@ fun HomeScreen(
HomeRepository.refresh(addonsUiState.addons)
}
LaunchedEffect(completedSeriesCandidates, catalogRefreshKey) {
if (completedSeriesCandidates.isEmpty()) {
nextUpItemsBySeries = emptyMap()
return@LaunchedEffect
}
if (addonsUiState.addons.none { it.manifest != null }) {
return@LaunchedEffect
}
val todayIsoDate = CurrentDateProvider.todayIsoDate()
val resolvedItems = mutableMapOf<String, Pair<Long, ContinueWatchingItem>>()
completedSeriesCandidates.forEach { completedEntry ->
val meta = MetaDetailsRepository.fetch(
type = completedEntry.parentMetaType,
id = completedEntry.parentMetaId,
) ?: return@forEach
val nextEpisode = meta.nextReleasedEpisodeAfter(
completedEntry = completedEntry,
todayIsoDate = todayIsoDate,
) ?: return@forEach
resolvedItems[completedEntry.parentMetaId] =
completedEntry.lastUpdatedEpochMs to completedEntry.toUpNextContinueWatchingItem(nextEpisode)
}
nextUpItemsBySeries = resolvedItems
}
NuvioScreen(
modifier = modifier,
horizontalPadding = 0.dp,

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.watchprogress
expect object CurrentDateProvider {
fun todayIsoDate(): String
}

View file

@ -1,5 +1,6 @@
package com.nuvio.app.features.watchprogress
import com.nuvio.app.features.details.MetaVideo
import kotlinx.serialization.Serializable
@Serializable
@ -30,6 +31,7 @@ data class WatchProgressEntry(
val lastStreamTitle: String? = null,
val lastStreamSubtitle: String? = null,
val lastSourceUrl: String? = null,
val isCompleted: Boolean = false,
) {
val progressFraction: Float
get() = if (durationMs > 0L) {
@ -40,6 +42,9 @@ data class WatchProgressEntry(
val isEpisode: Boolean
get() = seasonNumber != null && episodeNumber != null
val isResumable: Boolean
get() = !isCompleted
}
data class WatchProgressUiState(
@ -47,6 +52,9 @@ data class WatchProgressUiState(
) {
val byVideoId: Map<String, WatchProgressEntry>
get() = entries.associateBy { it.videoId }
val continueWatchingEntries: List<WatchProgressEntry>
get() = entries.continueWatchingEntries(limit = ContinueWatchingLimit)
}
data class WatchProgressPlaybackSession(
@ -129,6 +137,48 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
)
}
internal fun WatchProgressEntry.toUpNextContinueWatchingItem(
nextEpisode: MetaVideo,
): ContinueWatchingItem {
val subtitle = buildString {
append("Up Next")
if (nextEpisode.season != null && nextEpisode.episode != null) {
append(" • S")
append(nextEpisode.season)
append("E")
append(nextEpisode.episode)
}
nextEpisode.title.takeIf { it.isNotBlank() }?.let {
append("")
append(it)
}
}
return ContinueWatchingItem(
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
videoId = buildPlaybackVideoId(
parentMetaId = parentMetaId,
seasonNumber = nextEpisode.season,
episodeNumber = nextEpisode.episode,
fallbackVideoId = nextEpisode.id,
),
title = title,
subtitle = subtitle,
imageUrl = nextEpisode.thumbnail ?: episodeThumbnail ?: background ?: poster,
logo = logo,
poster = poster,
background = background,
seasonNumber = nextEpisode.season,
episodeNumber = nextEpisode.episode,
episodeTitle = nextEpisode.title,
episodeThumbnail = nextEpisode.thumbnail,
resumePositionMs = 0L,
durationMs = 0L,
progressFraction = 0f,
)
}
fun buildPlaybackVideoId(
parentMetaId: String,
seasonNumber: Int?,

View file

@ -71,14 +71,12 @@ object WatchProgressRepository {
) {
val positionMs = snapshot.positionMs.coerceAtLeast(0L)
val durationMs = snapshot.durationMs.coerceAtLeast(0L)
if (isWatchProgressComplete(positionMs = positionMs, durationMs = durationMs, isEnded = snapshot.isEnded)) {
if (entriesByVideoId.remove(session.videoId) != null) {
publish()
if (persist) persist()
}
return
}
if (!shouldStoreWatchProgress(positionMs = positionMs, durationMs = durationMs)) {
val isCompleted = isWatchProgressComplete(
positionMs = positionMs,
durationMs = durationMs,
isEnded = snapshot.isEnded,
)
if (!isCompleted && !shouldStoreWatchProgress(positionMs = positionMs, durationMs = durationMs)) {
return
}
@ -95,7 +93,7 @@ object WatchProgressRepository {
episodeNumber = session.episodeNumber,
episodeTitle = session.episodeTitle,
episodeThumbnail = session.episodeThumbnail,
lastPositionMs = positionMs,
lastPositionMs = if (isCompleted && durationMs > 0L) durationMs else positionMs,
durationMs = durationMs,
lastUpdatedEpochMs = WatchProgressClock.nowEpochMs(),
providerName = session.providerName,
@ -103,6 +101,7 @@ object WatchProgressRepository {
lastStreamTitle = session.lastStreamTitle,
lastStreamSubtitle = session.lastStreamSubtitle,
lastSourceUrl = session.lastSourceUrl,
isCompleted = isCompleted,
)
publish()
if (persist) persist()
@ -110,7 +109,7 @@ object WatchProgressRepository {
private fun publish() {
_uiState.value = WatchProgressUiState(
entries = entriesByVideoId.values.toList().continueWatchingEntries(limit = Int.MAX_VALUE),
entries = entriesByVideoId.values.toList().sortedByDescending { it.lastUpdatedEpochMs },
)
}

View file

@ -60,11 +60,12 @@ internal fun isWatchProgressComplete(
}
internal fun List<WatchProgressEntry>.resumeEntryForSeries(metaId: String): WatchProgressEntry? =
filter { it.parentMetaId == metaId }
filter { it.parentMetaId == metaId && !it.isCompleted }
.maxByOrNull { it.lastUpdatedEpochMs }
internal fun List<WatchProgressEntry>.continueWatchingEntries(
limit: Int = ContinueWatchingLimit,
): List<WatchProgressEntry> =
sortedByDescending { it.lastUpdatedEpochMs }
filterNot { it.isCompleted }
.sortedByDescending { it.lastUpdatedEpochMs }
.take(limit)

View file

@ -0,0 +1,13 @@
package com.nuvio.app.features.watchprogress
import platform.Foundation.NSDate
import platform.Foundation.NSDateFormatter
actual object CurrentDateProvider {
actual fun todayIsoDate(): String {
val formatter = NSDateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter.stringFromDate(NSDate())
}
}