diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt new file mode 100644 index 000000000..880700f44 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt @@ -0,0 +1,8 @@ +package com.nuvio.app.features.watchprogress + +import java.time.LocalDate + +actual object CurrentDateProvider { + actual fun todayIsoDate(): String = LocalDate.now().toString() +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index a2605c4db..77d773414 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -18,12 +18,19 @@ object MetaDetailsRepository { private val _uiState = MutableStateFlow(MetaDetailsUiState()) val uiState: StateFlow = _uiState.asStateFlow() private var activeRequestKey: String? = null + private val cachedMetaByRequestKey = mutableMapOf() 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index f07aa7a65..cad842a18 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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( - { 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" - } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt new file mode 100644 index 000000000..cdc83138b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt @@ -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 = + videos + .filter { it.season != null || it.episode != null } + .sortedWith( + compareBy( + { 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, + 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 +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt index 761849a02..d43a3a431 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt @@ -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, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index f924c19bf..ac5003adf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -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, 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 ff9bb9798..ac66e4182 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 @@ -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>>(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>() + 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt new file mode 100644 index 000000000..83091292f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.watchprogress + +expect object CurrentDateProvider { + fun todayIsoDate(): String +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 73cf66ae1..256bab868 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -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 get() = entries.associateBy { it.videoId } + + val continueWatchingEntries: List + 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?, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 1cf28a43b..6ddaad368 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -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 }, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt index 6ea6bda08..1caba2688 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt @@ -60,11 +60,12 @@ internal fun isWatchProgressComplete( } internal fun List.resumeEntryForSeries(metaId: String): WatchProgressEntry? = - filter { it.parentMetaId == metaId } + filter { it.parentMetaId == metaId && !it.isCompleted } .maxByOrNull { it.lastUpdatedEpochMs } internal fun List.continueWatchingEntries( limit: Int = ContinueWatchingLimit, ): List = - sortedByDescending { it.lastUpdatedEpochMs } + filterNot { it.isCompleted } + .sortedByDescending { it.lastUpdatedEpochMs } .take(limit) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt new file mode 100644 index 000000000..fa753e591 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt @@ -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()) + } +} +