mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-09 00:17:59 +00:00
feat: Implement CurrentDateProvider for date handling across platforms and enhance watch progress features
This commit is contained in:
parent
3a277a5b99
commit
7a263e5181
12 changed files with 348 additions and 83 deletions
|
|
@ -0,0 +1,8 @@
|
|||
package com.nuvio.app.features.watchprogress
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
actual object CurrentDateProvider {
|
||||
actual fun todayIsoDate(): String = LocalDate.now().toString()
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package com.nuvio.app.features.watchprogress
|
||||
|
||||
expect object CurrentDateProvider {
|
||||
fun todayIsoDate(): String
|
||||
}
|
||||
|
||||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in a new issue