mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 15:32:01 +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_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_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_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_card_style">Poster Card Style</string>
|
||||||
<string name="settings_continue_watching_section_on_launch">ON LAUNCH</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>
|
<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_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_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_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_home">HOME</string>
|
||||||
<string name="settings_content_discovery_section_sources">SOURCES</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>
|
<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_episode_style_list_description">Detail-first stacked cards</string>
|
||||||
<string name="settings_meta_episodes">Episodes</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_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_group_label">Group %1$d</string>
|
||||||
<string name="settings_meta_more_like_this">More like this</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>
|
<string name="settings_meta_more_like_this_description">TMDB recommendation backdrops on detail page</string>
|
||||||
|
|
|
||||||
|
|
@ -690,6 +690,7 @@ fun MetaDetailsScreen(
|
||||||
onTrailerClick = resolveTrailer,
|
onTrailerClick = resolveTrailer,
|
||||||
progressByVideoId = watchProgressUiState.byVideoId,
|
progressByVideoId = watchProgressUiState.byVideoId,
|
||||||
watchedKeys = watchedUiState.watchedKeys,
|
watchedKeys = watchedUiState.watchedKeys,
|
||||||
|
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
|
||||||
onEpisodeClick = onEpisodePlayClick,
|
onEpisodeClick = onEpisodePlayClick,
|
||||||
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
|
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
|
||||||
onOpenMeta = onOpenMeta,
|
onOpenMeta = onOpenMeta,
|
||||||
|
|
@ -970,6 +971,7 @@ private fun ConfiguredMetaSections(
|
||||||
onTrailerClick: (MetaTrailer) -> Unit,
|
onTrailerClick: (MetaTrailer) -> Unit,
|
||||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||||
watchedKeys: Set<String>,
|
watchedKeys: Set<String>,
|
||||||
|
blurUnwatchedEpisodes: Boolean,
|
||||||
onEpisodeClick: (MetaVideo) -> Unit,
|
onEpisodeClick: (MetaVideo) -> Unit,
|
||||||
onEpisodeLongPress: (MetaVideo) -> Unit,
|
onEpisodeLongPress: (MetaVideo) -> Unit,
|
||||||
onOpenMeta: ((MetaPreview) -> Unit)?,
|
onOpenMeta: ((MetaPreview) -> Unit)?,
|
||||||
|
|
@ -1062,6 +1064,7 @@ private fun ConfiguredMetaSections(
|
||||||
episodeCardStyle = settings.episodeCardStyle,
|
episodeCardStyle = settings.episodeCardStyle,
|
||||||
progressByVideoId = progressByVideoId,
|
progressByVideoId = progressByVideoId,
|
||||||
watchedKeys = watchedKeys,
|
watchedKeys = watchedKeys,
|
||||||
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
onEpisodeClick = onEpisodeClick,
|
onEpisodeClick = onEpisodeClick,
|
||||||
onEpisodeLongPress = onEpisodeLongPress,
|
onEpisodeLongPress = onEpisodeLongPress,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ data class MetaScreenSettingsUiState(
|
||||||
val cinematicBackground: Boolean = false,
|
val cinematicBackground: Boolean = false,
|
||||||
val tabLayout: Boolean = false,
|
val tabLayout: Boolean = false,
|
||||||
val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||||
|
val blurUnwatchedEpisodes: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class MetaEpisodeCardStyle {
|
enum class MetaEpisodeCardStyle {
|
||||||
|
|
@ -81,6 +82,8 @@ private data class StoredMetaScreenSettingsPayload(
|
||||||
@SerialName("tvStyleLayout")
|
@SerialName("tvStyleLayout")
|
||||||
val tabLayout: Boolean = false,
|
val tabLayout: Boolean = false,
|
||||||
val episodeCardStyle: String = "horizontal",
|
val episodeCardStyle: String = "horizontal",
|
||||||
|
@SerialName("blur_unwatched_episodes")
|
||||||
|
val blurUnwatchedEpisodes: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class MetaScreenSectionDefinition(
|
private data class MetaScreenSectionDefinition(
|
||||||
|
|
@ -156,6 +159,7 @@ object MetaScreenSettingsRepository {
|
||||||
private var cinematicBackground: Boolean = false
|
private var cinematicBackground: Boolean = false
|
||||||
private var tabLayout: Boolean = false
|
private var tabLayout: Boolean = false
|
||||||
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||||
|
private var blurUnwatchedEpisodes: Boolean = false
|
||||||
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
|
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
|
||||||
|
|
||||||
fun ensureLoaded() {
|
fun ensureLoaded() {
|
||||||
|
|
@ -172,6 +176,7 @@ object MetaScreenSettingsRepository {
|
||||||
tabLayout = parsed.tabLayout
|
tabLayout = parsed.tabLayout
|
||||||
episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle)
|
episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle)
|
||||||
?: MetaEpisodeCardStyle.Horizontal
|
?: MetaEpisodeCardStyle.Horizontal
|
||||||
|
blurUnwatchedEpisodes = parsed.blurUnwatchedEpisodes
|
||||||
preferences = parsed.items.mapNotNull { item ->
|
preferences = parsed.items.mapNotNull { item ->
|
||||||
val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null
|
val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null
|
||||||
key to item
|
key to item
|
||||||
|
|
@ -190,6 +195,7 @@ object MetaScreenSettingsRepository {
|
||||||
cinematicBackground = false
|
cinematicBackground = false
|
||||||
tabLayout = false
|
tabLayout = false
|
||||||
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||||
|
blurUnwatchedEpisodes = false
|
||||||
_uiState.value = MetaScreenSettingsUiState()
|
_uiState.value = MetaScreenSettingsUiState()
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
}
|
}
|
||||||
|
|
@ -215,6 +221,13 @@ object MetaScreenSettingsRepository {
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setBlurUnwatchedEpisodes(enabled: Boolean) {
|
||||||
|
ensureLoaded()
|
||||||
|
blurUnwatchedEpisodes = enabled
|
||||||
|
publish()
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) {
|
fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
if (!key.canBeTabbed) return
|
if (!key.canBeTabbed) return
|
||||||
|
|
@ -233,6 +246,8 @@ object MetaScreenSettingsRepository {
|
||||||
preferences.clear()
|
preferences.clear()
|
||||||
cinematicBackground = false
|
cinematicBackground = false
|
||||||
tabLayout = false
|
tabLayout = false
|
||||||
|
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||||
|
blurUnwatchedEpisodes = false
|
||||||
_uiState.value = MetaScreenSettingsUiState()
|
_uiState.value = MetaScreenSettingsUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,11 +256,13 @@ object MetaScreenSettingsRepository {
|
||||||
cinematicBackground: Boolean,
|
cinematicBackground: Boolean,
|
||||||
tabLayout: Boolean,
|
tabLayout: Boolean,
|
||||||
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||||
|
blurUnwatchedEpisodes: Boolean = false,
|
||||||
) {
|
) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
this.cinematicBackground = cinematicBackground
|
this.cinematicBackground = cinematicBackground
|
||||||
this.tabLayout = tabLayout
|
this.tabLayout = tabLayout
|
||||||
this.episodeCardStyle = episodeCardStyle
|
this.episodeCardStyle = episodeCardStyle
|
||||||
|
this.blurUnwatchedEpisodes = blurUnwatchedEpisodes
|
||||||
preferences = items.associate { item ->
|
preferences = items.associate { item ->
|
||||||
item.key to StoredMetaScreenSectionPreference(
|
item.key to StoredMetaScreenSectionPreference(
|
||||||
key = item.key.name,
|
key = item.key.name,
|
||||||
|
|
@ -271,6 +288,7 @@ object MetaScreenSettingsRepository {
|
||||||
cinematicBackground = false
|
cinematicBackground = false
|
||||||
tabLayout = false
|
tabLayout = false
|
||||||
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||||
|
blurUnwatchedEpisodes = false
|
||||||
normalizePreferences()
|
normalizePreferences()
|
||||||
publish()
|
publish()
|
||||||
persist()
|
persist()
|
||||||
|
|
@ -337,6 +355,7 @@ object MetaScreenSettingsRepository {
|
||||||
cinematicBackground = cinematicBackground,
|
cinematicBackground = cinematicBackground,
|
||||||
tabLayout = tabLayout,
|
tabLayout = tabLayout,
|
||||||
episodeCardStyle = episodeCardStyle,
|
episodeCardStyle = episodeCardStyle,
|
||||||
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,6 +367,7 @@ object MetaScreenSettingsRepository {
|
||||||
cinematicBackground = cinematicBackground,
|
cinematicBackground = cinematicBackground,
|
||||||
tabLayout = tabLayout,
|
tabLayout = tabLayout,
|
||||||
episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle),
|
episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle),
|
||||||
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.nuvio.app.features.details
|
package com.nuvio.app.features.details
|
||||||
|
|
||||||
import com.nuvio.app.features.watched.WatchedItem
|
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.watchprogress.WatchProgressEntry
|
||||||
import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode
|
import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode
|
||||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||||
|
|
@ -206,7 +207,7 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
|
||||||
content = WatchingContentRef(type = type, id = id),
|
content = WatchingContentRef(type = type, id = id),
|
||||||
seasonNumber = season,
|
seasonNumber = season,
|
||||||
episodeNumber = episode,
|
episodeNumber = episode,
|
||||||
markedAtEpochMs = markedAtEpochMs,
|
markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun WatchingSeriesPrimaryAction.toLegacySeriesPrimaryAction(): SeriesPrimaryAction =
|
private fun WatchingSeriesPrimaryAction.toLegacySeriesPrimaryAction(): SeriesPrimaryAction =
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.blur
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
@ -90,6 +91,7 @@ fun DetailSeriesContent(
|
||||||
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||||
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
|
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
|
||||||
watchedKeys: Set<String> = emptySet(),
|
watchedKeys: Set<String> = emptySet(),
|
||||||
|
blurUnwatchedEpisodes: Boolean = false,
|
||||||
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
|
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
|
||||||
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
|
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
|
|
@ -276,6 +278,7 @@ fun DetailSeriesContent(
|
||||||
watchedKeys = watchedKeys,
|
watchedKeys = watchedKeys,
|
||||||
fallbackImage = meta.background ?: meta.poster,
|
fallbackImage = meta.background ?: meta.poster,
|
||||||
progressByVideoId = progressByVideoId,
|
progressByVideoId = progressByVideoId,
|
||||||
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
preferredEpisodeNumber = preferredEpisodeNumber,
|
preferredEpisodeNumber = preferredEpisodeNumber,
|
||||||
onEpisodeClick = onEpisodeClick,
|
onEpisodeClick = onEpisodeClick,
|
||||||
onEpisodeLongPress = onEpisodeLongPress,
|
onEpisodeLongPress = onEpisodeLongPress,
|
||||||
|
|
@ -295,13 +298,14 @@ fun DetailSeriesContent(
|
||||||
video = episode,
|
video = episode,
|
||||||
fallbackImage = meta.background ?: meta.poster,
|
fallbackImage = meta.background ?: meta.poster,
|
||||||
progressEntry = progressByVideoId[episodeVideoId],
|
progressEntry = progressByVideoId[episodeVideoId],
|
||||||
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
|
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||||
WatchingState.isEpisodeWatched(
|
WatchingState.isEpisodeWatched(
|
||||||
watchedKeys = watchedKeys,
|
watchedKeys = watchedKeys,
|
||||||
metaType = meta.type,
|
metaType = meta.type,
|
||||||
metaId = meta.id,
|
metaId = meta.id,
|
||||||
episode = episode,
|
episode = episode,
|
||||||
),
|
),
|
||||||
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
sizing = sizing,
|
sizing = sizing,
|
||||||
onClick = { onEpisodeClick?.invoke(episode) },
|
onClick = { onEpisodeClick?.invoke(episode) },
|
||||||
onLongPress = { onEpisodeLongPress?.invoke(episode) },
|
onLongPress = { onEpisodeLongPress?.invoke(episode) },
|
||||||
|
|
@ -553,6 +557,7 @@ private fun EpisodeHorizontalRow(
|
||||||
watchedKeys: Set<String>,
|
watchedKeys: Set<String>,
|
||||||
fallbackImage: String?,
|
fallbackImage: String?,
|
||||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||||
|
blurUnwatchedEpisodes: Boolean,
|
||||||
preferredEpisodeNumber: Int? = null,
|
preferredEpisodeNumber: Int? = null,
|
||||||
onEpisodeClick: ((MetaVideo) -> Unit)?,
|
onEpisodeClick: ((MetaVideo) -> Unit)?,
|
||||||
onEpisodeLongPress: ((MetaVideo) -> Unit)?,
|
onEpisodeLongPress: ((MetaVideo) -> Unit)?,
|
||||||
|
|
@ -597,13 +602,14 @@ private fun EpisodeHorizontalRow(
|
||||||
video = episode,
|
video = episode,
|
||||||
fallbackImage = fallbackImage,
|
fallbackImage = fallbackImage,
|
||||||
progressEntry = progressByVideoId[episodeVideoId],
|
progressEntry = progressByVideoId[episodeVideoId],
|
||||||
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
|
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||||
WatchingState.isEpisodeWatched(
|
WatchingState.isEpisodeWatched(
|
||||||
watchedKeys = watchedKeys,
|
watchedKeys = watchedKeys,
|
||||||
metaType = metaType,
|
metaType = metaType,
|
||||||
metaId = parentMetaId,
|
metaId = parentMetaId,
|
||||||
episode = episode,
|
episode = episode,
|
||||||
),
|
),
|
||||||
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
metrics = rowMetrics,
|
metrics = rowMetrics,
|
||||||
onClick = { onEpisodeClick?.invoke(episode) },
|
onClick = { onEpisodeClick?.invoke(episode) },
|
||||||
onLongPress = { onEpisodeLongPress?.invoke(episode) },
|
onLongPress = { onEpisodeLongPress?.invoke(episode) },
|
||||||
|
|
@ -619,6 +625,7 @@ private fun EpisodeHorizontalCard(
|
||||||
fallbackImage: String?,
|
fallbackImage: String?,
|
||||||
progressEntry: WatchProgressEntry?,
|
progressEntry: WatchProgressEntry?,
|
||||||
isWatched: Boolean,
|
isWatched: Boolean,
|
||||||
|
blurUnwatchedEpisodes: Boolean,
|
||||||
metrics: EpisodeHorizontalCardMetrics,
|
metrics: EpisodeHorizontalCardMetrics,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
onLongPress: (() -> Unit)? = null,
|
onLongPress: (() -> Unit)? = null,
|
||||||
|
|
@ -642,11 +649,14 @@ private fun EpisodeHorizontalCard(
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val imageUrl = video.thumbnail ?: fallbackImage
|
val imageUrl = video.thumbnail ?: fallbackImage
|
||||||
|
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
|
||||||
if (imageUrl != null) {
|
if (imageUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageUrl,
|
model = imageUrl,
|
||||||
contentDescription = video.title,
|
contentDescription = video.title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -889,6 +899,7 @@ private fun EpisodeListCard(
|
||||||
fallbackImage: String?,
|
fallbackImage: String?,
|
||||||
progressEntry: WatchProgressEntry?,
|
progressEntry: WatchProgressEntry?,
|
||||||
isWatched: Boolean,
|
isWatched: Boolean,
|
||||||
|
blurUnwatchedEpisodes: Boolean,
|
||||||
sizing: SeriesContentSizing,
|
sizing: SeriesContentSizing,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
|
|
@ -923,11 +934,14 @@ private fun EpisodeListCard(
|
||||||
.clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)),
|
.clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)),
|
||||||
) {
|
) {
|
||||||
val imageUrl = video.thumbnail ?: fallbackImage
|
val imageUrl = video.thumbnail ?: fallbackImage
|
||||||
|
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
|
||||||
if (imageUrl != null) {
|
if (imageUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageUrl,
|
model = imageUrl,
|
||||||
contentDescription = video.title,
|
contentDescription = video.title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
} else {
|
} 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.CurrentDateProvider
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
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.nextUpDismissKey
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressClock
|
import com.nuvio.app.features.watchprogress.WatchProgressClock
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
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.watchprogress.toUpNextContinueWatchingItem
|
||||||
import com.nuvio.app.features.watching.application.WatchingState
|
import com.nuvio.app.features.watching.application.WatchingState
|
||||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
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.collection.CollectionRepository
|
||||||
import com.nuvio.app.features.profiles.ProfileRepository
|
import com.nuvio.app.features.profiles.ProfileRepository
|
||||||
import com.nuvio.app.features.home.components.HomeCollectionRowSection
|
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(
|
val visibleContinueWatchingEntries = remember(
|
||||||
effectiveWatchProgressEntries,
|
effectiveWatchProgressEntries,
|
||||||
latestCompletedBySeries,
|
latestCompletedBySeries,
|
||||||
|
|
@ -181,11 +186,28 @@ fun HomeScreen(
|
||||||
var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
|
var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
|
||||||
|
|
||||||
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() }
|
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 ->
|
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) {
|
if (nextUpDismissKey(cached.contentId, cached.seedSeason, cached.seedEpisode) in continueWatchingPreferences.dismissedNextUpKeys) {
|
||||||
return@mapNotNull null
|
return@mapNotNull null
|
||||||
}
|
}
|
||||||
|
if (!cached.hasAired && !continueWatchingPreferences.showUnairedNextUp) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
val item = cached.toContinueWatchingItem() ?: return@mapNotNull null
|
val item = cached.toContinueWatchingItem() ?: return@mapNotNull null
|
||||||
cached.contentId to (cached.sortTimestamp to item)
|
cached.contentId to (cached.sortTimestamp to item)
|
||||||
}.toMap()
|
}.toMap()
|
||||||
|
|
@ -264,7 +286,11 @@ fun HomeScreen(
|
||||||
HomeCatalogSettingsRepository.syncCollections(collections)
|
HomeCatalogSettingsRepository.syncCollections(collections)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(completedSeriesCandidates, metaProviderKey, isTraktProgressActive) {
|
LaunchedEffect(
|
||||||
|
completedSeriesCandidates,
|
||||||
|
metaProviderKey,
|
||||||
|
continueWatchingPreferences.showUnairedNextUp,
|
||||||
|
) {
|
||||||
if (completedSeriesCandidates.isEmpty()) {
|
if (completedSeriesCandidates.isEmpty()) {
|
||||||
nextUpItemsBySeries = emptyMap()
|
nextUpItemsBySeries = emptyMap()
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
|
|
@ -285,7 +311,7 @@ fun HomeScreen(
|
||||||
seasonNumber = completedEntry.seasonNumber,
|
seasonNumber = completedEntry.seasonNumber,
|
||||||
episodeNumber = completedEntry.episodeNumber,
|
episodeNumber = completedEntry.episodeNumber,
|
||||||
todayIsoDate = todayIsoDate,
|
todayIsoDate = todayIsoDate,
|
||||||
showUnairedNextUp = isTraktProgressActive,
|
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
|
||||||
) ?: return@withPermit null
|
) ?: return@withPermit null
|
||||||
val item = completedEntry.toContinueWatchingSeed(meta)
|
val item = completedEntry.toContinueWatchingSeed(meta)
|
||||||
.toUpNextContinueWatchingItem(nextEpisode)
|
.toUpNextContinueWatchingItem(nextEpisode)
|
||||||
|
|
@ -313,6 +339,10 @@ fun HomeScreen(
|
||||||
episodeTitle = item.episodeTitle,
|
episodeTitle = item.episodeTitle,
|
||||||
episodeThumbnail = item.episodeThumbnail,
|
episodeThumbnail = item.episodeThumbnail,
|
||||||
pauseDescription = item.pauseDescription,
|
pauseDescription = item.pauseDescription,
|
||||||
|
released = item.released,
|
||||||
|
hasAired = item.released?.let { released ->
|
||||||
|
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released)
|
||||||
|
} ?: true,
|
||||||
lastWatched = pair.first,
|
lastWatched = pair.first,
|
||||||
sortTimestamp = pair.first,
|
sortTimestamp = pair.first,
|
||||||
seedSeason = item.nextUpSeedSeasonNumber,
|
seedSeason = item.nextUpSeedSeasonNumber,
|
||||||
|
|
@ -431,6 +461,8 @@ fun HomeScreen(
|
||||||
HomeContinueWatchingSection(
|
HomeContinueWatchingSection(
|
||||||
items = continueWatchingItems,
|
items = continueWatchingItems,
|
||||||
style = continueWatchingPreferences.style,
|
style = continueWatchingPreferences.style,
|
||||||
|
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
|
||||||
|
blurNextUp = continueWatchingPreferences.blurNextUp,
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
sectionPadding = homeSectionPadding,
|
sectionPadding = homeSectionPadding,
|
||||||
layout = continueWatchingLayout,
|
layout = continueWatchingLayout,
|
||||||
|
|
@ -454,6 +486,8 @@ fun HomeScreen(
|
||||||
HomeContinueWatchingSection(
|
HomeContinueWatchingSection(
|
||||||
items = continueWatchingItems,
|
items = continueWatchingItems,
|
||||||
style = continueWatchingPreferences.style,
|
style = continueWatchingPreferences.style,
|
||||||
|
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
|
||||||
|
blurNextUp = continueWatchingPreferences.blurNextUp,
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
sectionPadding = homeSectionPadding,
|
sectionPadding = homeSectionPadding,
|
||||||
layout = continueWatchingLayout,
|
layout = continueWatchingLayout,
|
||||||
|
|
@ -496,6 +530,8 @@ fun HomeScreen(
|
||||||
HomeContinueWatchingSection(
|
HomeContinueWatchingSection(
|
||||||
items = continueWatchingItems,
|
items = continueWatchingItems,
|
||||||
style = continueWatchingPreferences.style,
|
style = continueWatchingPreferences.style,
|
||||||
|
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
|
||||||
|
blurNextUp = continueWatchingPreferences.blurNextUp,
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
sectionPadding = homeSectionPadding,
|
sectionPadding = homeSectionPadding,
|
||||||
layout = continueWatchingLayout,
|
layout = continueWatchingLayout,
|
||||||
|
|
@ -584,6 +620,13 @@ internal fun buildHomeContinueWatchingItems(
|
||||||
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
|
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
|
||||||
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
|
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
|
||||||
): List<ContinueWatchingItem> {
|
): List<ContinueWatchingItem> {
|
||||||
|
val inProgressSeriesIds = visibleEntries
|
||||||
|
.asSequence()
|
||||||
|
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
|
||||||
|
.map { entry -> entry.parentMetaId }
|
||||||
|
.filter(String::isNotBlank)
|
||||||
|
.toSet()
|
||||||
|
|
||||||
return buildList {
|
return buildList {
|
||||||
addAll(
|
addAll(
|
||||||
visibleEntries.map { entry ->
|
visibleEntries.map { entry ->
|
||||||
|
|
@ -596,7 +639,8 @@ internal fun buildHomeContinueWatchingItems(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
addAll(
|
addAll(
|
||||||
nextUpItemsBySeries.values.map { (lastUpdatedEpochMs, item) ->
|
nextUpItemsBySeries.values.mapNotNull { (lastUpdatedEpochMs, item) ->
|
||||||
|
if (item.parentMetaId in inProgressSeriesIds) return@mapNotNull null
|
||||||
HomeContinueWatchingCandidate(
|
HomeContinueWatchingCandidate(
|
||||||
lastUpdatedEpochMs = lastUpdatedEpochMs,
|
lastUpdatedEpochMs = lastUpdatedEpochMs,
|
||||||
item = item,
|
item = item,
|
||||||
|
|
@ -610,7 +654,7 @@ internal fun buildHomeContinueWatchingItems(
|
||||||
.thenByDescending { it.isProgressEntry },
|
.thenByDescending { it.isProgressEntry },
|
||||||
)
|
)
|
||||||
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
|
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
|
||||||
.distinctBy { it.item.videoId }
|
.distinctBy { candidate -> candidate.item.parentMetaId.ifBlank { candidate.item.videoId } }
|
||||||
.map(HomeContinueWatchingCandidate::item)
|
.map(HomeContinueWatchingCandidate::item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -668,6 +712,7 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
|
||||||
episodeTitle = episodeTitle,
|
episodeTitle = episodeTitle,
|
||||||
episodeThumbnail = episodeThumbnail,
|
episodeThumbnail = episodeThumbnail,
|
||||||
pauseDescription = pauseDescription,
|
pauseDescription = pauseDescription,
|
||||||
|
released = released,
|
||||||
isNextUp = true,
|
isNextUp = true,
|
||||||
nextUpSeedSeasonNumber = seedSeason,
|
nextUpSeedSeasonNumber = seedSeason,
|
||||||
nextUpSeedEpisodeNumber = seedEpisode,
|
nextUpSeedEpisodeNumber = seedEpisode,
|
||||||
|
|
@ -734,5 +779,6 @@ private fun ContinueWatchingItem.withFallbackMetadata(
|
||||||
episodeTitle = episodeTitle ?: fallback.episodeTitle,
|
episodeTitle = episodeTitle ?: fallback.episodeTitle,
|
||||||
episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail,
|
episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail,
|
||||||
pauseDescription = pauseDescription ?: fallback.pauseDescription,
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.blur
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
@ -50,10 +51,44 @@ import org.jetbrains.compose.resources.stringResource
|
||||||
private fun continueWatchingProgressPercent(progressFraction: Float): Int =
|
private fun continueWatchingProgressPercent(progressFraction: Float): Int =
|
||||||
(progressFraction * 100f).roundToInt().coerceIn(1, 99)
|
(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
|
@Composable
|
||||||
internal fun HomeContinueWatchingSection(
|
internal fun HomeContinueWatchingSection(
|
||||||
items: List<ContinueWatchingItem>,
|
items: List<ContinueWatchingItem>,
|
||||||
style: ContinueWatchingSectionStyle,
|
style: ContinueWatchingSectionStyle,
|
||||||
|
useEpisodeThumbnails: Boolean = true,
|
||||||
|
blurNextUp: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
sectionPadding: Dp? = null,
|
sectionPadding: Dp? = null,
|
||||||
layout: ContinueWatchingLayout? = null,
|
layout: ContinueWatchingLayout? = null,
|
||||||
|
|
@ -66,6 +101,8 @@ internal fun HomeContinueWatchingSection(
|
||||||
HomeContinueWatchingSectionContent(
|
HomeContinueWatchingSectionContent(
|
||||||
items = items,
|
items = items,
|
||||||
style = style,
|
style = style,
|
||||||
|
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||||
|
blurNextUp = blurNextUp,
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
sectionPadding = sectionPadding,
|
sectionPadding = sectionPadding,
|
||||||
layout = layout,
|
layout = layout,
|
||||||
|
|
@ -77,6 +114,8 @@ internal fun HomeContinueWatchingSection(
|
||||||
HomeContinueWatchingSectionContent(
|
HomeContinueWatchingSectionContent(
|
||||||
items = items,
|
items = items,
|
||||||
style = style,
|
style = style,
|
||||||
|
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||||
|
blurNextUp = blurNextUp,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
|
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
|
||||||
layout = rememberContinueWatchingLayout(maxWidth.value),
|
layout = rememberContinueWatchingLayout(maxWidth.value),
|
||||||
|
|
@ -91,6 +130,8 @@ internal fun HomeContinueWatchingSection(
|
||||||
private fun HomeContinueWatchingSectionContent(
|
private fun HomeContinueWatchingSectionContent(
|
||||||
items: List<ContinueWatchingItem>,
|
items: List<ContinueWatchingItem>,
|
||||||
style: ContinueWatchingSectionStyle,
|
style: ContinueWatchingSectionStyle,
|
||||||
|
useEpisodeThumbnails: Boolean,
|
||||||
|
blurNextUp: Boolean,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
sectionPadding: Dp,
|
sectionPadding: Dp,
|
||||||
layout: ContinueWatchingLayout,
|
layout: ContinueWatchingLayout,
|
||||||
|
|
@ -110,12 +151,16 @@ private fun HomeContinueWatchingSectionContent(
|
||||||
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
|
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
|
||||||
item = item,
|
item = item,
|
||||||
layout = layout,
|
layout = layout,
|
||||||
|
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||||
|
blurNextUp = blurNextUp,
|
||||||
onClick = onItemClick?.let { { it(item) } },
|
onClick = onItemClick?.let { { it(item) } },
|
||||||
onLongClick = onItemLongPress?.let { { it(item) } },
|
onLongClick = onItemLongPress?.let { { it(item) } },
|
||||||
)
|
)
|
||||||
ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard(
|
ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard(
|
||||||
item = item,
|
item = item,
|
||||||
layout = layout,
|
layout = layout,
|
||||||
|
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||||
|
blurNextUp = blurNextUp,
|
||||||
onClick = onItemClick?.let { { it(item) } },
|
onClick = onItemClick?.let { { it(item) } },
|
||||||
onLongClick = onItemLongPress?.let { { it(item) } },
|
onLongClick = onItemLongPress?.let { { it(item) } },
|
||||||
)
|
)
|
||||||
|
|
@ -273,6 +318,8 @@ private fun PosterCardPreview() {
|
||||||
private fun ContinueWatchingWideCard(
|
private fun ContinueWatchingWideCard(
|
||||||
item: ContinueWatchingItem,
|
item: ContinueWatchingItem,
|
||||||
layout: ContinueWatchingLayout,
|
layout: ContinueWatchingLayout,
|
||||||
|
useEpisodeThumbnails: Boolean,
|
||||||
|
blurNextUp: Boolean,
|
||||||
onClick: (() -> Unit)?,
|
onClick: (() -> Unit)?,
|
||||||
onLongClick: (() -> Unit)?,
|
onLongClick: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
|
|
@ -293,10 +340,12 @@ private fun ContinueWatchingWideCard(
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val artworkUrl = item.poster ?: item.background ?: item.imageUrl
|
val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
|
||||||
|
val artworkUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
|
||||||
ArtworkPanel(
|
ArtworkPanel(
|
||||||
imageUrl = artworkUrl,
|
imageUrl = artworkUrl,
|
||||||
width = layout.widePosterStripWidth,
|
width = layout.widePosterStripWidth,
|
||||||
|
blurred = shouldBlurArtwork,
|
||||||
modifier = Modifier.fillMaxHeight(),
|
modifier = Modifier.fillMaxHeight(),
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -384,6 +433,8 @@ private fun ContinueWatchingWideCard(
|
||||||
private fun ContinueWatchingPosterCard(
|
private fun ContinueWatchingPosterCard(
|
||||||
item: ContinueWatchingItem,
|
item: ContinueWatchingItem,
|
||||||
layout: ContinueWatchingLayout,
|
layout: ContinueWatchingLayout,
|
||||||
|
useEpisodeThumbnails: Boolean,
|
||||||
|
blurNextUp: Boolean,
|
||||||
onClick: (() -> Unit)?,
|
onClick: (() -> Unit)?,
|
||||||
onLongClick: (() -> Unit)?,
|
onLongClick: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
|
|
@ -404,12 +455,15 @@ private fun ContinueWatchingPosterCard(
|
||||||
)
|
)
|
||||||
.posterCardClickable(onClick = onClick, onLongClick = onLongClick),
|
.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) {
|
if (imageUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageUrl,
|
model = imageUrl,
|
||||||
contentDescription = item.title,
|
contentDescription = item.title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -489,6 +543,7 @@ private fun ContinueWatchingPosterCard(
|
||||||
private fun ArtworkPanel(
|
private fun ArtworkPanel(
|
||||||
imageUrl: String?,
|
imageUrl: String?,
|
||||||
width: Dp,
|
width: Dp,
|
||||||
|
blurred: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -500,7 +555,9 @@ private fun ArtworkPanel(
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageUrl,
|
model = imageUrl,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.then(if (blurred) Modifier.blur(18.dp) else Modifier),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.blur
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
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.details.MetaVideo
|
||||||
import com.nuvio.app.features.streams.StreamItem
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
import com.nuvio.app.features.streams.StreamsUiState
|
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 nuvio.composeapp.generated.resources.*
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
|
|
@ -72,8 +76,13 @@ import org.jetbrains.compose.resources.stringResource
|
||||||
fun PlayerEpisodesPanel(
|
fun PlayerEpisodesPanel(
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
episodes: List<MetaVideo>,
|
episodes: List<MetaVideo>,
|
||||||
|
parentMetaType: String,
|
||||||
|
parentMetaId: String,
|
||||||
currentSeason: Int?,
|
currentSeason: Int?,
|
||||||
currentEpisode: Int?,
|
currentEpisode: Int?,
|
||||||
|
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||||
|
watchedKeys: Set<String>,
|
||||||
|
blurUnwatchedEpisodes: Boolean,
|
||||||
// episode stream sub-view state
|
// episode stream sub-view state
|
||||||
episodeStreamsState: EpisodeStreamsPanelState,
|
episodeStreamsState: EpisodeStreamsPanelState,
|
||||||
onSeasonSelected: (Int) -> Unit,
|
onSeasonSelected: (Int) -> Unit,
|
||||||
|
|
@ -134,8 +143,13 @@ fun PlayerEpisodesPanel(
|
||||||
} else {
|
} else {
|
||||||
EpisodesListSubView(
|
EpisodesListSubView(
|
||||||
episodes = episodes,
|
episodes = episodes,
|
||||||
|
parentMetaType = parentMetaType,
|
||||||
|
parentMetaId = parentMetaId,
|
||||||
currentSeason = currentSeason,
|
currentSeason = currentSeason,
|
||||||
currentEpisode = currentEpisode,
|
currentEpisode = currentEpisode,
|
||||||
|
progressByVideoId = progressByVideoId,
|
||||||
|
watchedKeys = watchedKeys,
|
||||||
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
onSeasonSelected = onSeasonSelected,
|
onSeasonSelected = onSeasonSelected,
|
||||||
onEpisodeSelected = onEpisodeSelected,
|
onEpisodeSelected = onEpisodeSelected,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
|
|
@ -158,8 +172,13 @@ data class EpisodeStreamsPanelState(
|
||||||
@Composable
|
@Composable
|
||||||
private fun EpisodesListSubView(
|
private fun EpisodesListSubView(
|
||||||
episodes: List<MetaVideo>,
|
episodes: List<MetaVideo>,
|
||||||
|
parentMetaType: String,
|
||||||
|
parentMetaId: String,
|
||||||
currentSeason: Int?,
|
currentSeason: Int?,
|
||||||
currentEpisode: Int?,
|
currentEpisode: Int?,
|
||||||
|
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||||
|
watchedKeys: Set<String>,
|
||||||
|
blurUnwatchedEpisodes: Boolean,
|
||||||
onSeasonSelected: (Int) -> Unit,
|
onSeasonSelected: (Int) -> Unit,
|
||||||
onEpisodeSelected: (MetaVideo) -> Unit,
|
onEpisodeSelected: (MetaVideo) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
|
@ -296,9 +315,24 @@ private fun EpisodesListSubView(
|
||||||
key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" },
|
key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" },
|
||||||
) { _, episode ->
|
) { _, episode ->
|
||||||
val isCurrent = episode.season == currentSeason && episode.episode == currentEpisode
|
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(
|
EpisodeRow(
|
||||||
episode = episode,
|
episode = episode,
|
||||||
isCurrent = isCurrent,
|
isCurrent = isCurrent,
|
||||||
|
isWatched = isWatched,
|
||||||
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
onClick = { onEpisodeSelected(episode) },
|
onClick = { onEpisodeSelected(episode) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -311,9 +345,12 @@ private fun EpisodesListSubView(
|
||||||
private fun EpisodeRow(
|
private fun EpisodeRow(
|
||||||
episode: MetaVideo,
|
episode: MetaVideo,
|
||||||
isCurrent: Boolean,
|
isCurrent: Boolean,
|
||||||
|
isWatched: Boolean,
|
||||||
|
blurUnwatchedEpisodes: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched && !isCurrent
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -342,7 +379,8 @@ private fun EpisodeRow(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(80.dp)
|
.width(80.dp)
|
||||||
.height(48.dp)
|
.height(48.dp)
|
||||||
.clip(RoundedCornerShape(8.dp)),
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
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.details.MetaVideo
|
||||||
import com.nuvio.app.features.downloads.DownloadItem
|
import com.nuvio.app.features.downloads.DownloadItem
|
||||||
import com.nuvio.app.features.downloads.DownloadsRepository
|
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.StreamLinkCacheRepository
|
||||||
import com.nuvio.app.features.streams.StreamsUiState
|
import com.nuvio.app.features.streams.StreamsUiState
|
||||||
import com.nuvio.app.features.trakt.TraktScrobbleRepository
|
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.WatchProgressClock
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
|
import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||||
|
|
@ -143,6 +145,18 @@ fun PlayerScreen(
|
||||||
PlayerSettingsRepository.ensureLoaded()
|
PlayerSettingsRepository.ensureLoaded()
|
||||||
PlayerSettingsRepository.uiState
|
PlayerSettingsRepository.uiState
|
||||||
}.collectAsStateWithLifecycle()
|
}.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(
|
BoxWithConstraints(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -1799,8 +1813,13 @@ fun PlayerScreen(
|
||||||
PlayerEpisodesPanel(
|
PlayerEpisodesPanel(
|
||||||
visible = showEpisodesPanel,
|
visible = showEpisodesPanel,
|
||||||
episodes = allEpisodes,
|
episodes = allEpisodes,
|
||||||
|
parentMetaType = parentMetaType,
|
||||||
|
parentMetaId = parentMetaId,
|
||||||
currentSeason = activeSeasonNumber,
|
currentSeason = activeSeasonNumber,
|
||||||
currentEpisode = activeEpisodeNumber,
|
currentEpisode = activeEpisodeNumber,
|
||||||
|
progressByVideoId = watchProgressUiState.byVideoId,
|
||||||
|
watchedKeys = watchedUiState.watchedKeys,
|
||||||
|
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
|
||||||
episodeStreamsState = episodeStreamsPanelState.copy(
|
episodeStreamsState = episodeStreamsPanelState.copy(
|
||||||
streamsUiState = episodeStreamsRepoState,
|
streamsUiState = episodeStreamsRepoState,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
||||||
import nuvio.composeapp.generated.resources.Res
|
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_description
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title
|
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_card_style
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch
|
import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior
|
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_style_wide_description
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_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_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
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
|
|
@ -48,6 +54,9 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
||||||
isVisible: Boolean,
|
isVisible: Boolean,
|
||||||
style: ContinueWatchingSectionStyle,
|
style: ContinueWatchingSectionStyle,
|
||||||
upNextFromFurthestEpisode: Boolean,
|
upNextFromFurthestEpisode: Boolean,
|
||||||
|
useEpisodeThumbnails: Boolean,
|
||||||
|
showUnairedNextUp: Boolean,
|
||||||
|
blurNextUp: Boolean,
|
||||||
showResumePromptOnLaunch: Boolean,
|
showResumePromptOnLaunch: Boolean,
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
|
|
@ -84,6 +93,14 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
) {
|
) {
|
||||||
SettingsGroup(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(
|
SettingsSwitchRow(
|
||||||
title = stringResource(Res.string.settings_continue_watching_up_next_title),
|
title = stringResource(Res.string.settings_continue_watching_up_next_title),
|
||||||
description = stringResource(Res.string.settings_continue_watching_up_next_description),
|
description = stringResource(Res.string.settings_continue_watching_up_next_description),
|
||||||
|
|
@ -91,6 +108,24 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onCheckedChange = ContinueWatchingPreferencesRepository::setUpNextFromFurthestEpisode,
|
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_episode_style_list_description
|
||||||
import nuvio.composeapp.generated.resources.settings_meta_episodes
|
import nuvio.composeapp.generated.resources.settings_meta_episodes
|
||||||
import nuvio.composeapp.generated.resources.settings_meta_episodes_description
|
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_group_label
|
||||||
import nuvio.composeapp.generated.resources.settings_meta_more_like_this
|
import nuvio.composeapp.generated.resources.settings_meta_more_like_this
|
||||||
import nuvio.composeapp.generated.resources.settings_meta_more_like_this_description
|
import nuvio.composeapp.generated.resources.settings_meta_more_like_this_description
|
||||||
|
|
@ -130,6 +132,14 @@ internal fun LazyListScope.metaScreenSettingsContent(
|
||||||
selectedStyle = uiState.episodeCardStyle,
|
selectedStyle = uiState.episodeCardStyle,
|
||||||
onStyleSelected = MetaScreenSettingsRepository::setEpisodeCardStyle,
|
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,
|
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||||
style = continueWatchingPreferencesUiState.style,
|
style = continueWatchingPreferencesUiState.style,
|
||||||
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
||||||
|
useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
|
||||||
|
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
||||||
|
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -376,6 +376,9 @@ private fun MobileSettingsScreen(
|
||||||
isVisible = continueWatchingPreferencesUiState.isVisible,
|
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||||
style = continueWatchingPreferencesUiState.style,
|
style = continueWatchingPreferencesUiState.style,
|
||||||
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
||||||
|
useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
|
||||||
|
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
||||||
|
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||||
)
|
)
|
||||||
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
|
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
|
||||||
|
|
@ -614,6 +617,9 @@ private fun TabletSettingsScreen(
|
||||||
isVisible = continueWatchingPreferencesUiState.isVisible,
|
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||||
style = continueWatchingPreferencesUiState.style,
|
style = continueWatchingPreferencesUiState.style,
|
||||||
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
||||||
|
useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
|
||||||
|
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
||||||
|
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||||
)
|
)
|
||||||
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
|
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.httpGetTextWithHeaders
|
||||||
import com.nuvio.app.features.addons.httpRequestRaw
|
import com.nuvio.app.features.addons.httpRequestRaw
|
||||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
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.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.buildPlaybackVideoId
|
||||||
|
import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -29,7 +34,7 @@ import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
private const val BASE_URL = "https://api.trakt.tv"
|
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 HISTORY_LIMIT = 250
|
||||||
private const val METADATA_FETCH_TIMEOUT_MS = 3_500L
|
private const val METADATA_FETCH_TIMEOUT_MS = 3_500L
|
||||||
private const val METADATA_FETCH_CONCURRENCY = 5
|
private const val METADATA_FETCH_CONCURRENCY = 5
|
||||||
|
|
@ -113,8 +118,8 @@ object TraktProgressRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val historyEntries = runCatching {
|
val completedEntries = runCatching {
|
||||||
fetchHistoryEntries(headers)
|
fetchHistoryEntries(headers) + fetchWatchedShowSeedEntries(headers)
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
if (error is CancellationException) throw error
|
if (error is CancellationException) throw error
|
||||||
log.w { "Failed to fetch Trakt history snapshot: ${error.message}" }
|
log.w { "Failed to fetch Trakt history snapshot: ${error.message}" }
|
||||||
|
|
@ -122,7 +127,7 @@ object TraktProgressRepository {
|
||||||
|
|
||||||
if (!isLatestRefreshRequest(requestId)) return@launch
|
if (!isLatestRefreshRequest(requestId)) return@launch
|
||||||
|
|
||||||
val merged = mergeNewestByVideoId(playbackEntries + historyEntries)
|
val merged = mergeNewestByVideoId(playbackEntries + completedEntries)
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
entries = merged.sortedByDescending { it.lastUpdatedEpochMs },
|
entries = merged.sortedByDescending { it.lastUpdatedEpochMs },
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
|
@ -345,12 +350,32 @@ object TraktProgressRepository {
|
||||||
mergeNewestByVideoId(completedEpisodes + completedMovies)
|
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> {
|
private fun mergeNewestByVideoId(entries: List<WatchProgressEntry>): List<WatchProgressEntry> {
|
||||||
val mergedByVideoId = linkedMapOf<String, WatchProgressEntry>()
|
val mergedByVideoId = linkedMapOf<String, WatchProgressEntry>()
|
||||||
entries.forEach { rawEntry ->
|
entries.forEach { rawEntry ->
|
||||||
val entry = rawEntry.normalizedCompletion()
|
val entry = rawEntry.normalizedCompletion()
|
||||||
val existing = mergedByVideoId[entry.videoId]
|
val existing = mergedByVideoId[entry.videoId]
|
||||||
if (existing == null || entry.lastUpdatedEpochMs > existing.lastUpdatedEpochMs) {
|
if (existing == null || shouldReplaceProgressSnapshotEntry(existing = existing, candidate = entry)) {
|
||||||
mergedByVideoId[entry.videoId] = entry
|
mergedByVideoId[entry.videoId] = entry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -360,6 +385,18 @@ object TraktProgressRepository {
|
||||||
.sortedByDescending { it.lastUpdatedEpochMs }
|
.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(
|
private fun mergeEntriesPreferRichMetadata(
|
||||||
current: List<WatchProgressEntry>,
|
current: List<WatchProgressEntry>,
|
||||||
hydrated: List<WatchProgressEntry>,
|
hydrated: List<WatchProgressEntry>,
|
||||||
|
|
@ -499,6 +536,7 @@ object TraktProgressRepository {
|
||||||
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
|
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
|
||||||
isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD,
|
isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD,
|
||||||
progressPercent = progressPercent,
|
progressPercent = progressPercent,
|
||||||
|
source = WatchProgressSourceTraktPlayback,
|
||||||
).normalizedCompletion()
|
).normalizedCompletion()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -533,6 +571,7 @@ object TraktProgressRepository {
|
||||||
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
|
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
|
||||||
isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD,
|
isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD,
|
||||||
progressPercent = progressPercent,
|
progressPercent = progressPercent,
|
||||||
|
source = WatchProgressSourceTraktPlayback,
|
||||||
).normalizedCompletion()
|
).normalizedCompletion()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -564,6 +603,7 @@ object TraktProgressRepository {
|
||||||
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
|
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
|
||||||
isCompleted = true,
|
isCompleted = true,
|
||||||
progressPercent = 100f,
|
progressPercent = 100f,
|
||||||
|
source = WatchProgressSourceTraktHistory,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -583,6 +623,73 @@ object TraktProgressRepository {
|
||||||
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
|
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
|
||||||
isCompleted = true,
|
isCompleted = true,
|
||||||
progressPercent = 100f,
|
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 {
|
private fun rankedTimestamp(isoDate: String?, fallbackIndex: Int): Long {
|
||||||
val compactDigits = isoDate
|
isoDate
|
||||||
?.filter(Char::isDigit)
|
?.takeIf { it.isNotBlank() }
|
||||||
?.take(14)
|
?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs)
|
||||||
?.takeIf { it.length >= 8 }
|
?.let { return it }
|
||||||
?.padEnd(14, '0')
|
|
||||||
?.toLongOrNull()
|
|
||||||
if (compactDigits != null) return compactDigits
|
|
||||||
|
|
||||||
return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L)
|
return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -632,6 +735,32 @@ private data class TraktHistoryMovieItem(
|
||||||
@SerialName("movie") val movie: TraktMedia? = null,
|
@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
|
@Serializable
|
||||||
private data class TraktMedia(
|
private data class TraktMedia(
|
||||||
@SerialName("title") val title: String? = null,
|
@SerialName("title") val title: String? = null,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.nuvio.app.features.watched
|
package com.nuvio.app.features.watched
|
||||||
|
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
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.WatchingContentRef
|
||||||
import com.nuvio.app.features.watching.domain.watchedKey
|
import com.nuvio.app.features.watching.domain.watchedKey
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
@ -36,6 +37,43 @@ fun MetaPreview.toWatchedItem(markedAtEpochMs: Long): WatchedItem =
|
||||||
val WatchedItem.isEpisode: Boolean
|
val WatchedItem.isEpisode: Boolean
|
||||||
get() = season != null && episode != null
|
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(
|
fun watchedItemKey(
|
||||||
type: String,
|
type: String,
|
||||||
id: String,
|
id: String,
|
||||||
|
|
@ -47,3 +85,5 @@ fun watchedItemKey(
|
||||||
episodeNumber = episode,
|
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.details.MetaDetails
|
||||||
import com.nuvio.app.features.profiles.ProfileRepository
|
import com.nuvio.app.features.profiles.ProfileRepository
|
||||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
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.SupabaseWatchedSyncAdapter
|
||||||
import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter
|
import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter
|
||||||
import com.nuvio.app.features.watching.sync.WatchedSyncAdapter
|
import com.nuvio.app.features.watching.sync.WatchedSyncAdapter
|
||||||
|
|
@ -42,8 +45,8 @@ object WatchedRepository {
|
||||||
private var itemsByKey: MutableMap<String, WatchedItem> = mutableMapOf()
|
private var itemsByKey: MutableMap<String, WatchedItem> = mutableMapOf()
|
||||||
internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter
|
internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter
|
||||||
|
|
||||||
private fun activeSyncAdapter(): WatchedSyncAdapter =
|
private fun activePullSyncAdapter(): WatchedSyncAdapter =
|
||||||
if (TraktAuthRepository.isAuthenticated.value) TraktWatchedSyncAdapter else syncAdapter
|
if (shouldUseTraktWatchedSync()) TraktWatchedSyncAdapter else syncAdapter
|
||||||
|
|
||||||
fun ensureLoaded() {
|
fun ensureLoaded() {
|
||||||
if (hasLoaded) return
|
if (hasLoaded) return
|
||||||
|
|
@ -72,21 +75,27 @@ object WatchedRepository {
|
||||||
val items = runCatching {
|
val items = runCatching {
|
||||||
json.decodeFromString<StoredWatchedPayload>(payload).items
|
json.decodeFromString<StoredWatchedPayload>(payload).items
|
||||||
}.getOrDefault(emptyList())
|
}.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()
|
publish()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun pullFromServer(profileId: Int) {
|
suspend fun pullFromServer(profileId: Int) {
|
||||||
|
TraktAuthRepository.ensureLoaded()
|
||||||
|
TraktSettingsRepository.ensureLoaded()
|
||||||
currentProfileId = profileId
|
currentProfileId = profileId
|
||||||
runCatching {
|
runCatching {
|
||||||
val serverItems = activeSyncAdapter().pull(
|
val serverItems = activePullSyncAdapter().pull(
|
||||||
profileId = profileId,
|
profileId = profileId,
|
||||||
pageSize = watchedItemsPageSize,
|
pageSize = watchedItemsPageSize,
|
||||||
)
|
)
|
||||||
|
|
||||||
itemsByKey = serverItems
|
itemsByKey = serverItems
|
||||||
|
.map(WatchedItem::normalizedMarkedAt)
|
||||||
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
|
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
|
||||||
.toMutableMap()
|
.toMutableMap()
|
||||||
hasLoaded = true
|
hasLoaded = true
|
||||||
|
|
@ -203,7 +212,7 @@ object WatchedRepository {
|
||||||
runCatching {
|
runCatching {
|
||||||
if (items.isEmpty()) return@runCatching
|
if (items.isEmpty()) return@runCatching
|
||||||
val profileId = ProfileRepository.activeProfileId
|
val profileId = ProfileRepository.activeProfileId
|
||||||
activeSyncAdapter().push(profileId = profileId, items = items)
|
pushToActiveTargets(profileId = profileId, items = items)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
log.e(e) { "Failed to push watched items" }
|
log.e(e) { "Failed to push watched items" }
|
||||||
}
|
}
|
||||||
|
|
@ -215,7 +224,7 @@ object WatchedRepository {
|
||||||
runCatching {
|
runCatching {
|
||||||
if (items.isEmpty()) return@runCatching
|
if (items.isEmpty()) return@runCatching
|
||||||
val profileId = ProfileRepository.activeProfileId
|
val profileId = ProfileRepository.activeProfileId
|
||||||
activeSyncAdapter().delete(profileId = profileId, items = items)
|
deleteFromActiveTargets(profileId = profileId, items = items)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
log.e(e) { "Failed to push watched item delete" }
|
log.e(e) { "Failed to push watched item delete" }
|
||||||
}
|
}
|
||||||
|
|
@ -223,7 +232,9 @@ object WatchedRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun publish() {
|
private fun publish() {
|
||||||
val items = itemsByKey.values.sortedByDescending { it.markedAtEpochMs }
|
val items = itemsByKey.values
|
||||||
|
.map(WatchedItem::normalizedMarkedAt)
|
||||||
|
.sortedByDescending { it.markedAtEpochMs }
|
||||||
_uiState.value = WatchedUiState(
|
_uiState.value = WatchedUiState(
|
||||||
items = items,
|
items = items,
|
||||||
watchedKeys = items.mapTo(linkedSetOf()) {
|
watchedKeys = items.mapTo(linkedSetOf()) {
|
||||||
|
|
@ -238,9 +249,55 @@ object WatchedRepository {
|
||||||
currentProfileId,
|
currentProfileId,
|
||||||
json.encodeToString(
|
json.encodeToString(
|
||||||
StoredWatchedPayload(
|
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.details.MetaVideo
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
import com.nuvio.app.features.watched.WatchedItem
|
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.watched.watchedItemKey
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
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.WatchingCompletedEpisode
|
||||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||||
import com.nuvio.app.features.watching.domain.WatchingProgressRecord
|
import com.nuvio.app.features.watching.domain.WatchingProgressRecord
|
||||||
import com.nuvio.app.features.watching.domain.WatchingWatchedRecord
|
import com.nuvio.app.features.watching.domain.WatchingWatchedRecord
|
||||||
import com.nuvio.app.features.watching.domain.continueWatchingProgressEntries
|
|
||||||
import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode
|
import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode
|
||||||
|
|
||||||
object WatchingState {
|
object WatchingState {
|
||||||
|
|
@ -59,7 +61,9 @@ object WatchingState {
|
||||||
add(WatchingContentRef(type = item.type, id = item.id))
|
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)
|
val watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord)
|
||||||
return contentRefs.mapNotNull { content ->
|
return contentRefs.mapNotNull { content ->
|
||||||
latestCompletedSeriesEpisode(
|
latestCompletedSeriesEpisode(
|
||||||
|
|
@ -73,21 +77,9 @@ object WatchingState {
|
||||||
|
|
||||||
fun visibleContinueWatchingEntries(
|
fun visibleContinueWatchingEntries(
|
||||||
progressEntries: List<WatchProgressEntry>,
|
progressEntries: List<WatchProgressEntry>,
|
||||||
|
@Suppress("UNUSED_PARAMETER")
|
||||||
latestCompletedBySeries: Map<WatchingContentRef, WatchingCompletedEpisode>,
|
latestCompletedBySeries: Map<WatchingContentRef, WatchingCompletedEpisode>,
|
||||||
): List<WatchProgressEntry> {
|
): List<WatchProgressEntry> = progressEntries.continueWatchingEntries()
|
||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
|
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
|
||||||
|
|
@ -110,5 +102,5 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
|
||||||
content = WatchingContentRef(type = type, id = id),
|
content = WatchingContentRef(type = type, id = id),
|
||||||
seasonNumber = season,
|
seasonNumber = season,
|
||||||
episodeNumber = episode,
|
episodeNumber = episode,
|
||||||
markedAtEpochMs = markedAtEpochMs,
|
markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
|
||||||
override suspend fun pull(profileId: Int): List<ProgressSyncRecord> {
|
override suspend fun pull(profileId: Int): List<ProgressSyncRecord> {
|
||||||
val params = buildJsonObject { put("p_profile_id", profileId) }
|
val params = buildJsonObject { put("p_profile_id", profileId) }
|
||||||
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params)
|
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(
|
ProgressSyncRecord(
|
||||||
contentId = entry.contentId,
|
contentId = entry.contentId,
|
||||||
contentType = entry.contentType,
|
contentType = entry.contentType,
|
||||||
|
|
@ -32,6 +33,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
|
||||||
lastWatched = entry.lastWatched,
|
lastWatched = entry.lastWatched,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
return records
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun push(
|
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.core.network.SupabaseProvider
|
||||||
import com.nuvio.app.features.watched.WatchedItem
|
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.postgrest
|
||||||
import io.github.jan.supabase.postgrest.rpc
|
import io.github.jan.supabase.postgrest.rpc
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
|
|
@ -45,7 +46,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter {
|
||||||
name = syncItem.title,
|
name = syncItem.title,
|
||||||
season = syncItem.season,
|
season = syncItem.season,
|
||||||
episode = syncItem.episode,
|
episode = syncItem.episode,
|
||||||
markedAtEpochMs = syncItem.watchedAt,
|
markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(syncItem.watchedAt),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +62,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter {
|
||||||
title = item.name,
|
title = item.name,
|
||||||
season = item.season,
|
season = item.season,
|
||||||
episode = item.episode,
|
episode = item.episode,
|
||||||
watchedAt = item.markedAtEpochMs,
|
watchedAt = normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val params = buildJsonObject {
|
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.addons.httpPostJsonWithHeaders
|
||||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||||
import com.nuvio.app.features.trakt.TraktEpisodeMappingService
|
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.WatchedItem
|
||||||
|
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
|
@ -472,26 +474,18 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rankedTimestamp(isoDate: String?): Long {
|
private fun rankedTimestamp(isoDate: String?): Long {
|
||||||
val digits = isoDate
|
return isoDate
|
||||||
?.filter(Char::isDigit)
|
?.takeIf { it.isNotBlank() }
|
||||||
?.take(14)
|
?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs)
|
||||||
?.takeIf { it.length >= 8 }
|
?: 0L
|
||||||
?.padEnd(14, '0')
|
|
||||||
?.toLongOrNull()
|
|
||||||
return digits ?: 0L
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun epochMsToIso(epochMs: Long): String {
|
private fun epochMsToIso(epochMs: Long): String {
|
||||||
// Convert to a compact ISO 8601 UTC string.
|
val normalizedEpochMs = normalizeWatchedMarkedAtEpochMs(epochMs)
|
||||||
// Input is stored as a ranked-timestamp (YYYYMMDDHHmmss) in some places,
|
if (normalizedEpochMs <= 0L) return "unknown"
|
||||||
// or a real epoch-ms. We only send when it looks like real epoch-ms.
|
if (normalizedEpochMs < 10_000_000_000L) return "unknown"
|
||||||
if (epochMs <= 0L) return "unknown"
|
|
||||||
if (epochMs < 10_000_000_000L) {
|
|
||||||
// Looks like seconds-based or ranked timestamp — send unknown
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
// Real epoch ms → simple ISO via arithmetic
|
// Real epoch ms → simple ISO via arithmetic
|
||||||
val totalSeconds = epochMs / 1000
|
val totalSeconds = normalizedEpochMs / 1000
|
||||||
val s = (totalSeconds % 60).toInt()
|
val s = (totalSeconds % 60).toInt()
|
||||||
val m = ((totalSeconds / 60) % 60).toInt()
|
val m = ((totalSeconds / 60) % 60).toInt()
|
||||||
val h = ((totalSeconds / 3600) % 24).toInt()
|
val h = ((totalSeconds / 3600) % 24).toInt()
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ data class CachedNextUpItem(
|
||||||
val episodeTitle: String? = null,
|
val episodeTitle: String? = null,
|
||||||
val episodeThumbnail: String? = null,
|
val episodeThumbnail: String? = null,
|
||||||
val pauseDescription: String? = null,
|
val pauseDescription: String? = null,
|
||||||
|
val released: String? = null,
|
||||||
|
val hasAired: Boolean = true,
|
||||||
val lastWatched: Long,
|
val lastWatched: Long,
|
||||||
val sortTimestamp: Long,
|
val sortTimestamp: Long,
|
||||||
val seedSeason: Int? = null,
|
val seedSeason: Int? = null,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.watchprogress
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
|
|
@ -13,6 +14,12 @@ private data class StoredContinueWatchingPreferences(
|
||||||
val isVisible: Boolean = true,
|
val isVisible: Boolean = true,
|
||||||
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
|
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
|
||||||
val upNextFromFurthestEpisode: Boolean = true,
|
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 dismissedNextUpKeys: Set<String> = emptySet(),
|
||||||
val showResumePromptOnLaunch: Boolean = true,
|
val showResumePromptOnLaunch: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
@ -46,6 +53,9 @@ object ContinueWatchingPreferencesRepository {
|
||||||
isVisible: Boolean,
|
isVisible: Boolean,
|
||||||
style: ContinueWatchingSectionStyle,
|
style: ContinueWatchingSectionStyle,
|
||||||
upNextFromFurthestEpisode: Boolean,
|
upNextFromFurthestEpisode: Boolean,
|
||||||
|
useEpisodeThumbnails: Boolean = true,
|
||||||
|
showUnairedNextUp: Boolean = true,
|
||||||
|
blurNextUp: Boolean = false,
|
||||||
dismissedNextUpKeys: Set<String>,
|
dismissedNextUpKeys: Set<String>,
|
||||||
) {
|
) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
|
|
@ -53,6 +63,9 @@ object ContinueWatchingPreferencesRepository {
|
||||||
isVisible = isVisible,
|
isVisible = isVisible,
|
||||||
style = style,
|
style = style,
|
||||||
upNextFromFurthestEpisode = upNextFromFurthestEpisode,
|
upNextFromFurthestEpisode = upNextFromFurthestEpisode,
|
||||||
|
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||||
|
showUnairedNextUp = showUnairedNextUp,
|
||||||
|
blurNextUp = blurNextUp,
|
||||||
dismissedNextUpKeys = dismissedNextUpKeys
|
dismissedNextUpKeys = dismissedNextUpKeys
|
||||||
.map(String::trim)
|
.map(String::trim)
|
||||||
.filter(String::isNotBlank)
|
.filter(String::isNotBlank)
|
||||||
|
|
@ -79,6 +92,9 @@ object ContinueWatchingPreferencesRepository {
|
||||||
isVisible = stored.isVisible,
|
isVisible = stored.isVisible,
|
||||||
style = stored.style,
|
style = stored.style,
|
||||||
upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode,
|
upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode,
|
||||||
|
useEpisodeThumbnails = stored.useEpisodeThumbnails,
|
||||||
|
showUnairedNextUp = stored.showUnairedNextUp,
|
||||||
|
blurNextUp = stored.blurNextUp,
|
||||||
dismissedNextUpKeys = stored.dismissedNextUpKeys,
|
dismissedNextUpKeys = stored.dismissedNextUpKeys,
|
||||||
showResumePromptOnLaunch = stored.showResumePromptOnLaunch,
|
showResumePromptOnLaunch = stored.showResumePromptOnLaunch,
|
||||||
)
|
)
|
||||||
|
|
@ -105,6 +121,24 @@ object ContinueWatchingPreferencesRepository {
|
||||||
persist()
|
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) {
|
fun addDismissedNextUpKey(key: String) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
val normalizedKey = key.trim()
|
val normalizedKey = key.trim()
|
||||||
|
|
@ -139,6 +173,9 @@ object ContinueWatchingPreferencesRepository {
|
||||||
isVisible = _uiState.value.isVisible,
|
isVisible = _uiState.value.isVisible,
|
||||||
style = _uiState.value.style,
|
style = _uiState.value.style,
|
||||||
upNextFromFurthestEpisode = _uiState.value.upNextFromFurthestEpisode,
|
upNextFromFurthestEpisode = _uiState.value.upNextFromFurthestEpisode,
|
||||||
|
useEpisodeThumbnails = _uiState.value.useEpisodeThumbnails,
|
||||||
|
showUnairedNextUp = _uiState.value.showUnairedNextUp,
|
||||||
|
blurNextUp = _uiState.value.blurNextUp,
|
||||||
dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys,
|
dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys,
|
||||||
showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch,
|
showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,12 @@ import com.nuvio.app.features.details.MetaVideo
|
||||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||||
import kotlinx.serialization.Serializable
|
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
|
@Serializable
|
||||||
enum class ContinueWatchingSectionStyle {
|
enum class ContinueWatchingSectionStyle {
|
||||||
|
|
@ -37,6 +42,7 @@ data class WatchProgressEntry(
|
||||||
val lastSourceUrl: String? = null,
|
val lastSourceUrl: String? = null,
|
||||||
val isCompleted: Boolean = false,
|
val isCompleted: Boolean = false,
|
||||||
val progressPercent: Float? = null,
|
val progressPercent: Float? = null,
|
||||||
|
val source: String = WatchProgressSourceLocal,
|
||||||
) {
|
) {
|
||||||
val normalizedProgressPercent: Float?
|
val normalizedProgressPercent: Float?
|
||||||
get() = progressPercent?.coerceIn(0f, 100f)
|
get() = progressPercent?.coerceIn(0f, 100f)
|
||||||
|
|
@ -150,6 +156,7 @@ data class ContinueWatchingItem(
|
||||||
val episodeTitle: String? = null,
|
val episodeTitle: String? = null,
|
||||||
val episodeThumbnail: String? = null,
|
val episodeThumbnail: String? = null,
|
||||||
val pauseDescription: String? = null,
|
val pauseDescription: String? = null,
|
||||||
|
val released: String? = null,
|
||||||
val isNextUp: Boolean = false,
|
val isNextUp: Boolean = false,
|
||||||
val nextUpSeedSeasonNumber: Int? = null,
|
val nextUpSeedSeasonNumber: Int? = null,
|
||||||
val nextUpSeedEpisodeNumber: Int? = null,
|
val nextUpSeedEpisodeNumber: Int? = null,
|
||||||
|
|
@ -163,6 +170,9 @@ data class ContinueWatchingPreferencesUiState(
|
||||||
val isVisible: Boolean = true,
|
val isVisible: Boolean = true,
|
||||||
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
|
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
|
||||||
val upNextFromFurthestEpisode: Boolean = true,
|
val upNextFromFurthestEpisode: Boolean = true,
|
||||||
|
val useEpisodeThumbnails: Boolean = true,
|
||||||
|
val showUnairedNextUp: Boolean = true,
|
||||||
|
val blurNextUp: Boolean = false,
|
||||||
val dismissedNextUpKeys: Set<String> = emptySet(),
|
val dismissedNextUpKeys: Set<String> = emptySet(),
|
||||||
val showResumePromptOnLaunch: Boolean = true,
|
val showResumePromptOnLaunch: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
@ -204,6 +214,7 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
|
||||||
episodeTitle = normalizedEntry.episodeTitle,
|
episodeTitle = normalizedEntry.episodeTitle,
|
||||||
episodeThumbnail = normalizedEntry.episodeThumbnail,
|
episodeThumbnail = normalizedEntry.episodeThumbnail,
|
||||||
pauseDescription = normalizedEntry.pauseDescription,
|
pauseDescription = normalizedEntry.pauseDescription,
|
||||||
|
released = null,
|
||||||
isNextUp = false,
|
isNextUp = false,
|
||||||
nextUpSeedSeasonNumber = null,
|
nextUpSeedSeasonNumber = null,
|
||||||
nextUpSeedEpisodeNumber = null,
|
nextUpSeedEpisodeNumber = null,
|
||||||
|
|
@ -241,6 +252,7 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem(
|
||||||
episodeTitle = nextEpisode.title,
|
episodeTitle = nextEpisode.title,
|
||||||
episodeThumbnail = nextEpisode.thumbnail,
|
episodeThumbnail = nextEpisode.thumbnail,
|
||||||
pauseDescription = nextEpisode.overview,
|
pauseDescription = nextEpisode.overview,
|
||||||
|
released = nextEpisode.released,
|
||||||
isNextUp = true,
|
isNextUp = true,
|
||||||
nextUpSeedSeasonNumber = seasonNumber,
|
nextUpSeedSeasonNumber = seasonNumber,
|
||||||
nextUpSeedEpisodeNumber = episodeNumber,
|
nextUpSeedEpisodeNumber = episodeNumber,
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,9 @@ object WatchProgressRepository {
|
||||||
TraktProgressRepository.ensureLoaded()
|
TraktProgressRepository.ensureLoaded()
|
||||||
currentProfileId = profileId
|
currentProfileId = profileId
|
||||||
|
|
||||||
if (shouldUseTraktProgress()) {
|
val useTraktProgress = shouldUseTraktProgress()
|
||||||
|
|
||||||
|
if (useTraktProgress) {
|
||||||
runCatching { TraktProgressRepository.refreshNow() }
|
runCatching { TraktProgressRepository.refreshNow() }
|
||||||
.onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } }
|
.onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } }
|
||||||
publish()
|
publish()
|
||||||
|
|
@ -419,8 +421,9 @@ object WatchProgressRepository {
|
||||||
|
|
||||||
private fun publish() {
|
private fun publish() {
|
||||||
val entries = currentEntries()
|
val entries = currentEntries()
|
||||||
|
val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs }
|
||||||
_uiState.value = WatchProgressUiState(
|
_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(
|
internal fun List<WatchProgressEntry>.continueWatchingEntries(
|
||||||
limit: Int = ContinueWatchingLimit,
|
limit: Int = ContinueWatchingLimit,
|
||||||
): List<WatchProgressEntry> {
|
): List<WatchProgressEntry> {
|
||||||
|
val inProgressEntries = filter { entry -> entry.shouldTreatAsInProgressForContinueWatching() }
|
||||||
val domainEntries = continueWatchingProgressEntries(
|
val domainEntries = continueWatchingProgressEntries(
|
||||||
progressRecords = map(WatchProgressEntry::toDomainProgressRecord),
|
progressRecords = inProgressEntries.map(WatchProgressEntry::toDomainProgressRecord),
|
||||||
limit = limit,
|
limit = limit,
|
||||||
)
|
)
|
||||||
val ids = domainEntries.map { record -> record.videoId }.toSet()
|
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 }
|
.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 =
|
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
|
||||||
normalizedCompletion().let { entry ->
|
normalizedCompletion().let { entry ->
|
||||||
WatchingProgressRecord(
|
WatchingProgressRecord(
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,29 @@ class HomeScreenTest {
|
||||||
assertEquals("S1E5 • The Wolf and the Lion", result.single().subtitle)
|
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
|
@Test
|
||||||
fun `Trakt continue watching window filters old progress only when Trakt source is active`() {
|
fun `Trakt continue watching window filters old progress only when Trakt source is active`() {
|
||||||
val oldEntry = progressEntry(
|
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 })
|
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
|
@Test
|
||||||
fun `codec normalizes completed entries inferred from percent`() {
|
fun `codec normalizes completed entries inferred from percent`() {
|
||||||
val payload = WatchProgressCodec.encodeEntries(
|
val payload = WatchProgressCodec.encodeEntries(
|
||||||
|
|
@ -174,6 +229,7 @@ class WatchProgressRulesTest {
|
||||||
durationMs: Long = 1_000_000L,
|
durationMs: Long = 1_000_000L,
|
||||||
isCompleted: Boolean = false,
|
isCompleted: Boolean = false,
|
||||||
progressPercent: Float? = null,
|
progressPercent: Float? = null,
|
||||||
|
source: String = WatchProgressSourceLocal,
|
||||||
): WatchProgressEntry =
|
): WatchProgressEntry =
|
||||||
WatchProgressEntry(
|
WatchProgressEntry(
|
||||||
contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
|
contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
|
||||||
|
|
@ -188,5 +244,6 @@ class WatchProgressRulesTest {
|
||||||
lastUpdatedEpochMs = lastUpdatedEpochMs,
|
lastUpdatedEpochMs = lastUpdatedEpochMs,
|
||||||
isCompleted = isCompleted,
|
isCompleted = isCompleted,
|
||||||
progressPercent = progressPercent,
|
progressPercent = progressPercent,
|
||||||
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue