mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 07:21:58 +00:00
Merge branch 'parity' into cmp-rewrite
This commit is contained in:
commit
9e9db390f9
29 changed files with 869 additions and 77 deletions
|
|
@ -506,6 +506,10 @@
|
|||
<string name="settings_show_secret">Show value</string>
|
||||
<string name="settings_continue_watching_resume_prompt_description">Show a popup to continue where you left off when opening the app after leaving from the player.</string>
|
||||
<string name="settings_continue_watching_resume_prompt_title">Resume prompt on launch</string>
|
||||
<string name="settings_continue_watching_blur_next_up_description">Blur next episode thumbnails in Continue Watching to avoid spoilers.</string>
|
||||
<string name="settings_continue_watching_blur_next_up_title">Blur Unwatched in Continue Watching</string>
|
||||
<string name="settings_continue_watching_show_unaired_next_up_description">Include upcoming episodes in Continue Watching before they air.</string>
|
||||
<string name="settings_continue_watching_show_unaired_next_up_title">Show Unaired Next Up Episodes</string>
|
||||
<string name="settings_continue_watching_section_card_style">Poster Card Style</string>
|
||||
<string name="settings_continue_watching_section_on_launch">ON LAUNCH</string>
|
||||
<string name="settings_continue_watching_section_up_next_behavior">UP NEXT BEHAVIOR</string>
|
||||
|
|
@ -518,6 +522,8 @@
|
|||
<string name="settings_continue_watching_style_wide_description">Info-dense horizontal card</string>
|
||||
<string name="settings_continue_watching_up_next_description">Show next episode based on the furthest watched episode. Disable for rewatches to use the most recently watched episode instead.</string>
|
||||
<string name="settings_continue_watching_up_next_title">Up Next From Furthest Episode</string>
|
||||
<string name="settings_continue_watching_use_episode_thumbnails_description">Use episode thumbnails as default image. When disabled, uses backdrop.</string>
|
||||
<string name="settings_continue_watching_use_episode_thumbnails_title">Use Episode Thumbnails in Continue Watching</string>
|
||||
<string name="settings_content_discovery_section_home">HOME</string>
|
||||
<string name="settings_content_discovery_section_sources">SOURCES</string>
|
||||
<string name="settings_content_discovery_addons_description">Install, remove, refresh, and sort your content sources.</string>
|
||||
|
|
@ -557,6 +563,8 @@
|
|||
<string name="settings_meta_episode_style_list_description">Detail-first stacked cards</string>
|
||||
<string name="settings_meta_episodes">Episodes</string>
|
||||
<string name="settings_meta_episodes_description">Seasons and episode list for series.</string>
|
||||
<string name="settings_meta_blur_unwatched_episodes">Blur Unwatched Episodes</string>
|
||||
<string name="settings_meta_blur_unwatched_episodes_description">Blur episode thumbnails until watched to avoid spoilers.</string>
|
||||
<string name="settings_meta_group_label">Group %1$d</string>
|
||||
<string name="settings_meta_more_like_this">More like this</string>
|
||||
<string name="settings_meta_more_like_this_description">TMDB recommendation backdrops on detail page</string>
|
||||
|
|
|
|||
|
|
@ -690,6 +690,7 @@ fun MetaDetailsScreen(
|
|||
onTrailerClick = resolveTrailer,
|
||||
progressByVideoId = watchProgressUiState.byVideoId,
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
|
||||
onEpisodeClick = onEpisodePlayClick,
|
||||
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
|
||||
onOpenMeta = onOpenMeta,
|
||||
|
|
@ -970,6 +971,7 @@ private fun ConfiguredMetaSections(
|
|||
onTrailerClick: (MetaTrailer) -> Unit,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
watchedKeys: Set<String>,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
onEpisodeClick: (MetaVideo) -> Unit,
|
||||
onEpisodeLongPress: (MetaVideo) -> Unit,
|
||||
onOpenMeta: ((MetaPreview) -> Unit)?,
|
||||
|
|
@ -1062,6 +1064,7 @@ private fun ConfiguredMetaSections(
|
|||
episodeCardStyle = settings.episodeCardStyle,
|
||||
progressByVideoId = progressByVideoId,
|
||||
watchedKeys = watchedKeys,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
onEpisodeClick = onEpisodeClick,
|
||||
onEpisodeLongPress = onEpisodeLongPress,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ data class MetaScreenSettingsUiState(
|
|||
val cinematicBackground: Boolean = false,
|
||||
val tabLayout: Boolean = false,
|
||||
val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||
val blurUnwatchedEpisodes: Boolean = false,
|
||||
)
|
||||
|
||||
enum class MetaEpisodeCardStyle {
|
||||
|
|
@ -81,6 +82,8 @@ private data class StoredMetaScreenSettingsPayload(
|
|||
@SerialName("tvStyleLayout")
|
||||
val tabLayout: Boolean = false,
|
||||
val episodeCardStyle: String = "horizontal",
|
||||
@SerialName("blur_unwatched_episodes")
|
||||
val blurUnwatchedEpisodes: Boolean = false,
|
||||
)
|
||||
|
||||
private data class MetaScreenSectionDefinition(
|
||||
|
|
@ -156,6 +159,7 @@ object MetaScreenSettingsRepository {
|
|||
private var cinematicBackground: Boolean = false
|
||||
private var tabLayout: Boolean = false
|
||||
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||
private var blurUnwatchedEpisodes: Boolean = false
|
||||
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
|
||||
|
||||
fun ensureLoaded() {
|
||||
|
|
@ -172,6 +176,7 @@ object MetaScreenSettingsRepository {
|
|||
tabLayout = parsed.tabLayout
|
||||
episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle)
|
||||
?: MetaEpisodeCardStyle.Horizontal
|
||||
blurUnwatchedEpisodes = parsed.blurUnwatchedEpisodes
|
||||
preferences = parsed.items.mapNotNull { item ->
|
||||
val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null
|
||||
key to item
|
||||
|
|
@ -190,6 +195,7 @@ object MetaScreenSettingsRepository {
|
|||
cinematicBackground = false
|
||||
tabLayout = false
|
||||
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||
blurUnwatchedEpisodes = false
|
||||
_uiState.value = MetaScreenSettingsUiState()
|
||||
ensureLoaded()
|
||||
}
|
||||
|
|
@ -215,6 +221,13 @@ object MetaScreenSettingsRepository {
|
|||
persist()
|
||||
}
|
||||
|
||||
fun setBlurUnwatchedEpisodes(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
blurUnwatchedEpisodes = enabled
|
||||
publish()
|
||||
persist()
|
||||
}
|
||||
|
||||
fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) {
|
||||
ensureLoaded()
|
||||
if (!key.canBeTabbed) return
|
||||
|
|
@ -233,6 +246,8 @@ object MetaScreenSettingsRepository {
|
|||
preferences.clear()
|
||||
cinematicBackground = false
|
||||
tabLayout = false
|
||||
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||
blurUnwatchedEpisodes = false
|
||||
_uiState.value = MetaScreenSettingsUiState()
|
||||
}
|
||||
|
||||
|
|
@ -241,11 +256,13 @@ object MetaScreenSettingsRepository {
|
|||
cinematicBackground: Boolean,
|
||||
tabLayout: Boolean,
|
||||
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||
blurUnwatchedEpisodes: Boolean = false,
|
||||
) {
|
||||
ensureLoaded()
|
||||
this.cinematicBackground = cinematicBackground
|
||||
this.tabLayout = tabLayout
|
||||
this.episodeCardStyle = episodeCardStyle
|
||||
this.blurUnwatchedEpisodes = blurUnwatchedEpisodes
|
||||
preferences = items.associate { item ->
|
||||
item.key to StoredMetaScreenSectionPreference(
|
||||
key = item.key.name,
|
||||
|
|
@ -271,6 +288,7 @@ object MetaScreenSettingsRepository {
|
|||
cinematicBackground = false
|
||||
tabLayout = false
|
||||
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||
blurUnwatchedEpisodes = false
|
||||
normalizePreferences()
|
||||
publish()
|
||||
persist()
|
||||
|
|
@ -337,6 +355,7 @@ object MetaScreenSettingsRepository {
|
|||
cinematicBackground = cinematicBackground,
|
||||
tabLayout = tabLayout,
|
||||
episodeCardStyle = episodeCardStyle,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -348,6 +367,7 @@ object MetaScreenSettingsRepository {
|
|||
cinematicBackground = cinematicBackground,
|
||||
tabLayout = tabLayout,
|
||||
episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle),
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.nuvio.app.features.details
|
||||
|
||||
import com.nuvio.app.features.watched.WatchedItem
|
||||
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode
|
||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||
|
|
@ -206,7 +207,7 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
|
|||
content = WatchingContentRef(type = type, id = id),
|
||||
seasonNumber = season,
|
||||
episodeNumber = episode,
|
||||
markedAtEpochMs = markedAtEpochMs,
|
||||
markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs),
|
||||
)
|
||||
|
||||
private fun WatchingSeriesPrimaryAction.toLegacySeriesPrimaryAction(): SeriesPrimaryAction =
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
|
@ -90,6 +91,7 @@ fun DetailSeriesContent(
|
|||
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
|
||||
watchedKeys: Set<String> = emptySet(),
|
||||
blurUnwatchedEpisodes: Boolean = false,
|
||||
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
|
||||
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
|
||||
) {
|
||||
|
|
@ -276,6 +278,7 @@ fun DetailSeriesContent(
|
|||
watchedKeys = watchedKeys,
|
||||
fallbackImage = meta.background ?: meta.poster,
|
||||
progressByVideoId = progressByVideoId,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
preferredEpisodeNumber = preferredEpisodeNumber,
|
||||
onEpisodeClick = onEpisodeClick,
|
||||
onEpisodeLongPress = onEpisodeLongPress,
|
||||
|
|
@ -295,13 +298,14 @@ fun DetailSeriesContent(
|
|||
video = episode,
|
||||
fallbackImage = meta.background ?: meta.poster,
|
||||
progressEntry = progressByVideoId[episodeVideoId],
|
||||
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
|
||||
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||
WatchingState.isEpisodeWatched(
|
||||
watchedKeys = watchedKeys,
|
||||
metaType = meta.type,
|
||||
metaId = meta.id,
|
||||
episode = episode,
|
||||
),
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
sizing = sizing,
|
||||
onClick = { onEpisodeClick?.invoke(episode) },
|
||||
onLongPress = { onEpisodeLongPress?.invoke(episode) },
|
||||
|
|
@ -553,6 +557,7 @@ private fun EpisodeHorizontalRow(
|
|||
watchedKeys: Set<String>,
|
||||
fallbackImage: String?,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
preferredEpisodeNumber: Int? = null,
|
||||
onEpisodeClick: ((MetaVideo) -> Unit)?,
|
||||
onEpisodeLongPress: ((MetaVideo) -> Unit)?,
|
||||
|
|
@ -597,13 +602,14 @@ private fun EpisodeHorizontalRow(
|
|||
video = episode,
|
||||
fallbackImage = fallbackImage,
|
||||
progressEntry = progressByVideoId[episodeVideoId],
|
||||
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
|
||||
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||
WatchingState.isEpisodeWatched(
|
||||
watchedKeys = watchedKeys,
|
||||
metaType = metaType,
|
||||
metaId = parentMetaId,
|
||||
episode = episode,
|
||||
),
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
metrics = rowMetrics,
|
||||
onClick = { onEpisodeClick?.invoke(episode) },
|
||||
onLongPress = { onEpisodeLongPress?.invoke(episode) },
|
||||
|
|
@ -619,6 +625,7 @@ private fun EpisodeHorizontalCard(
|
|||
fallbackImage: String?,
|
||||
progressEntry: WatchProgressEntry?,
|
||||
isWatched: Boolean,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
metrics: EpisodeHorizontalCardMetrics,
|
||||
onClick: (() -> Unit)? = null,
|
||||
onLongPress: (() -> Unit)? = null,
|
||||
|
|
@ -642,11 +649,14 @@ private fun EpisodeHorizontalCard(
|
|||
),
|
||||
) {
|
||||
val imageUrl = video.thumbnail ?: fallbackImage
|
||||
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = video.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
|
@ -889,6 +899,7 @@ private fun EpisodeListCard(
|
|||
fallbackImage: String?,
|
||||
progressEntry: WatchProgressEntry?,
|
||||
isWatched: Boolean,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
sizing: SeriesContentSizing,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
|
|
@ -923,11 +934,14 @@ private fun EpisodeListCard(
|
|||
.clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)),
|
||||
) {
|
||||
val imageUrl = video.thumbnail ?: fallbackImage
|
||||
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = video.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ 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
|
||||
import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching
|
||||
import com.nuvio.app.features.watchprogress.nextUpDismissKey
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressClock
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
|
|
@ -49,6 +50,7 @@ import com.nuvio.app.features.watchprogress.toContinueWatchingItem
|
|||
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
|
||||
import com.nuvio.app.features.watching.application.WatchingState
|
||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||
import com.nuvio.app.features.watching.domain.isReleasedBy
|
||||
import com.nuvio.app.features.collection.CollectionRepository
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.home.components.HomeCollectionRowSection
|
||||
|
|
@ -166,6 +168,9 @@ fun HomeScreen(
|
|||
)
|
||||
}
|
||||
}
|
||||
val completedSeriesContentIds = remember(completedSeriesCandidates) {
|
||||
completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id }
|
||||
}
|
||||
val visibleContinueWatchingEntries = remember(
|
||||
effectiveWatchProgressEntries,
|
||||
latestCompletedBySeries,
|
||||
|
|
@ -181,11 +186,28 @@ fun HomeScreen(
|
|||
var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
|
||||
|
||||
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() }
|
||||
val cachedNextUpItems = remember(cachedSnapshots.first, continueWatchingPreferences.dismissedNextUpKeys) {
|
||||
val cachedNextUpItems = remember(
|
||||
cachedSnapshots.first,
|
||||
continueWatchingPreferences.dismissedNextUpKeys,
|
||||
completedSeriesContentIds,
|
||||
isTraktProgressActive,
|
||||
continueWatchingPreferences.showUnairedNextUp,
|
||||
watchedUiState.isLoaded,
|
||||
) {
|
||||
cachedSnapshots.first.mapNotNull { cached ->
|
||||
if (
|
||||
!isTraktProgressActive &&
|
||||
watchedUiState.isLoaded &&
|
||||
cached.contentId !in completedSeriesContentIds
|
||||
) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (nextUpDismissKey(cached.contentId, cached.seedSeason, cached.seedEpisode) in continueWatchingPreferences.dismissedNextUpKeys) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (!cached.hasAired && !continueWatchingPreferences.showUnairedNextUp) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val item = cached.toContinueWatchingItem() ?: return@mapNotNull null
|
||||
cached.contentId to (cached.sortTimestamp to item)
|
||||
}.toMap()
|
||||
|
|
@ -264,7 +286,11 @@ fun HomeScreen(
|
|||
HomeCatalogSettingsRepository.syncCollections(collections)
|
||||
}
|
||||
|
||||
LaunchedEffect(completedSeriesCandidates, metaProviderKey, isTraktProgressActive) {
|
||||
LaunchedEffect(
|
||||
completedSeriesCandidates,
|
||||
metaProviderKey,
|
||||
continueWatchingPreferences.showUnairedNextUp,
|
||||
) {
|
||||
if (completedSeriesCandidates.isEmpty()) {
|
||||
nextUpItemsBySeries = emptyMap()
|
||||
return@LaunchedEffect
|
||||
|
|
@ -285,7 +311,7 @@ fun HomeScreen(
|
|||
seasonNumber = completedEntry.seasonNumber,
|
||||
episodeNumber = completedEntry.episodeNumber,
|
||||
todayIsoDate = todayIsoDate,
|
||||
showUnairedNextUp = isTraktProgressActive,
|
||||
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
|
||||
) ?: return@withPermit null
|
||||
val item = completedEntry.toContinueWatchingSeed(meta)
|
||||
.toUpNextContinueWatchingItem(nextEpisode)
|
||||
|
|
@ -313,6 +339,10 @@ fun HomeScreen(
|
|||
episodeTitle = item.episodeTitle,
|
||||
episodeThumbnail = item.episodeThumbnail,
|
||||
pauseDescription = item.pauseDescription,
|
||||
released = item.released,
|
||||
hasAired = item.released?.let { released ->
|
||||
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released)
|
||||
} ?: true,
|
||||
lastWatched = pair.first,
|
||||
sortTimestamp = pair.first,
|
||||
seedSeason = item.nextUpSeedSeasonNumber,
|
||||
|
|
@ -431,6 +461,8 @@ fun HomeScreen(
|
|||
HomeContinueWatchingSection(
|
||||
items = continueWatchingItems,
|
||||
style = continueWatchingPreferences.style,
|
||||
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
|
||||
blurNextUp = continueWatchingPreferences.blurNextUp,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
sectionPadding = homeSectionPadding,
|
||||
layout = continueWatchingLayout,
|
||||
|
|
@ -454,6 +486,8 @@ fun HomeScreen(
|
|||
HomeContinueWatchingSection(
|
||||
items = continueWatchingItems,
|
||||
style = continueWatchingPreferences.style,
|
||||
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
|
||||
blurNextUp = continueWatchingPreferences.blurNextUp,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
sectionPadding = homeSectionPadding,
|
||||
layout = continueWatchingLayout,
|
||||
|
|
@ -496,6 +530,8 @@ fun HomeScreen(
|
|||
HomeContinueWatchingSection(
|
||||
items = continueWatchingItems,
|
||||
style = continueWatchingPreferences.style,
|
||||
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
|
||||
blurNextUp = continueWatchingPreferences.blurNextUp,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
sectionPadding = homeSectionPadding,
|
||||
layout = continueWatchingLayout,
|
||||
|
|
@ -584,6 +620,13 @@ internal fun buildHomeContinueWatchingItems(
|
|||
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
|
||||
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
|
||||
): List<ContinueWatchingItem> {
|
||||
val inProgressSeriesIds = visibleEntries
|
||||
.asSequence()
|
||||
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
|
||||
.map { entry -> entry.parentMetaId }
|
||||
.filter(String::isNotBlank)
|
||||
.toSet()
|
||||
|
||||
return buildList {
|
||||
addAll(
|
||||
visibleEntries.map { entry ->
|
||||
|
|
@ -596,7 +639,8 @@ internal fun buildHomeContinueWatchingItems(
|
|||
},
|
||||
)
|
||||
addAll(
|
||||
nextUpItemsBySeries.values.map { (lastUpdatedEpochMs, item) ->
|
||||
nextUpItemsBySeries.values.mapNotNull { (lastUpdatedEpochMs, item) ->
|
||||
if (item.parentMetaId in inProgressSeriesIds) return@mapNotNull null
|
||||
HomeContinueWatchingCandidate(
|
||||
lastUpdatedEpochMs = lastUpdatedEpochMs,
|
||||
item = item,
|
||||
|
|
@ -610,7 +654,7 @@ internal fun buildHomeContinueWatchingItems(
|
|||
.thenByDescending { it.isProgressEntry },
|
||||
)
|
||||
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
|
||||
.distinctBy { it.item.videoId }
|
||||
.distinctBy { candidate -> candidate.item.parentMetaId.ifBlank { candidate.item.videoId } }
|
||||
.map(HomeContinueWatchingCandidate::item)
|
||||
}
|
||||
|
||||
|
|
@ -668,6 +712,7 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
|
|||
episodeTitle = episodeTitle,
|
||||
episodeThumbnail = episodeThumbnail,
|
||||
pauseDescription = pauseDescription,
|
||||
released = released,
|
||||
isNextUp = true,
|
||||
nextUpSeedSeasonNumber = seedSeason,
|
||||
nextUpSeedEpisodeNumber = seedEpisode,
|
||||
|
|
@ -734,5 +779,6 @@ private fun ContinueWatchingItem.withFallbackMetadata(
|
|||
episodeTitle = episodeTitle ?: fallback.episodeTitle,
|
||||
episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail,
|
||||
pauseDescription = pauseDescription ?: fallback.pauseDescription,
|
||||
released = released ?: fallback.released,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import androidx.compose.material3.contentColorFor
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
|
@ -50,10 +51,44 @@ import org.jetbrains.compose.resources.stringResource
|
|||
private fun continueWatchingProgressPercent(progressFraction: Float): Int =
|
||||
(progressFraction * 100f).roundToInt().coerceIn(1, 99)
|
||||
|
||||
private fun ContinueWatchingItem.continueWatchingArtworkUrl(
|
||||
useEpisodeThumbnails: Boolean,
|
||||
): String? = when {
|
||||
isNextUp && useEpisodeThumbnails -> firstNonBlank(
|
||||
episodeThumbnail,
|
||||
background,
|
||||
poster,
|
||||
imageUrl,
|
||||
)
|
||||
isNextUp -> firstNonBlank(
|
||||
background,
|
||||
poster,
|
||||
episodeThumbnail,
|
||||
imageUrl,
|
||||
)
|
||||
useEpisodeThumbnails -> firstNonBlank(
|
||||
episodeThumbnail,
|
||||
background,
|
||||
poster,
|
||||
imageUrl,
|
||||
)
|
||||
else -> firstNonBlank(
|
||||
background,
|
||||
poster,
|
||||
episodeThumbnail,
|
||||
imageUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun firstNonBlank(vararg values: String?): String? =
|
||||
values.firstOrNull { value -> !value.isNullOrBlank() }?.trim()
|
||||
|
||||
@Composable
|
||||
internal fun HomeContinueWatchingSection(
|
||||
items: List<ContinueWatchingItem>,
|
||||
style: ContinueWatchingSectionStyle,
|
||||
useEpisodeThumbnails: Boolean = true,
|
||||
blurNextUp: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
sectionPadding: Dp? = null,
|
||||
layout: ContinueWatchingLayout? = null,
|
||||
|
|
@ -66,6 +101,8 @@ internal fun HomeContinueWatchingSection(
|
|||
HomeContinueWatchingSectionContent(
|
||||
items = items,
|
||||
style = style,
|
||||
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||
blurNextUp = blurNextUp,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
sectionPadding = sectionPadding,
|
||||
layout = layout,
|
||||
|
|
@ -77,6 +114,8 @@ internal fun HomeContinueWatchingSection(
|
|||
HomeContinueWatchingSectionContent(
|
||||
items = items,
|
||||
style = style,
|
||||
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||
blurNextUp = blurNextUp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
|
||||
layout = rememberContinueWatchingLayout(maxWidth.value),
|
||||
|
|
@ -91,6 +130,8 @@ internal fun HomeContinueWatchingSection(
|
|||
private fun HomeContinueWatchingSectionContent(
|
||||
items: List<ContinueWatchingItem>,
|
||||
style: ContinueWatchingSectionStyle,
|
||||
useEpisodeThumbnails: Boolean,
|
||||
blurNextUp: Boolean,
|
||||
modifier: Modifier,
|
||||
sectionPadding: Dp,
|
||||
layout: ContinueWatchingLayout,
|
||||
|
|
@ -110,12 +151,16 @@ private fun HomeContinueWatchingSectionContent(
|
|||
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
|
||||
item = item,
|
||||
layout = layout,
|
||||
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||
blurNextUp = blurNextUp,
|
||||
onClick = onItemClick?.let { { it(item) } },
|
||||
onLongClick = onItemLongPress?.let { { it(item) } },
|
||||
)
|
||||
ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard(
|
||||
item = item,
|
||||
layout = layout,
|
||||
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||
blurNextUp = blurNextUp,
|
||||
onClick = onItemClick?.let { { it(item) } },
|
||||
onLongClick = onItemLongPress?.let { { it(item) } },
|
||||
)
|
||||
|
|
@ -273,6 +318,8 @@ private fun PosterCardPreview() {
|
|||
private fun ContinueWatchingWideCard(
|
||||
item: ContinueWatchingItem,
|
||||
layout: ContinueWatchingLayout,
|
||||
useEpisodeThumbnails: Boolean,
|
||||
blurNextUp: Boolean,
|
||||
onClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
) {
|
||||
|
|
@ -293,10 +340,12 @@ private fun ContinueWatchingWideCard(
|
|||
onLongClick = onLongClick,
|
||||
),
|
||||
) {
|
||||
val artworkUrl = item.poster ?: item.background ?: item.imageUrl
|
||||
val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
|
||||
val artworkUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
|
||||
ArtworkPanel(
|
||||
imageUrl = artworkUrl,
|
||||
width = layout.widePosterStripWidth,
|
||||
blurred = shouldBlurArtwork,
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
)
|
||||
Column(
|
||||
|
|
@ -384,6 +433,8 @@ private fun ContinueWatchingWideCard(
|
|||
private fun ContinueWatchingPosterCard(
|
||||
item: ContinueWatchingItem,
|
||||
layout: ContinueWatchingLayout,
|
||||
useEpisodeThumbnails: Boolean,
|
||||
blurNextUp: Boolean,
|
||||
onClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
) {
|
||||
|
|
@ -404,12 +455,15 @@ private fun ContinueWatchingPosterCard(
|
|||
)
|
||||
.posterCardClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
) {
|
||||
val imageUrl = item.poster ?: item.imageUrl
|
||||
val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
|
||||
val imageUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = item.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
|
@ -489,6 +543,7 @@ private fun ContinueWatchingPosterCard(
|
|||
private fun ArtworkPanel(
|
||||
imageUrl: String?,
|
||||
width: Dp,
|
||||
blurred: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
|
|
@ -500,7 +555,9 @@ private fun ArtworkPanel(
|
|||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (blurred) Modifier.blur(18.dp) else Modifier),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
|
@ -60,6 +61,9 @@ import coil3.compose.AsyncImage
|
|||
import com.nuvio.app.features.details.MetaVideo
|
||||
import com.nuvio.app.features.streams.StreamItem
|
||||
import com.nuvio.app.features.streams.StreamsUiState
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
|
||||
import com.nuvio.app.features.watching.application.WatchingState
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
|
|
@ -72,8 +76,13 @@ import org.jetbrains.compose.resources.stringResource
|
|||
fun PlayerEpisodesPanel(
|
||||
visible: Boolean,
|
||||
episodes: List<MetaVideo>,
|
||||
parentMetaType: String,
|
||||
parentMetaId: String,
|
||||
currentSeason: Int?,
|
||||
currentEpisode: Int?,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
watchedKeys: Set<String>,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
// episode stream sub-view state
|
||||
episodeStreamsState: EpisodeStreamsPanelState,
|
||||
onSeasonSelected: (Int) -> Unit,
|
||||
|
|
@ -134,8 +143,13 @@ fun PlayerEpisodesPanel(
|
|||
} else {
|
||||
EpisodesListSubView(
|
||||
episodes = episodes,
|
||||
parentMetaType = parentMetaType,
|
||||
parentMetaId = parentMetaId,
|
||||
currentSeason = currentSeason,
|
||||
currentEpisode = currentEpisode,
|
||||
progressByVideoId = progressByVideoId,
|
||||
watchedKeys = watchedKeys,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
onSeasonSelected = onSeasonSelected,
|
||||
onEpisodeSelected = onEpisodeSelected,
|
||||
onDismiss = onDismiss,
|
||||
|
|
@ -158,8 +172,13 @@ data class EpisodeStreamsPanelState(
|
|||
@Composable
|
||||
private fun EpisodesListSubView(
|
||||
episodes: List<MetaVideo>,
|
||||
parentMetaType: String,
|
||||
parentMetaId: String,
|
||||
currentSeason: Int?,
|
||||
currentEpisode: Int?,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
watchedKeys: Set<String>,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
onSeasonSelected: (Int) -> Unit,
|
||||
onEpisodeSelected: (MetaVideo) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
|
|
@ -296,9 +315,24 @@ private fun EpisodesListSubView(
|
|||
key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" },
|
||||
) { _, episode ->
|
||||
val isCurrent = episode.season == currentSeason && episode.episode == currentEpisode
|
||||
val episodeVideoId = buildPlaybackVideoId(
|
||||
parentMetaId = parentMetaId,
|
||||
seasonNumber = episode.season,
|
||||
episodeNumber = episode.episode,
|
||||
fallbackVideoId = episode.id,
|
||||
)
|
||||
val isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||
WatchingState.isEpisodeWatched(
|
||||
watchedKeys = watchedKeys,
|
||||
metaType = parentMetaType,
|
||||
metaId = parentMetaId,
|
||||
episode = episode,
|
||||
)
|
||||
EpisodeRow(
|
||||
episode = episode,
|
||||
isCurrent = isCurrent,
|
||||
isWatched = isWatched,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
onClick = { onEpisodeSelected(episode) },
|
||||
)
|
||||
}
|
||||
|
|
@ -311,9 +345,12 @@ private fun EpisodesListSubView(
|
|||
private fun EpisodeRow(
|
||||
episode: MetaVideo,
|
||||
isCurrent: Boolean,
|
||||
isWatched: Boolean,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched && !isCurrent
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
|
@ -342,7 +379,8 @@ private fun EpisodeRow(
|
|||
modifier = Modifier
|
||||
.width(80.dp)
|
||||
.height(48.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||
import com.nuvio.app.features.details.MetaVideo
|
||||
import com.nuvio.app.features.downloads.DownloadItem
|
||||
import com.nuvio.app.features.downloads.DownloadsRepository
|
||||
|
|
@ -55,6 +56,7 @@ import com.nuvio.app.features.streams.StreamItem
|
|||
import com.nuvio.app.features.streams.StreamLinkCacheRepository
|
||||
import com.nuvio.app.features.streams.StreamsUiState
|
||||
import com.nuvio.app.features.trakt.TraktScrobbleRepository
|
||||
import com.nuvio.app.features.watched.WatchedRepository
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressClock
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||
|
|
@ -143,6 +145,18 @@ fun PlayerScreen(
|
|||
PlayerSettingsRepository.ensureLoaded()
|
||||
PlayerSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val metaScreenSettingsUiState by remember {
|
||||
MetaScreenSettingsRepository.ensureLoaded()
|
||||
MetaScreenSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val watchedUiState by remember {
|
||||
WatchedRepository.ensureLoaded()
|
||||
WatchedRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val watchProgressUiState by remember {
|
||||
WatchProgressRepository.ensureLoaded()
|
||||
WatchProgressRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
|
|
@ -1799,8 +1813,13 @@ fun PlayerScreen(
|
|||
PlayerEpisodesPanel(
|
||||
visible = showEpisodesPanel,
|
||||
episodes = allEpisodes,
|
||||
parentMetaType = parentMetaType,
|
||||
parentMetaId = parentMetaId,
|
||||
currentSeason = activeSeasonNumber,
|
||||
currentEpisode = activeEpisodeNumber,
|
||||
progressByVideoId = watchProgressUiState.byVideoId,
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
|
||||
episodeStreamsState = episodeStreamsPanelState.copy(
|
||||
streamsUiState = episodeStreamsRepoState,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
|||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_blur_next_up_description
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_blur_next_up_title
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_show_unaired_next_up_description
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_show_unaired_next_up_title
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_section_card_style
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior
|
||||
|
|
@ -40,6 +44,8 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_style_wid
|
|||
import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide_description
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_description
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_title
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_use_episode_thumbnails_description
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_use_episode_thumbnails_title
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
|
|
@ -48,6 +54,9 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
|||
isVisible: Boolean,
|
||||
style: ContinueWatchingSectionStyle,
|
||||
upNextFromFurthestEpisode: Boolean,
|
||||
useEpisodeThumbnails: Boolean,
|
||||
showUnairedNextUp: Boolean,
|
||||
blurNextUp: Boolean,
|
||||
showResumePromptOnLaunch: Boolean,
|
||||
) {
|
||||
item {
|
||||
|
|
@ -84,6 +93,14 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
|||
isTablet = isTablet,
|
||||
) {
|
||||
SettingsGroup(isTablet = isTablet) {
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_title),
|
||||
description = stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_description),
|
||||
checked = useEpisodeThumbnails,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = ContinueWatchingPreferencesRepository::setUseEpisodeThumbnails,
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_continue_watching_up_next_title),
|
||||
description = stringResource(Res.string.settings_continue_watching_up_next_description),
|
||||
|
|
@ -91,6 +108,24 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
|||
isTablet = isTablet,
|
||||
onCheckedChange = ContinueWatchingPreferencesRepository::setUpNextFromFurthestEpisode,
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_continue_watching_show_unaired_next_up_title),
|
||||
description = stringResource(Res.string.settings_continue_watching_show_unaired_next_up_description),
|
||||
checked = showUnairedNextUp,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = ContinueWatchingPreferencesRepository::setShowUnairedNextUp,
|
||||
)
|
||||
if (useEpisodeThumbnails) {
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_continue_watching_blur_next_up_title),
|
||||
description = stringResource(Res.string.settings_continue_watching_blur_next_up_description),
|
||||
checked = blurNextUp,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = ContinueWatchingPreferencesRepository::setBlurNextUp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ import nuvio.composeapp.generated.resources.settings_meta_episode_style_list
|
|||
import nuvio.composeapp.generated.resources.settings_meta_episode_style_list_description
|
||||
import nuvio.composeapp.generated.resources.settings_meta_episodes
|
||||
import nuvio.composeapp.generated.resources.settings_meta_episodes_description
|
||||
import nuvio.composeapp.generated.resources.settings_meta_blur_unwatched_episodes
|
||||
import nuvio.composeapp.generated.resources.settings_meta_blur_unwatched_episodes_description
|
||||
import nuvio.composeapp.generated.resources.settings_meta_group_label
|
||||
import nuvio.composeapp.generated.resources.settings_meta_more_like_this
|
||||
import nuvio.composeapp.generated.resources.settings_meta_more_like_this_description
|
||||
|
|
@ -130,6 +132,14 @@ internal fun LazyListScope.metaScreenSettingsContent(
|
|||
selectedStyle = uiState.episodeCardStyle,
|
||||
onStyleSelected = MetaScreenSettingsRepository::setEpisodeCardStyle,
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_meta_blur_unwatched_episodes),
|
||||
description = stringResource(Res.string.settings_meta_blur_unwatched_episodes_description),
|
||||
checked = uiState.blurUnwatchedEpisodes,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = { MetaScreenSettingsRepository.setBlurUnwatchedEpisodes(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,9 @@ fun ContinueWatchingSettingsScreen(
|
|||
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||
style = continueWatchingPreferencesUiState.style,
|
||||
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
||||
useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
|
||||
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
||||
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -376,6 +376,9 @@ private fun MobileSettingsScreen(
|
|||
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||
style = continueWatchingPreferencesUiState.style,
|
||||
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
||||
useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
|
||||
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
||||
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||
)
|
||||
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
|
||||
|
|
@ -614,6 +617,9 @@ private fun TabletSettingsScreen(
|
|||
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||
style = continueWatchingPreferencesUiState.style,
|
||||
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
||||
useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
|
||||
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
||||
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||
)
|
||||
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@ import co.touchlab.kermit.Logger
|
|||
import com.nuvio.app.features.addons.httpGetTextWithHeaders
|
||||
import com.nuvio.app.features.addons.httpRequestRaw
|
||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktShowProgress
|
||||
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
|
||||
import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -29,7 +34,7 @@ import kotlinx.serialization.decodeFromString
|
|||
import kotlinx.serialization.json.Json
|
||||
|
||||
private const val BASE_URL = "https://api.trakt.tv"
|
||||
private const val TRAKT_COMPLETION_PERCENT_THRESHOLD = 80f
|
||||
private const val TRAKT_COMPLETION_PERCENT_THRESHOLD = 90f
|
||||
private const val HISTORY_LIMIT = 250
|
||||
private const val METADATA_FETCH_TIMEOUT_MS = 3_500L
|
||||
private const val METADATA_FETCH_CONCURRENCY = 5
|
||||
|
|
@ -113,8 +118,8 @@ object TraktProgressRepository {
|
|||
}
|
||||
|
||||
scope.launch {
|
||||
val historyEntries = runCatching {
|
||||
fetchHistoryEntries(headers)
|
||||
val completedEntries = runCatching {
|
||||
fetchHistoryEntries(headers) + fetchWatchedShowSeedEntries(headers)
|
||||
}.onFailure { error ->
|
||||
if (error is CancellationException) throw error
|
||||
log.w { "Failed to fetch Trakt history snapshot: ${error.message}" }
|
||||
|
|
@ -122,7 +127,7 @@ object TraktProgressRepository {
|
|||
|
||||
if (!isLatestRefreshRequest(requestId)) return@launch
|
||||
|
||||
val merged = mergeNewestByVideoId(playbackEntries + historyEntries)
|
||||
val merged = mergeNewestByVideoId(playbackEntries + completedEntries)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
entries = merged.sortedByDescending { it.lastUpdatedEpochMs },
|
||||
isLoading = false,
|
||||
|
|
@ -345,12 +350,32 @@ object TraktProgressRepository {
|
|||
mergeNewestByVideoId(completedEpisodes + completedMovies)
|
||||
}
|
||||
|
||||
private suspend fun fetchWatchedShowSeedEntries(
|
||||
headers: Map<String, String>,
|
||||
): List<WatchProgressEntry> = withContext(Dispatchers.Default) {
|
||||
ContinueWatchingPreferencesRepository.ensureLoaded()
|
||||
val useFurthestEpisode = ContinueWatchingPreferencesRepository.uiState.value.upNextFromFurthestEpisode
|
||||
val payload = httpGetTextWithHeaders(
|
||||
url = "$BASE_URL/sync/watched/shows",
|
||||
headers = headers,
|
||||
)
|
||||
val watchedShows = json.decodeFromString<List<TraktWatchedShowItem>>(payload)
|
||||
watchedShows
|
||||
.mapNotNull { item ->
|
||||
mapWatchedShowSeed(
|
||||
item = item,
|
||||
useFurthestEpisode = useFurthestEpisode,
|
||||
)
|
||||
}
|
||||
.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
|
||||
}
|
||||
|
||||
private fun mergeNewestByVideoId(entries: List<WatchProgressEntry>): List<WatchProgressEntry> {
|
||||
val mergedByVideoId = linkedMapOf<String, WatchProgressEntry>()
|
||||
entries.forEach { rawEntry ->
|
||||
val entry = rawEntry.normalizedCompletion()
|
||||
val existing = mergedByVideoId[entry.videoId]
|
||||
if (existing == null || entry.lastUpdatedEpochMs > existing.lastUpdatedEpochMs) {
|
||||
if (existing == null || shouldReplaceProgressSnapshotEntry(existing = existing, candidate = entry)) {
|
||||
mergedByVideoId[entry.videoId] = entry
|
||||
}
|
||||
}
|
||||
|
|
@ -360,6 +385,18 @@ object TraktProgressRepository {
|
|||
.sortedByDescending { it.lastUpdatedEpochMs }
|
||||
}
|
||||
|
||||
private fun shouldReplaceProgressSnapshotEntry(
|
||||
existing: WatchProgressEntry,
|
||||
candidate: WatchProgressEntry,
|
||||
): Boolean {
|
||||
val existingInProgress = existing.shouldTreatAsInProgressForContinueWatching()
|
||||
val candidateInProgress = candidate.shouldTreatAsInProgressForContinueWatching()
|
||||
if (existingInProgress != candidateInProgress) {
|
||||
return candidateInProgress
|
||||
}
|
||||
return candidate.lastUpdatedEpochMs > existing.lastUpdatedEpochMs
|
||||
}
|
||||
|
||||
private fun mergeEntriesPreferRichMetadata(
|
||||
current: List<WatchProgressEntry>,
|
||||
hydrated: List<WatchProgressEntry>,
|
||||
|
|
@ -499,6 +536,7 @@ object TraktProgressRepository {
|
|||
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
|
||||
isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD,
|
||||
progressPercent = progressPercent,
|
||||
source = WatchProgressSourceTraktPlayback,
|
||||
).normalizedCompletion()
|
||||
}
|
||||
|
||||
|
|
@ -533,6 +571,7 @@ object TraktProgressRepository {
|
|||
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
|
||||
isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD,
|
||||
progressPercent = progressPercent,
|
||||
source = WatchProgressSourceTraktPlayback,
|
||||
).normalizedCompletion()
|
||||
}
|
||||
|
||||
|
|
@ -564,6 +603,7 @@ object TraktProgressRepository {
|
|||
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
|
||||
isCompleted = true,
|
||||
progressPercent = 100f,
|
||||
source = WatchProgressSourceTraktHistory,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -583,6 +623,73 @@ object TraktProgressRepository {
|
|||
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
|
||||
isCompleted = true,
|
||||
progressPercent = 100f,
|
||||
source = WatchProgressSourceTraktHistory,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapWatchedShowSeed(
|
||||
item: TraktWatchedShowItem,
|
||||
useFurthestEpisode: Boolean,
|
||||
): WatchProgressEntry? {
|
||||
val show = item.show ?: return null
|
||||
val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title)
|
||||
if (parentMetaId.isBlank()) return null
|
||||
|
||||
val completedEpisode = item.seasons.orEmpty()
|
||||
.asSequence()
|
||||
.filter { season -> (season.number ?: 0) > 0 }
|
||||
.flatMap { season ->
|
||||
val seasonNumber = season.number ?: return@flatMap emptySequence()
|
||||
season.episodes.orEmpty()
|
||||
.asSequence()
|
||||
.filter { episode -> (episode.number ?: 0) > 0 && (episode.plays ?: 1) > 0 }
|
||||
.mapNotNull { episode ->
|
||||
val episodeNumber = episode.number ?: return@mapNotNull null
|
||||
TraktWatchedShowEpisodeSeed(
|
||||
season = seasonNumber,
|
||||
episode = episodeNumber,
|
||||
watchedAt = rankedTimestamp(
|
||||
isoDate = episode.lastWatchedAt ?: item.lastWatchedAt,
|
||||
fallbackIndex = 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
.maxWithOrNull(
|
||||
if (useFurthestEpisode) {
|
||||
compareBy<TraktWatchedShowEpisodeSeed>(
|
||||
{ it.season },
|
||||
{ it.episode },
|
||||
{ it.watchedAt },
|
||||
)
|
||||
} else {
|
||||
compareBy<TraktWatchedShowEpisodeSeed>(
|
||||
{ it.watchedAt },
|
||||
{ it.season },
|
||||
{ it.episode },
|
||||
)
|
||||
},
|
||||
) ?: return null
|
||||
|
||||
return WatchProgressEntry(
|
||||
contentType = "series",
|
||||
parentMetaId = parentMetaId,
|
||||
parentMetaType = "series",
|
||||
videoId = buildPlaybackVideoId(
|
||||
parentMetaId = parentMetaId,
|
||||
seasonNumber = completedEpisode.season,
|
||||
episodeNumber = completedEpisode.episode,
|
||||
fallbackVideoId = null,
|
||||
),
|
||||
title = show.title ?: parentMetaId,
|
||||
seasonNumber = completedEpisode.season,
|
||||
episodeNumber = completedEpisode.episode,
|
||||
lastPositionMs = 1L,
|
||||
durationMs = 1L,
|
||||
lastUpdatedEpochMs = completedEpisode.watchedAt,
|
||||
isCompleted = true,
|
||||
progressPercent = 100f,
|
||||
source = WatchProgressSourceTraktShowProgress,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -597,14 +704,10 @@ object TraktProgressRepository {
|
|||
}
|
||||
|
||||
private fun rankedTimestamp(isoDate: String?, fallbackIndex: Int): Long {
|
||||
val compactDigits = isoDate
|
||||
?.filter(Char::isDigit)
|
||||
?.take(14)
|
||||
?.takeIf { it.length >= 8 }
|
||||
?.padEnd(14, '0')
|
||||
?.toLongOrNull()
|
||||
if (compactDigits != null) return compactDigits
|
||||
|
||||
isoDate
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs)
|
||||
?.let { return it }
|
||||
return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L)
|
||||
}
|
||||
}
|
||||
|
|
@ -632,6 +735,32 @@ private data class TraktHistoryMovieItem(
|
|||
@SerialName("movie") val movie: TraktMedia? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TraktWatchedShowItem(
|
||||
@SerialName("last_watched_at") val lastWatchedAt: String? = null,
|
||||
@SerialName("show") val show: TraktMedia? = null,
|
||||
@SerialName("seasons") val seasons: List<TraktWatchedShowSeason>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TraktWatchedShowSeason(
|
||||
@SerialName("number") val number: Int? = null,
|
||||
@SerialName("episodes") val episodes: List<TraktWatchedShowEpisode>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TraktWatchedShowEpisode(
|
||||
@SerialName("number") val number: Int? = null,
|
||||
@SerialName("plays") val plays: Int? = null,
|
||||
@SerialName("last_watched_at") val lastWatchedAt: String? = null,
|
||||
)
|
||||
|
||||
private data class TraktWatchedShowEpisodeSeed(
|
||||
val season: Int,
|
||||
val episode: Int,
|
||||
val watchedAt: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TraktMedia(
|
||||
@SerialName("title") val title: String? = null,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.nuvio.app.features.watched
|
||||
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.trakt.TraktPlatformClock
|
||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||
import com.nuvio.app.features.watching.domain.watchedKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
|
@ -36,6 +37,43 @@ fun MetaPreview.toWatchedItem(markedAtEpochMs: Long): WatchedItem =
|
|||
val WatchedItem.isEpisode: Boolean
|
||||
get() = season != null && episode != null
|
||||
|
||||
internal fun WatchedItem.normalizedMarkedAt(): WatchedItem {
|
||||
val normalized = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs)
|
||||
return if (normalized == markedAtEpochMs) this else copy(markedAtEpochMs = normalized)
|
||||
}
|
||||
|
||||
internal fun normalizeWatchedMarkedAtEpochMs(value: Long): Long {
|
||||
if (value !in CompactWatchedTimestampMin..CompactWatchedTimestampMax) return value
|
||||
|
||||
val raw = value.toString().padStart(14, '0')
|
||||
val year = raw.substring(0, 4).toIntOrNull() ?: return value
|
||||
val month = raw.substring(4, 6).toIntOrNull() ?: return value
|
||||
val day = raw.substring(6, 8).toIntOrNull() ?: return value
|
||||
val hour = raw.substring(8, 10).toIntOrNull() ?: return value
|
||||
val minute = raw.substring(10, 12).toIntOrNull() ?: return value
|
||||
val second = raw.substring(12, 14).toIntOrNull() ?: return value
|
||||
|
||||
if (month !in 1..12 || day !in 1..31 || hour !in 0..23 || minute !in 0..59 || second !in 0..59) {
|
||||
return value
|
||||
}
|
||||
|
||||
val iso = buildString {
|
||||
append(year.toString().padStart(4, '0'))
|
||||
append('-')
|
||||
append(month.toString().padStart(2, '0'))
|
||||
append('-')
|
||||
append(day.toString().padStart(2, '0'))
|
||||
append('T')
|
||||
append(hour.toString().padStart(2, '0'))
|
||||
append(':')
|
||||
append(minute.toString().padStart(2, '0'))
|
||||
append(':')
|
||||
append(second.toString().padStart(2, '0'))
|
||||
append('Z')
|
||||
}
|
||||
return TraktPlatformClock.parseIsoDateTimeToEpochMs(iso) ?: value
|
||||
}
|
||||
|
||||
fun watchedItemKey(
|
||||
type: String,
|
||||
id: String,
|
||||
|
|
@ -47,3 +85,5 @@ fun watchedItemKey(
|
|||
episodeNumber = episode,
|
||||
)
|
||||
|
||||
private const val CompactWatchedTimestampMin = 19000101000000L
|
||||
private const val CompactWatchedTimestampMax = 29991231235959L
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import co.touchlab.kermit.Logger
|
|||
import com.nuvio.app.features.details.MetaDetails
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.features.trakt.WatchProgressSource
|
||||
import com.nuvio.app.features.trakt.shouldUseTraktProgress
|
||||
import com.nuvio.app.features.watching.sync.SupabaseWatchedSyncAdapter
|
||||
import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter
|
||||
import com.nuvio.app.features.watching.sync.WatchedSyncAdapter
|
||||
|
|
@ -42,8 +45,8 @@ object WatchedRepository {
|
|||
private var itemsByKey: MutableMap<String, WatchedItem> = mutableMapOf()
|
||||
internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter
|
||||
|
||||
private fun activeSyncAdapter(): WatchedSyncAdapter =
|
||||
if (TraktAuthRepository.isAuthenticated.value) TraktWatchedSyncAdapter else syncAdapter
|
||||
private fun activePullSyncAdapter(): WatchedSyncAdapter =
|
||||
if (shouldUseTraktWatchedSync()) TraktWatchedSyncAdapter else syncAdapter
|
||||
|
||||
fun ensureLoaded() {
|
||||
if (hasLoaded) return
|
||||
|
|
@ -72,21 +75,27 @@ object WatchedRepository {
|
|||
val items = runCatching {
|
||||
json.decodeFromString<StoredWatchedPayload>(payload).items
|
||||
}.getOrDefault(emptyList())
|
||||
itemsByKey = items.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }.toMutableMap()
|
||||
itemsByKey = items
|
||||
.map(WatchedItem::normalizedMarkedAt)
|
||||
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
|
||||
.toMutableMap()
|
||||
}
|
||||
|
||||
publish()
|
||||
}
|
||||
|
||||
suspend fun pullFromServer(profileId: Int) {
|
||||
TraktAuthRepository.ensureLoaded()
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
currentProfileId = profileId
|
||||
runCatching {
|
||||
val serverItems = activeSyncAdapter().pull(
|
||||
val serverItems = activePullSyncAdapter().pull(
|
||||
profileId = profileId,
|
||||
pageSize = watchedItemsPageSize,
|
||||
)
|
||||
|
||||
itemsByKey = serverItems
|
||||
.map(WatchedItem::normalizedMarkedAt)
|
||||
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
|
||||
.toMutableMap()
|
||||
hasLoaded = true
|
||||
|
|
@ -203,7 +212,7 @@ object WatchedRepository {
|
|||
runCatching {
|
||||
if (items.isEmpty()) return@runCatching
|
||||
val profileId = ProfileRepository.activeProfileId
|
||||
activeSyncAdapter().push(profileId = profileId, items = items)
|
||||
pushToActiveTargets(profileId = profileId, items = items)
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Failed to push watched items" }
|
||||
}
|
||||
|
|
@ -215,7 +224,7 @@ object WatchedRepository {
|
|||
runCatching {
|
||||
if (items.isEmpty()) return@runCatching
|
||||
val profileId = ProfileRepository.activeProfileId
|
||||
activeSyncAdapter().delete(profileId = profileId, items = items)
|
||||
deleteFromActiveTargets(profileId = profileId, items = items)
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Failed to push watched item delete" }
|
||||
}
|
||||
|
|
@ -223,7 +232,9 @@ object WatchedRepository {
|
|||
}
|
||||
|
||||
private fun publish() {
|
||||
val items = itemsByKey.values.sortedByDescending { it.markedAtEpochMs }
|
||||
val items = itemsByKey.values
|
||||
.map(WatchedItem::normalizedMarkedAt)
|
||||
.sortedByDescending { it.markedAtEpochMs }
|
||||
_uiState.value = WatchedUiState(
|
||||
items = items,
|
||||
watchedKeys = items.mapTo(linkedSetOf()) {
|
||||
|
|
@ -238,9 +249,55 @@ object WatchedRepository {
|
|||
currentProfileId,
|
||||
json.encodeToString(
|
||||
StoredWatchedPayload(
|
||||
items = itemsByKey.values.sortedByDescending { it.markedAtEpochMs },
|
||||
items = itemsByKey.values
|
||||
.map(WatchedItem::normalizedMarkedAt)
|
||||
.sortedByDescending { it.markedAtEpochMs },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun shouldUseTraktWatchedSync(): Boolean =
|
||||
shouldUseTraktWatchedSync(
|
||||
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
|
||||
source = TraktSettingsRepository.uiState.value.watchProgressSource,
|
||||
)
|
||||
|
||||
private suspend fun pushToActiveTargets(
|
||||
profileId: Int,
|
||||
items: Collection<WatchedItem>,
|
||||
) {
|
||||
if (shouldUseTraktWatchedSync()) {
|
||||
TraktWatchedSyncAdapter.push(profileId = profileId, items = items)
|
||||
return
|
||||
}
|
||||
|
||||
syncAdapter.push(profileId = profileId, items = items)
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
TraktWatchedSyncAdapter.push(profileId = profileId, items = items)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteFromActiveTargets(
|
||||
profileId: Int,
|
||||
items: Collection<WatchedItem>,
|
||||
) {
|
||||
if (shouldUseTraktWatchedSync()) {
|
||||
TraktWatchedSyncAdapter.delete(profileId = profileId, items = items)
|
||||
return
|
||||
}
|
||||
|
||||
syncAdapter.delete(profileId = profileId, items = items)
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
TraktWatchedSyncAdapter.delete(profileId = profileId, items = items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun shouldUseTraktWatchedSync(
|
||||
isAuthenticated: Boolean,
|
||||
source: WatchProgressSource,
|
||||
): Boolean = shouldUseTraktProgress(
|
||||
isAuthenticated = isAuthenticated,
|
||||
source = source,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@ package com.nuvio.app.features.watching.application
|
|||
import com.nuvio.app.features.details.MetaVideo
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.watched.WatchedItem
|
||||
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
|
||||
import com.nuvio.app.features.watched.watchedItemKey
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
import com.nuvio.app.features.watchprogress.continueWatchingEntries
|
||||
import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching
|
||||
import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode
|
||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||
import com.nuvio.app.features.watching.domain.WatchingProgressRecord
|
||||
import com.nuvio.app.features.watching.domain.WatchingWatchedRecord
|
||||
import com.nuvio.app.features.watching.domain.continueWatchingProgressEntries
|
||||
import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode
|
||||
|
||||
object WatchingState {
|
||||
|
|
@ -59,7 +61,9 @@ object WatchingState {
|
|||
add(WatchingContentRef(type = item.type, id = item.id))
|
||||
}
|
||||
}
|
||||
val progressRecords = progressEntries.map(WatchProgressEntry::toDomainProgressRecord)
|
||||
val progressRecords = progressEntries
|
||||
.filter { entry -> entry.shouldUseAsCompletedSeedForContinueWatching() }
|
||||
.map(WatchProgressEntry::toDomainProgressRecord)
|
||||
val watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord)
|
||||
return contentRefs.mapNotNull { content ->
|
||||
latestCompletedSeriesEpisode(
|
||||
|
|
@ -73,21 +77,9 @@ object WatchingState {
|
|||
|
||||
fun visibleContinueWatchingEntries(
|
||||
progressEntries: List<WatchProgressEntry>,
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
latestCompletedBySeries: Map<WatchingContentRef, WatchingCompletedEpisode>,
|
||||
): List<WatchProgressEntry> {
|
||||
val visibleIds = continueWatchingProgressEntries(
|
||||
progressRecords = progressEntries.map(WatchProgressEntry::toDomainProgressRecord),
|
||||
)
|
||||
.filter { record ->
|
||||
val latestCompleted = latestCompletedBySeries[record.content]
|
||||
latestCompleted == null || record.lastUpdatedEpochMs > latestCompleted.markedAtEpochMs
|
||||
}
|
||||
.mapTo(linkedSetOf()) { record -> record.videoId }
|
||||
|
||||
return progressEntries
|
||||
.filter { entry -> entry.videoId in visibleIds }
|
||||
.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
|
||||
}
|
||||
): List<WatchProgressEntry> = progressEntries.continueWatchingEntries()
|
||||
}
|
||||
|
||||
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
|
||||
|
|
@ -110,5 +102,5 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
|
|||
content = WatchingContentRef(type = type, id = id),
|
||||
seasonNumber = season,
|
||||
episodeNumber = episode,
|
||||
markedAtEpochMs = markedAtEpochMs,
|
||||
markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
|
|||
override suspend fun pull(profileId: Int): List<ProgressSyncRecord> {
|
||||
val params = buildJsonObject { put("p_profile_id", profileId) }
|
||||
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params)
|
||||
return result.decodeList<WatchProgressSyncEntry>().map { entry ->
|
||||
val serverEntries = result.decodeList<WatchProgressSyncEntry>()
|
||||
val records = serverEntries.map { entry ->
|
||||
ProgressSyncRecord(
|
||||
contentId = entry.contentId,
|
||||
contentType = entry.contentType,
|
||||
|
|
@ -32,6 +33,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
|
|||
lastWatched = entry.lastWatched,
|
||||
)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
override suspend fun push(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.watching.sync
|
|||
|
||||
import com.nuvio.app.core.network.SupabaseProvider
|
||||
import com.nuvio.app.features.watched.WatchedItem
|
||||
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
|
||||
import io.github.jan.supabase.postgrest.postgrest
|
||||
import io.github.jan.supabase.postgrest.rpc
|
||||
import kotlinx.serialization.SerialName
|
||||
|
|
@ -45,7 +46,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter {
|
|||
name = syncItem.title,
|
||||
season = syncItem.season,
|
||||
episode = syncItem.episode,
|
||||
markedAtEpochMs = syncItem.watchedAt,
|
||||
markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(syncItem.watchedAt),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -61,7 +62,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter {
|
|||
title = item.name,
|
||||
season = item.season,
|
||||
episode = item.episode,
|
||||
watchedAt = item.markedAtEpochMs,
|
||||
watchedAt = normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs),
|
||||
)
|
||||
}
|
||||
val params = buildJsonObject {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import com.nuvio.app.features.addons.httpGetTextWithHeaders
|
|||
import com.nuvio.app.features.addons.httpPostJsonWithHeaders
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktEpisodeMappingService
|
||||
import com.nuvio.app.features.trakt.TraktPlatformClock
|
||||
import com.nuvio.app.features.watched.WatchedItem
|
||||
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
|
|
@ -472,26 +474,18 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
|
|||
}
|
||||
|
||||
private fun rankedTimestamp(isoDate: String?): Long {
|
||||
val digits = isoDate
|
||||
?.filter(Char::isDigit)
|
||||
?.take(14)
|
||||
?.takeIf { it.length >= 8 }
|
||||
?.padEnd(14, '0')
|
||||
?.toLongOrNull()
|
||||
return digits ?: 0L
|
||||
return isoDate
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs)
|
||||
?: 0L
|
||||
}
|
||||
|
||||
private fun epochMsToIso(epochMs: Long): String {
|
||||
// Convert to a compact ISO 8601 UTC string.
|
||||
// Input is stored as a ranked-timestamp (YYYYMMDDHHmmss) in some places,
|
||||
// or a real epoch-ms. We only send when it looks like real epoch-ms.
|
||||
if (epochMs <= 0L) return "unknown"
|
||||
if (epochMs < 10_000_000_000L) {
|
||||
// Looks like seconds-based or ranked timestamp — send unknown
|
||||
return "unknown"
|
||||
}
|
||||
val normalizedEpochMs = normalizeWatchedMarkedAtEpochMs(epochMs)
|
||||
if (normalizedEpochMs <= 0L) return "unknown"
|
||||
if (normalizedEpochMs < 10_000_000_000L) return "unknown"
|
||||
// Real epoch ms → simple ISO via arithmetic
|
||||
val totalSeconds = epochMs / 1000
|
||||
val totalSeconds = normalizedEpochMs / 1000
|
||||
val s = (totalSeconds % 60).toInt()
|
||||
val m = ((totalSeconds / 60) % 60).toInt()
|
||||
val h = ((totalSeconds / 3600) % 24).toInt()
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ data class CachedNextUpItem(
|
|||
val episodeTitle: String? = null,
|
||||
val episodeThumbnail: String? = null,
|
||||
val pauseDescription: String? = null,
|
||||
val released: String? = null,
|
||||
val hasAired: Boolean = true,
|
||||
val lastWatched: Long,
|
||||
val sortTimestamp: Long,
|
||||
val seedSeason: Int? = null,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.watchprogress
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
|
@ -13,6 +14,12 @@ private data class StoredContinueWatchingPreferences(
|
|||
val isVisible: Boolean = true,
|
||||
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
|
||||
val upNextFromFurthestEpisode: Boolean = true,
|
||||
@SerialName("use_episode_thumbnails_in_cw")
|
||||
val useEpisodeThumbnails: Boolean = true,
|
||||
@SerialName("show_unaired_next_up")
|
||||
val showUnairedNextUp: Boolean = true,
|
||||
@SerialName("blur_continue_watching_next_up")
|
||||
val blurNextUp: Boolean = false,
|
||||
val dismissedNextUpKeys: Set<String> = emptySet(),
|
||||
val showResumePromptOnLaunch: Boolean = true,
|
||||
)
|
||||
|
|
@ -46,6 +53,9 @@ object ContinueWatchingPreferencesRepository {
|
|||
isVisible: Boolean,
|
||||
style: ContinueWatchingSectionStyle,
|
||||
upNextFromFurthestEpisode: Boolean,
|
||||
useEpisodeThumbnails: Boolean = true,
|
||||
showUnairedNextUp: Boolean = true,
|
||||
blurNextUp: Boolean = false,
|
||||
dismissedNextUpKeys: Set<String>,
|
||||
) {
|
||||
ensureLoaded()
|
||||
|
|
@ -53,6 +63,9 @@ object ContinueWatchingPreferencesRepository {
|
|||
isVisible = isVisible,
|
||||
style = style,
|
||||
upNextFromFurthestEpisode = upNextFromFurthestEpisode,
|
||||
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||
showUnairedNextUp = showUnairedNextUp,
|
||||
blurNextUp = blurNextUp,
|
||||
dismissedNextUpKeys = dismissedNextUpKeys
|
||||
.map(String::trim)
|
||||
.filter(String::isNotBlank)
|
||||
|
|
@ -79,6 +92,9 @@ object ContinueWatchingPreferencesRepository {
|
|||
isVisible = stored.isVisible,
|
||||
style = stored.style,
|
||||
upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode,
|
||||
useEpisodeThumbnails = stored.useEpisodeThumbnails,
|
||||
showUnairedNextUp = stored.showUnairedNextUp,
|
||||
blurNextUp = stored.blurNextUp,
|
||||
dismissedNextUpKeys = stored.dismissedNextUpKeys,
|
||||
showResumePromptOnLaunch = stored.showResumePromptOnLaunch,
|
||||
)
|
||||
|
|
@ -105,6 +121,24 @@ object ContinueWatchingPreferencesRepository {
|
|||
persist()
|
||||
}
|
||||
|
||||
fun setUseEpisodeThumbnails(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
_uiState.value = _uiState.value.copy(useEpisodeThumbnails = enabled)
|
||||
persist()
|
||||
}
|
||||
|
||||
fun setShowUnairedNextUp(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
_uiState.value = _uiState.value.copy(showUnairedNextUp = enabled)
|
||||
persist()
|
||||
}
|
||||
|
||||
fun setBlurNextUp(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
_uiState.value = _uiState.value.copy(blurNextUp = enabled)
|
||||
persist()
|
||||
}
|
||||
|
||||
fun addDismissedNextUpKey(key: String) {
|
||||
ensureLoaded()
|
||||
val normalizedKey = key.trim()
|
||||
|
|
@ -139,6 +173,9 @@ object ContinueWatchingPreferencesRepository {
|
|||
isVisible = _uiState.value.isVisible,
|
||||
style = _uiState.value.style,
|
||||
upNextFromFurthestEpisode = _uiState.value.upNextFromFurthestEpisode,
|
||||
useEpisodeThumbnails = _uiState.value.useEpisodeThumbnails,
|
||||
showUnairedNextUp = _uiState.value.showUnairedNextUp,
|
||||
blurNextUp = _uiState.value.blurNextUp,
|
||||
dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys,
|
||||
showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@ import com.nuvio.app.features.details.MetaVideo
|
|||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
internal const val WatchProgressCompletionPercentThreshold = 99.5f
|
||||
internal const val WatchProgressCompletionPercentThreshold = 90f
|
||||
internal const val WatchProgressTraktPlaybackNextUpSeedPercentThreshold = 95f
|
||||
internal const val WatchProgressSourceLocal = "local"
|
||||
internal const val WatchProgressSourceTraktPlayback = "trakt_playback"
|
||||
internal const val WatchProgressSourceTraktHistory = "trakt_history"
|
||||
internal const val WatchProgressSourceTraktShowProgress = "trakt_show_progress"
|
||||
|
||||
@Serializable
|
||||
enum class ContinueWatchingSectionStyle {
|
||||
|
|
@ -37,6 +42,7 @@ data class WatchProgressEntry(
|
|||
val lastSourceUrl: String? = null,
|
||||
val isCompleted: Boolean = false,
|
||||
val progressPercent: Float? = null,
|
||||
val source: String = WatchProgressSourceLocal,
|
||||
) {
|
||||
val normalizedProgressPercent: Float?
|
||||
get() = progressPercent?.coerceIn(0f, 100f)
|
||||
|
|
@ -150,6 +156,7 @@ data class ContinueWatchingItem(
|
|||
val episodeTitle: String? = null,
|
||||
val episodeThumbnail: String? = null,
|
||||
val pauseDescription: String? = null,
|
||||
val released: String? = null,
|
||||
val isNextUp: Boolean = false,
|
||||
val nextUpSeedSeasonNumber: Int? = null,
|
||||
val nextUpSeedEpisodeNumber: Int? = null,
|
||||
|
|
@ -163,6 +170,9 @@ data class ContinueWatchingPreferencesUiState(
|
|||
val isVisible: Boolean = true,
|
||||
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
|
||||
val upNextFromFurthestEpisode: Boolean = true,
|
||||
val useEpisodeThumbnails: Boolean = true,
|
||||
val showUnairedNextUp: Boolean = true,
|
||||
val blurNextUp: Boolean = false,
|
||||
val dismissedNextUpKeys: Set<String> = emptySet(),
|
||||
val showResumePromptOnLaunch: Boolean = true,
|
||||
)
|
||||
|
|
@ -204,6 +214,7 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
|
|||
episodeTitle = normalizedEntry.episodeTitle,
|
||||
episodeThumbnail = normalizedEntry.episodeThumbnail,
|
||||
pauseDescription = normalizedEntry.pauseDescription,
|
||||
released = null,
|
||||
isNextUp = false,
|
||||
nextUpSeedSeasonNumber = null,
|
||||
nextUpSeedEpisodeNumber = null,
|
||||
|
|
@ -241,6 +252,7 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem(
|
|||
episodeTitle = nextEpisode.title,
|
||||
episodeThumbnail = nextEpisode.thumbnail,
|
||||
pauseDescription = nextEpisode.overview,
|
||||
released = nextEpisode.released,
|
||||
isNextUp = true,
|
||||
nextUpSeedSeasonNumber = seasonNumber,
|
||||
nextUpSeedEpisodeNumber = episodeNumber,
|
||||
|
|
|
|||
|
|
@ -126,7 +126,9 @@ object WatchProgressRepository {
|
|||
TraktProgressRepository.ensureLoaded()
|
||||
currentProfileId = profileId
|
||||
|
||||
if (shouldUseTraktProgress()) {
|
||||
val useTraktProgress = shouldUseTraktProgress()
|
||||
|
||||
if (useTraktProgress) {
|
||||
runCatching { TraktProgressRepository.refreshNow() }
|
||||
.onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } }
|
||||
publish()
|
||||
|
|
@ -419,8 +421,9 @@ object WatchProgressRepository {
|
|||
|
||||
private fun publish() {
|
||||
val entries = currentEntries()
|
||||
val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs }
|
||||
_uiState.value = WatchProgressUiState(
|
||||
entries = entries.sortedByDescending { it.lastUpdatedEpochMs },
|
||||
entries = sortedEntries,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,15 +67,50 @@ internal fun List<WatchProgressEntry>.resumeEntryForSeries(metaId: String): Watc
|
|||
internal fun List<WatchProgressEntry>.continueWatchingEntries(
|
||||
limit: Int = ContinueWatchingLimit,
|
||||
): List<WatchProgressEntry> {
|
||||
val inProgressEntries = filter { entry -> entry.shouldTreatAsInProgressForContinueWatching() }
|
||||
val domainEntries = continueWatchingProgressEntries(
|
||||
progressRecords = map(WatchProgressEntry::toDomainProgressRecord),
|
||||
progressRecords = inProgressEntries.map(WatchProgressEntry::toDomainProgressRecord),
|
||||
limit = limit,
|
||||
)
|
||||
val ids = domainEntries.map { record -> record.videoId }.toSet()
|
||||
return filter { entry -> entry.videoId in ids }
|
||||
return inProgressEntries.filter { entry -> entry.videoId in ids }
|
||||
.sortedByDescending { it.lastUpdatedEpochMs }
|
||||
}
|
||||
|
||||
internal fun WatchProgressEntry.shouldTreatAsInProgressForContinueWatching(): Boolean {
|
||||
val entry = normalizedCompletion()
|
||||
if (entry.isEffectivelyCompleted) return false
|
||||
|
||||
val hasStartedPlayback = entry.lastPositionMs > 0L ||
|
||||
entry.normalizedProgressPercent?.let { it > 0f } == true
|
||||
if (!hasStartedPlayback) return false
|
||||
|
||||
return entry.source != WatchProgressSourceTraktHistory &&
|
||||
entry.source != WatchProgressSourceTraktShowProgress
|
||||
}
|
||||
|
||||
internal fun WatchProgressEntry.shouldUseAsCompletedSeedForContinueWatching(): Boolean {
|
||||
val entry = normalizedCompletion()
|
||||
if (isMalformedNextUpSeedContentId(entry.parentMetaId)) return false
|
||||
if (!entry.isEffectivelyCompleted) return false
|
||||
if (entry.source != WatchProgressSourceTraktPlayback) return true
|
||||
|
||||
val explicitPercent = entry.normalizedProgressPercent ?: return false
|
||||
return explicitPercent >= WatchProgressTraktPlaybackNextUpSeedPercentThreshold
|
||||
}
|
||||
|
||||
internal fun String?.isSeriesTypeForContinueWatching(): Boolean =
|
||||
equals("series", ignoreCase = true) || equals("tv", ignoreCase = true)
|
||||
|
||||
internal fun isMalformedNextUpSeedContentId(contentId: String?): Boolean {
|
||||
val trimmed = contentId?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return true
|
||||
return when (trimmed.lowercase()) {
|
||||
"tmdb", "imdb", "trakt", "tmdb:", "imdb:", "trakt:" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
|
||||
normalizedCompletion().let { entry ->
|
||||
WatchingProgressRecord(
|
||||
|
|
|
|||
|
|
@ -61,6 +61,29 @@ class HomeScreenTest {
|
|||
assertEquals("S1E5 • The Wolf and the Lion", result.single().subtitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build home continue watching items suppresses next up when series has in progress resume`() {
|
||||
val inProgress = progressEntry(
|
||||
videoId = "show:1:4",
|
||||
title = "Show",
|
||||
episodeNumber = 4,
|
||||
episodeTitle = "Current",
|
||||
lastUpdatedEpochMs = 200L,
|
||||
)
|
||||
val nextUp = continueWatchingItem(
|
||||
videoId = "show:1:5",
|
||||
subtitle = "Up Next • S1E5 • Next",
|
||||
)
|
||||
|
||||
val result = buildHomeContinueWatchingItems(
|
||||
visibleEntries = listOf(inProgress),
|
||||
nextUpItemsBySeries = mapOf("show" to (500L to nextUp)),
|
||||
)
|
||||
|
||||
assertEquals(listOf("show:1:4"), result.map(ContinueWatchingItem::videoId))
|
||||
assertEquals("S1E4 • Current", result.single().subtitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Trakt continue watching window filters old progress only when Trakt source is active`() {
|
||||
val oldEntry = progressEntry(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
package com.nuvio.app.features.watched
|
||||
|
||||
import com.nuvio.app.features.trakt.TraktPlatformClock
|
||||
import com.nuvio.app.features.trakt.WatchProgressSource
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class WatchedModelsTest {
|
||||
@Test
|
||||
fun `compact watched timestamp normalizes to epoch millis`() {
|
||||
val expected = TraktPlatformClock.parseIsoDateTimeToEpochMs("2026-04-25T10:02:00Z")
|
||||
|
||||
assertEquals(expected, normalizeWatchedMarkedAtEpochMs(20260425100200L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epoch watched timestamp is kept unchanged`() {
|
||||
assertEquals(1_778_060_222_000L, normalizeWatchedMarkedAtEpochMs(1_778_060_222_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Trakt watched sync follows selected watch progress source`() {
|
||||
assertTrue(
|
||||
shouldUseTraktWatchedSync(
|
||||
isAuthenticated = true,
|
||||
source = WatchProgressSource.TRAKT,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
shouldUseTraktWatchedSync(
|
||||
isAuthenticated = true,
|
||||
source = WatchProgressSource.NUVIO_SYNC,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
shouldUseTraktWatchedSync(
|
||||
isAuthenticated = false,
|
||||
source = WatchProgressSource.TRAKT,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package com.nuvio.app.features.watching.application
|
||||
|
||||
import com.nuvio.app.features.trakt.TraktPlatformClock
|
||||
import com.nuvio.app.features.watched.WatchedItem
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class WatchingStateTest {
|
||||
@Test
|
||||
fun `latest completed ignores Trakt playback below next up seed threshold`() {
|
||||
val almostCompletePlayback = entry(
|
||||
videoId = "show:1:4",
|
||||
seasonNumber = 1,
|
||||
episodeNumber = 4,
|
||||
progressPercent = 94f,
|
||||
source = WatchProgressSourceTraktPlayback,
|
||||
)
|
||||
|
||||
val result = WatchingState.latestCompletedBySeries(
|
||||
progressEntries = listOf(almostCompletePlayback),
|
||||
watchedItems = emptyList(),
|
||||
)
|
||||
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible continue watching keeps active resume when newer episode is completed`() {
|
||||
val resume = entry(
|
||||
videoId = "show:1:4",
|
||||
seasonNumber = 1,
|
||||
episodeNumber = 4,
|
||||
lastUpdatedEpochMs = 10L,
|
||||
)
|
||||
val completed = entry(
|
||||
videoId = "show:1:5",
|
||||
seasonNumber = 1,
|
||||
episodeNumber = 5,
|
||||
lastUpdatedEpochMs = 20L,
|
||||
isCompleted = true,
|
||||
)
|
||||
val latestCompleted = WatchingState.latestCompletedBySeries(
|
||||
progressEntries = listOf(resume, completed),
|
||||
watchedItems = emptyList(),
|
||||
)
|
||||
|
||||
val result = WatchingState.visibleContinueWatchingEntries(
|
||||
progressEntries = listOf(resume, completed),
|
||||
latestCompletedBySeries = latestCompleted,
|
||||
)
|
||||
|
||||
assertEquals(listOf("show:1:4"), result.map { it.videoId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `latest completed normalizes compact watched timestamps before sorting`() {
|
||||
val expected = TraktPlatformClock.parseIsoDateTimeToEpochMs("2026-04-25T10:02:00Z")
|
||||
|
||||
val result = WatchingState.latestCompletedBySeries(
|
||||
progressEntries = emptyList(),
|
||||
watchedItems = listOf(
|
||||
WatchedItem(
|
||||
id = "show",
|
||||
type = "series",
|
||||
name = "Show",
|
||||
season = 3,
|
||||
episode = 1,
|
||||
markedAtEpochMs = 20260425100200L,
|
||||
),
|
||||
),
|
||||
preferFurthestEpisode = false,
|
||||
)
|
||||
|
||||
assertEquals(expected, result.values.single().markedAtEpochMs)
|
||||
}
|
||||
|
||||
private fun entry(
|
||||
videoId: String,
|
||||
seasonNumber: Int?,
|
||||
episodeNumber: Int?,
|
||||
lastUpdatedEpochMs: Long = 1L,
|
||||
isCompleted: Boolean = false,
|
||||
progressPercent: Float? = null,
|
||||
source: String = "local",
|
||||
): WatchProgressEntry =
|
||||
WatchProgressEntry(
|
||||
contentType = "series",
|
||||
parentMetaId = "show",
|
||||
parentMetaType = "series",
|
||||
videoId = videoId,
|
||||
title = "Show",
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
lastPositionMs = 120_000L,
|
||||
durationMs = 1_000_000L,
|
||||
lastUpdatedEpochMs = lastUpdatedEpochMs,
|
||||
isCompleted = isCompleted,
|
||||
progressPercent = progressPercent,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
|
@ -118,6 +118,61 @@ class WatchProgressRulesTest {
|
|||
assertEquals(listOf("movie-progress"), result.map { it.videoId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continue watching keeps active resume even when a newer episode is completed`() {
|
||||
val inProgress = entry(
|
||||
videoId = "show:1:4",
|
||||
parentMetaId = "show",
|
||||
seasonNumber = 1,
|
||||
episodeNumber = 4,
|
||||
lastUpdatedEpochMs = 10L,
|
||||
)
|
||||
val completed = entry(
|
||||
videoId = "show:1:5",
|
||||
parentMetaId = "show",
|
||||
seasonNumber = 1,
|
||||
episodeNumber = 5,
|
||||
lastUpdatedEpochMs = 20L,
|
||||
isCompleted = true,
|
||||
)
|
||||
|
||||
val result = listOf(inProgress, completed).continueWatchingEntries()
|
||||
|
||||
assertEquals(listOf("show:1:4"), result.map { it.videoId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Trakt playback next up seeds require TV percent threshold`() {
|
||||
val belowSeedThreshold = entry(
|
||||
videoId = "show:1:4",
|
||||
parentMetaId = "show",
|
||||
seasonNumber = 1,
|
||||
episodeNumber = 4,
|
||||
progressPercent = 94f,
|
||||
source = WatchProgressSourceTraktPlayback,
|
||||
)
|
||||
val seed = belowSeedThreshold.copy(progressPercent = 95f)
|
||||
|
||||
assertFalse(belowSeedThreshold.shouldUseAsCompletedSeedForContinueWatching())
|
||||
assertTrue(seed.shouldUseAsCompletedSeedForContinueWatching())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Trakt history is not treated as active resume`() {
|
||||
val history = entry(
|
||||
videoId = "show:1:4",
|
||||
parentMetaId = "show",
|
||||
seasonNumber = 1,
|
||||
episodeNumber = 4,
|
||||
lastPositionMs = 1L,
|
||||
durationMs = 0L,
|
||||
progressPercent = 50f,
|
||||
source = WatchProgressSourceTraktHistory,
|
||||
)
|
||||
|
||||
assertFalse(history.shouldTreatAsInProgressForContinueWatching())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `codec normalizes completed entries inferred from percent`() {
|
||||
val payload = WatchProgressCodec.encodeEntries(
|
||||
|
|
@ -174,6 +229,7 @@ class WatchProgressRulesTest {
|
|||
durationMs: Long = 1_000_000L,
|
||||
isCompleted: Boolean = false,
|
||||
progressPercent: Float? = null,
|
||||
source: String = WatchProgressSourceLocal,
|
||||
): WatchProgressEntry =
|
||||
WatchProgressEntry(
|
||||
contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
|
||||
|
|
@ -188,5 +244,6 @@ class WatchProgressRulesTest {
|
|||
lastUpdatedEpochMs = lastUpdatedEpochMs,
|
||||
isCompleted = isCompleted,
|
||||
progressPercent = progressPercent,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue