mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-23 10:12:06 +00:00
Improve watched marking parity
This commit is contained in:
parent
4e5a32510b
commit
efddba9df2
6 changed files with 326 additions and 33 deletions
|
|
@ -1019,6 +1019,7 @@
|
||||||
<string name="episode_mark_previous_watched">Mark previous as watched</string>
|
<string name="episode_mark_previous_watched">Mark previous as watched</string>
|
||||||
<string name="episode_mark_season_unwatched">Mark %1$s as unwatched</string>
|
<string name="episode_mark_season_unwatched">Mark %1$s as unwatched</string>
|
||||||
<string name="episode_mark_season_watched">Mark %1$s as watched</string>
|
<string name="episode_mark_season_watched">Mark %1$s as watched</string>
|
||||||
|
<string name="episode_mark_previous_seasons_watched">Mark previous seasons as watched</string>
|
||||||
<string name="episode_mark_unwatched">Mark as unwatched</string>
|
<string name="episode_mark_unwatched">Mark as unwatched</string>
|
||||||
<string name="episode_mark_watched">Mark as watched</string>
|
<string name="episode_mark_watched">Mark as watched</string>
|
||||||
<string name="home_continue_watching_up_next">Up next</string>
|
<string name="home_continue_watching_up_next">Up next</string>
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ import com.nuvio.app.features.details.components.DetailProductionSection
|
||||||
import com.nuvio.app.features.details.components.DetailSeriesContent
|
import com.nuvio.app.features.details.components.DetailSeriesContent
|
||||||
import com.nuvio.app.features.details.components.DetailTrailersSection
|
import com.nuvio.app.features.details.components.DetailTrailersSection
|
||||||
import com.nuvio.app.features.details.components.EpisodeWatchedActionSheet
|
import com.nuvio.app.features.details.components.EpisodeWatchedActionSheet
|
||||||
|
import com.nuvio.app.features.details.components.SeasonWatchedActionSheet
|
||||||
import com.nuvio.app.features.details.components.TrailerPlayerPopup
|
import com.nuvio.app.features.details.components.TrailerPlayerPopup
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
import com.nuvio.app.features.library.LibraryRepository
|
import com.nuvio.app.features.library.LibraryRepository
|
||||||
|
|
@ -92,6 +93,7 @@ import com.nuvio.app.features.trailer.TrailerPlaybackResolver
|
||||||
import com.nuvio.app.features.trailer.TrailerPlaybackSource
|
import com.nuvio.app.features.trailer.TrailerPlaybackSource
|
||||||
import com.nuvio.app.features.watched.WatchedRepository
|
import com.nuvio.app.features.watched.WatchedRepository
|
||||||
import com.nuvio.app.features.watched.previousReleasedEpisodesBefore
|
import com.nuvio.app.features.watched.previousReleasedEpisodesBefore
|
||||||
|
import com.nuvio.app.features.watched.releasedPlayableEpisodes
|
||||||
import com.nuvio.app.features.watched.releasedEpisodesForSeason
|
import com.nuvio.app.features.watched.releasedEpisodesForSeason
|
||||||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||||
|
|
@ -151,6 +153,7 @@ fun MetaDetailsScreen(
|
||||||
var autoLoadAttempted by remember(type, id) { mutableStateOf(false) }
|
var autoLoadAttempted by remember(type, id) { mutableStateOf(false) }
|
||||||
var observedOfflineState by remember(type, id) { mutableStateOf(false) }
|
var observedOfflineState by remember(type, id) { mutableStateOf(false) }
|
||||||
var selectedEpisodeForActions by remember(type, id) { mutableStateOf<MetaVideo?>(null) }
|
var selectedEpisodeForActions by remember(type, id) { mutableStateOf<MetaVideo?>(null) }
|
||||||
|
var selectedSeasonForActions by remember(type, id) { mutableStateOf<Int?>(null) }
|
||||||
val commentsEnabled by remember {
|
val commentsEnabled by remember {
|
||||||
TraktCommentsSettings.ensureLoaded()
|
TraktCommentsSettings.ensureLoaded()
|
||||||
TraktCommentsSettings.enabled
|
TraktCommentsSettings.enabled
|
||||||
|
|
@ -337,7 +340,10 @@ fun MetaDetailsScreen(
|
||||||
LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L))
|
LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val movieProgress = watchProgressUiState.byVideoId[meta.id]
|
val progressByVideoId = remember(watchProgressUiState.entries) {
|
||||||
|
watchProgressUiState.byVideoId
|
||||||
|
}
|
||||||
|
val movieProgress = progressByVideoId[meta.id]
|
||||||
?.takeUnless { it.isCompleted }
|
?.takeUnless { it.isCompleted }
|
||||||
val cwPrefs by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
|
val cwPrefs by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val seriesAction = remember(watchProgressUiState.entries, watchedUiState.items, meta, todayIsoDate, cwPrefs.upNextFromFurthestEpisode) {
|
val seriesAction = remember(watchProgressUiState.entries, watchedUiState.items, meta, todayIsoDate, cwPrefs.upNextFromFurthestEpisode) {
|
||||||
|
|
@ -715,11 +721,12 @@ fun MetaDetailsScreen(
|
||||||
},
|
},
|
||||||
onCommentClick = { review -> selectedComment = review },
|
onCommentClick = { review -> selectedComment = review },
|
||||||
onTrailerClick = resolveTrailer,
|
onTrailerClick = resolveTrailer,
|
||||||
progressByVideoId = watchProgressUiState.byVideoId,
|
progressByVideoId = progressByVideoId,
|
||||||
watchedKeys = watchedUiState.watchedKeys,
|
watchedKeys = watchedUiState.watchedKeys,
|
||||||
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
|
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
|
||||||
onEpisodeClick = onEpisodePlayClick,
|
onEpisodeClick = onEpisodePlayClick,
|
||||||
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
|
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
|
||||||
|
onSeasonLongPress = { season -> selectedSeasonForActions = season },
|
||||||
onOpenMeta = onOpenMeta,
|
onOpenMeta = onOpenMeta,
|
||||||
onCastClick = onCastClick,
|
onCastClick = onCastClick,
|
||||||
onCompanyClick = onCompanyClick,
|
onCompanyClick = onCompanyClick,
|
||||||
|
|
@ -776,12 +783,12 @@ fun MetaDetailsScreen(
|
||||||
)
|
)
|
||||||
|
|
||||||
selectedEpisodeForActions?.let { selectedEpisode ->
|
selectedEpisodeForActions?.let { selectedEpisode ->
|
||||||
val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys) {
|
val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys, progressByVideoId) {
|
||||||
WatchingState.isEpisodeWatched(
|
isEpisodeWatchedForActions(
|
||||||
watchedKeys = watchedUiState.watchedKeys,
|
meta = meta,
|
||||||
metaType = meta.type,
|
|
||||||
metaId = meta.id,
|
|
||||||
episode = selectedEpisode,
|
episode = selectedEpisode,
|
||||||
|
watchedKeys = watchedUiState.watchedKeys,
|
||||||
|
progressByVideoId = progressByVideoId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val previousEpisodes = remember(meta, selectedEpisode, todayIsoDate) {
|
val previousEpisodes = remember(meta, selectedEpisode, todayIsoDate) {
|
||||||
|
|
@ -796,20 +803,20 @@ fun MetaDetailsScreen(
|
||||||
todayIsoDate = todayIsoDate,
|
todayIsoDate = todayIsoDate,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys) {
|
val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
|
||||||
WatchingState.areEpisodesWatched(
|
areEpisodesWatchedForActions(
|
||||||
watchedKeys = watchedUiState.watchedKeys,
|
meta = meta,
|
||||||
metaType = meta.type,
|
|
||||||
metaId = meta.id,
|
|
||||||
episodes = previousEpisodes,
|
episodes = previousEpisodes,
|
||||||
|
watchedKeys = watchedUiState.watchedKeys,
|
||||||
|
progressByVideoId = progressByVideoId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys) {
|
val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
|
||||||
WatchingState.areEpisodesWatched(
|
areEpisodesWatchedForActions(
|
||||||
watchedKeys = watchedUiState.watchedKeys,
|
meta = meta,
|
||||||
metaType = meta.type,
|
|
||||||
metaId = meta.id,
|
|
||||||
episodes = seasonEpisodes,
|
episodes = seasonEpisodes,
|
||||||
|
watchedKeys = watchedUiState.watchedKeys,
|
||||||
|
progressByVideoId = progressByVideoId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
EpisodeWatchedActionSheet(
|
EpisodeWatchedActionSheet(
|
||||||
|
|
@ -850,6 +857,62 @@ fun MetaDetailsScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectedSeasonForActions?.let { selectedSeason ->
|
||||||
|
val seasonLabel = selectedSeasonLabel(selectedSeason)
|
||||||
|
val seasonEpisodes = remember(meta, selectedSeason, todayIsoDate) {
|
||||||
|
meta.releasedEpisodesForSeason(
|
||||||
|
seasonNumber = selectedSeason,
|
||||||
|
todayIsoDate = todayIsoDate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val previousSeasonEpisodes = remember(meta, selectedSeason, todayIsoDate) {
|
||||||
|
val normalizedSelectedSeason = selectedSeason.coerceAtLeast(0)
|
||||||
|
meta.releasedPlayableEpisodes(todayIsoDate)
|
||||||
|
.filter { episode ->
|
||||||
|
val season = episode.season?.coerceAtLeast(0) ?: 0
|
||||||
|
season > 0 && season < normalizedSelectedSeason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
|
||||||
|
areEpisodesWatchedForActions(
|
||||||
|
meta = meta,
|
||||||
|
episodes = seasonEpisodes,
|
||||||
|
watchedKeys = watchedUiState.watchedKeys,
|
||||||
|
progressByVideoId = progressByVideoId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val canMarkPreviousSeasons = remember(previousSeasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
|
||||||
|
previousSeasonEpisodes.any { episode ->
|
||||||
|
!isEpisodeWatchedForActions(
|
||||||
|
meta = meta,
|
||||||
|
episode = episode,
|
||||||
|
watchedKeys = watchedUiState.watchedKeys,
|
||||||
|
progressByVideoId = progressByVideoId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SeasonWatchedActionSheet(
|
||||||
|
seasonLabel = seasonLabel,
|
||||||
|
isSeasonWatched = isSeasonWatched,
|
||||||
|
canMarkPreviousSeasons = canMarkPreviousSeasons,
|
||||||
|
onDismiss = { selectedSeasonForActions = null },
|
||||||
|
onToggleSeasonWatched = {
|
||||||
|
WatchingActions.toggleSeasonWatched(
|
||||||
|
meta = meta,
|
||||||
|
episodes = seasonEpisodes,
|
||||||
|
areCurrentlyWatched = isSeasonWatched,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onMarkPreviousSeasonsWatched = {
|
||||||
|
WatchingActions.togglePreviousEpisodesWatched(
|
||||||
|
meta = meta,
|
||||||
|
episodes = previousSeasonEpisodes,
|
||||||
|
areCurrentlyWatched = false,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (inAppTrailerPlaybackEnabled) {
|
if (inAppTrailerPlaybackEnabled) {
|
||||||
TrailerPlayerPopup(
|
TrailerPlayerPopup(
|
||||||
visible = selectedTrailer != null,
|
visible = selectedTrailer != null,
|
||||||
|
|
@ -970,6 +1033,49 @@ private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean {
|
||||||
return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow")
|
return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun selectedSeasonLabel(season: Int): String =
|
||||||
|
if (season == 0) {
|
||||||
|
stringResource(Res.string.episodes_specials)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.episodes_season, season)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isEpisodeWatchedForActions(
|
||||||
|
meta: MetaDetails,
|
||||||
|
episode: MetaVideo,
|
||||||
|
watchedKeys: Set<String>,
|
||||||
|
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||||
|
): Boolean {
|
||||||
|
val episodeVideoId = buildPlaybackVideoId(
|
||||||
|
parentMetaId = meta.id,
|
||||||
|
seasonNumber = episode.season,
|
||||||
|
episodeNumber = episode.episode,
|
||||||
|
fallbackVideoId = episode.id,
|
||||||
|
)
|
||||||
|
return progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||||
|
WatchingState.isEpisodeWatched(
|
||||||
|
watchedKeys = watchedKeys,
|
||||||
|
metaType = meta.type,
|
||||||
|
metaId = meta.id,
|
||||||
|
episode = episode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun areEpisodesWatchedForActions(
|
||||||
|
meta: MetaDetails,
|
||||||
|
episodes: Collection<MetaVideo>,
|
||||||
|
watchedKeys: Set<String>,
|
||||||
|
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||||
|
): Boolean = episodes.isNotEmpty() && episodes.all { episode ->
|
||||||
|
isEpisodeWatchedForActions(
|
||||||
|
meta = meta,
|
||||||
|
episode = episode,
|
||||||
|
watchedKeys = watchedKeys,
|
||||||
|
progressByVideoId = progressByVideoId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun extractImdbId(value: String?): String? =
|
private fun extractImdbId(value: String?): String? =
|
||||||
value
|
value
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|
@ -1026,6 +1132,7 @@ private fun ConfiguredMetaSections(
|
||||||
blurUnwatchedEpisodes: Boolean,
|
blurUnwatchedEpisodes: Boolean,
|
||||||
onEpisodeClick: (MetaVideo) -> Unit,
|
onEpisodeClick: (MetaVideo) -> Unit,
|
||||||
onEpisodeLongPress: (MetaVideo) -> Unit,
|
onEpisodeLongPress: (MetaVideo) -> Unit,
|
||||||
|
onSeasonLongPress: (Int) -> Unit,
|
||||||
onOpenMeta: ((MetaPreview) -> Unit)?,
|
onOpenMeta: ((MetaPreview) -> Unit)?,
|
||||||
onCastClick: ((MetaPerson, String?) -> Unit)?,
|
onCastClick: ((MetaPerson, String?) -> Unit)?,
|
||||||
onCompanyClick: ((MetaCompany, String) -> Unit)?,
|
onCompanyClick: ((MetaCompany, String) -> Unit)?,
|
||||||
|
|
@ -1120,6 +1227,7 @@ private fun ConfiguredMetaSections(
|
||||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
onEpisodeClick = onEpisodeClick,
|
onEpisodeClick = onEpisodeClick,
|
||||||
onEpisodeLongPress = onEpisodeLongPress,
|
onEpisodeLongPress = onEpisodeLongPress,
|
||||||
|
onSeasonLongPress = onSeasonLongPress,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ fun DetailSeriesContent(
|
||||||
blurUnwatchedEpisodes: Boolean = false,
|
blurUnwatchedEpisodes: Boolean = false,
|
||||||
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
|
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
|
||||||
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
|
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
|
||||||
|
onSeasonLongPress: ((Int) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val hasVideos = meta.videos.isNotEmpty()
|
val hasVideos = meta.videos.isNotEmpty()
|
||||||
if (meta.type != "series" && !hasVideos) return
|
if (meta.type != "series" && !hasVideos) return
|
||||||
|
|
@ -230,12 +231,14 @@ fun DetailSeriesContent(
|
||||||
currentSeason = currentSeason,
|
currentSeason = currentSeason,
|
||||||
sizing = sizing,
|
sizing = sizing,
|
||||||
onSelect = { selectedSeasonOverride = it },
|
onSelect = { selectedSeasonOverride = it },
|
||||||
|
onLongPress = onSeasonLongPress,
|
||||||
)
|
)
|
||||||
SeasonViewMode.Text -> SeasonTextChipScrollRow(
|
SeasonViewMode.Text -> SeasonTextChipScrollRow(
|
||||||
seasons = seasons,
|
seasons = seasons,
|
||||||
currentSeason = currentSeason,
|
currentSeason = currentSeason,
|
||||||
sizing = sizing,
|
sizing = sizing,
|
||||||
onSelect = { selectedSeasonOverride = it },
|
onSelect = { selectedSeasonOverride = it },
|
||||||
|
onLongPress = onSeasonLongPress,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -245,6 +248,7 @@ fun DetailSeriesContent(
|
||||||
currentSeason = currentSeason,
|
currentSeason = currentSeason,
|
||||||
sizing = sizing,
|
sizing = sizing,
|
||||||
onSelect = { selectedSeasonOverride = it },
|
onSelect = { selectedSeasonOverride = it },
|
||||||
|
onLongPress = onSeasonLongPress,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -372,12 +376,14 @@ private fun SeasonViewModeToggle(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SeasonTextChipScrollRow(
|
private fun SeasonTextChipScrollRow(
|
||||||
seasons: List<Int>,
|
seasons: List<Int>,
|
||||||
currentSeason: Int,
|
currentSeason: Int,
|
||||||
sizing: SeriesContentSizing,
|
sizing: SeriesContentSizing,
|
||||||
onSelect: (Int) -> Unit,
|
onSelect: (Int) -> Unit,
|
||||||
|
onLongPress: ((Int) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val seasonListState = rememberLazyListState()
|
val seasonListState = rememberLazyListState()
|
||||||
var hasPositionedSeasonRow by remember(seasons) { mutableStateOf(false) }
|
var hasPositionedSeasonRow by remember(seasons) { mutableStateOf(false) }
|
||||||
|
|
@ -411,7 +417,10 @@ private fun SeasonTextChipScrollRow(
|
||||||
Color.Transparent
|
Color.Transparent
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.clickable { onSelect(season) }
|
.combinedClickable(
|
||||||
|
onClick = { onSelect(season) },
|
||||||
|
onLongClick = onLongPress?.let { handler -> { handler(season) } },
|
||||||
|
)
|
||||||
.padding(
|
.padding(
|
||||||
horizontal = sizing.seasonChipHorizontalPadding,
|
horizontal = sizing.seasonChipHorizontalPadding,
|
||||||
vertical = sizing.seasonChipVerticalPadding,
|
vertical = sizing.seasonChipVerticalPadding,
|
||||||
|
|
@ -443,6 +452,7 @@ private fun SeasonPosterScrollRow(
|
||||||
currentSeason: Int,
|
currentSeason: Int,
|
||||||
sizing: SeriesContentSizing,
|
sizing: SeriesContentSizing,
|
||||||
onSelect: (Int) -> Unit,
|
onSelect: (Int) -> Unit,
|
||||||
|
onLongPress: ((Int) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val seasonListState = rememberLazyListState()
|
val seasonListState = rememberLazyListState()
|
||||||
var hasPositionedSeasonRow by remember(seasons) { mutableStateOf(false) }
|
var hasPositionedSeasonRow by remember(seasons) { mutableStateOf(false) }
|
||||||
|
|
@ -475,11 +485,13 @@ private fun SeasonPosterScrollRow(
|
||||||
isSelected = season == currentSeason,
|
isSelected = season == currentSeason,
|
||||||
sizing = sizing,
|
sizing = sizing,
|
||||||
onClick = { onSelect(season) },
|
onClick = { onSelect(season) },
|
||||||
|
onLongClick = onLongPress?.let { handler -> { handler(season) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SeasonPosterButton(
|
private fun SeasonPosterButton(
|
||||||
label: String,
|
label: String,
|
||||||
|
|
@ -487,11 +499,15 @@ private fun SeasonPosterButton(
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
sizing: SeriesContentSizing,
|
sizing: SeriesContentSizing,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onLongClick: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(sizing.seasonPosterWidth)
|
.width(sizing.seasonPosterWidth)
|
||||||
.clickable(onClick = onClick),
|
.combinedClickable(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
package com.nuvio.app.features.details.components
|
package com.nuvio.app.features.details.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.DoneAll
|
import androidx.compose.material.icons.filled.DoneAll
|
||||||
|
|
@ -135,6 +130,73 @@ fun EpisodeWatchedActionSheet(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SeasonWatchedActionSheet(
|
||||||
|
seasonLabel: String,
|
||||||
|
isSeasonWatched: Boolean,
|
||||||
|
canMarkPreviousSeasons: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onToggleSeasonWatched: () -> Unit,
|
||||||
|
onMarkPreviousSeasonsWatched: () -> Unit,
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
NuvioModalBottomSheet(
|
||||||
|
onDismissRequest = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sheetState = sheetState,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = nuvioSafeBottomPadding(16.dp)),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = seasonLabel,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
)
|
||||||
|
NuvioBottomSheetDivider()
|
||||||
|
NuvioBottomSheetActionRow(
|
||||||
|
icon = Icons.Default.PlaylistAddCheckCircle,
|
||||||
|
title = if (isSeasonWatched) {
|
||||||
|
stringResource(Res.string.episode_mark_season_unwatched, seasonLabel)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.episode_mark_season_watched, seasonLabel)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onToggleSeasonWatched()
|
||||||
|
coroutineScope.launch {
|
||||||
|
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (canMarkPreviousSeasons) {
|
||||||
|
NuvioBottomSheetDivider()
|
||||||
|
NuvioBottomSheetActionRow(
|
||||||
|
icon = Icons.Default.DoneAll,
|
||||||
|
title = stringResource(Res.string.episode_mark_previous_seasons_watched),
|
||||||
|
onClick = {
|
||||||
|
onMarkPreviousSeasonsWatched()
|
||||||
|
coroutineScope.launch {
|
||||||
|
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EpisodeActionSheetHeader(
|
private fun EpisodeActionSheetHeader(
|
||||||
episode: MetaVideo,
|
episode: MetaVideo,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import kotlinx.serialization.json.Json
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class StoredWatchedPayload(
|
private data class StoredWatchedPayload(
|
||||||
val items: List<WatchedItem> = emptyList(),
|
val items: List<WatchedItem> = emptyList(),
|
||||||
|
val lastSuccessfulPushEpochMs: Long = 0L,
|
||||||
)
|
)
|
||||||
|
|
||||||
object WatchedRepository {
|
object WatchedRepository {
|
||||||
|
|
@ -43,6 +44,7 @@ object WatchedRepository {
|
||||||
private var hasLoaded = false
|
private var hasLoaded = false
|
||||||
private var currentProfileId: Int = 1
|
private var currentProfileId: Int = 1
|
||||||
private var itemsByKey: MutableMap<String, WatchedItem> = mutableMapOf()
|
private var itemsByKey: MutableMap<String, WatchedItem> = mutableMapOf()
|
||||||
|
private var lastSuccessfulPushEpochMs: Long = 0L
|
||||||
internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter
|
internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter
|
||||||
|
|
||||||
private fun activePullSyncAdapter(): WatchedSyncAdapter =
|
private fun activePullSyncAdapter(): WatchedSyncAdapter =
|
||||||
|
|
@ -62,6 +64,7 @@ object WatchedRepository {
|
||||||
hasLoaded = false
|
hasLoaded = false
|
||||||
currentProfileId = 1
|
currentProfileId = 1
|
||||||
itemsByKey.clear()
|
itemsByKey.clear()
|
||||||
|
lastSuccessfulPushEpochMs = 0L
|
||||||
_uiState.value = WatchedUiState()
|
_uiState.value = WatchedUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,13 +75,16 @@ object WatchedRepository {
|
||||||
|
|
||||||
val payload = WatchedStorage.loadPayload(profileId).orEmpty().trim()
|
val payload = WatchedStorage.loadPayload(profileId).orEmpty().trim()
|
||||||
if (payload.isNotEmpty()) {
|
if (payload.isNotEmpty()) {
|
||||||
val items = runCatching {
|
val storedPayload = runCatching {
|
||||||
json.decodeFromString<StoredWatchedPayload>(payload).items
|
json.decodeFromString<StoredWatchedPayload>(payload)
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(StoredWatchedPayload())
|
||||||
itemsByKey = items
|
lastSuccessfulPushEpochMs = storedPayload.lastSuccessfulPushEpochMs
|
||||||
|
itemsByKey = storedPayload.items
|
||||||
.map(WatchedItem::normalizedMarkedAt)
|
.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()
|
||||||
|
} else {
|
||||||
|
lastSuccessfulPushEpochMs = 0L
|
||||||
}
|
}
|
||||||
|
|
||||||
publish()
|
publish()
|
||||||
|
|
@ -88,16 +94,23 @@ object WatchedRepository {
|
||||||
TraktAuthRepository.ensureLoaded()
|
TraktAuthRepository.ensureLoaded()
|
||||||
TraktSettingsRepository.ensureLoaded()
|
TraktSettingsRepository.ensureLoaded()
|
||||||
currentProfileId = profileId
|
currentProfileId = profileId
|
||||||
|
val pullStartedEpochMs = WatchedClock.nowEpochMs()
|
||||||
|
val localBeforePull = itemsByKey.values
|
||||||
|
.map(WatchedItem::normalizedMarkedAt)
|
||||||
|
.toList()
|
||||||
|
val lastPushEpochMs = lastSuccessfulPushEpochMs
|
||||||
runCatching {
|
runCatching {
|
||||||
val serverItems = activePullSyncAdapter().pull(
|
val serverItems = activePullSyncAdapter().pull(
|
||||||
profileId = profileId,
|
profileId = profileId,
|
||||||
pageSize = watchedItemsPageSize,
|
pageSize = watchedItemsPageSize,
|
||||||
)
|
)
|
||||||
|
|
||||||
itemsByKey = serverItems
|
itemsByKey = mergeWatchedItemsPreservingUnsynced(
|
||||||
.map(WatchedItem::normalizedMarkedAt)
|
serverItems = serverItems,
|
||||||
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
|
localItems = localBeforePull,
|
||||||
.toMutableMap()
|
lastSuccessfulPushEpochMs = lastPushEpochMs,
|
||||||
|
pullStartedEpochMs = pullStartedEpochMs,
|
||||||
|
).toMutableMap()
|
||||||
hasLoaded = true
|
hasLoaded = true
|
||||||
publish()
|
publish()
|
||||||
persist()
|
persist()
|
||||||
|
|
@ -213,6 +226,7 @@ object WatchedRepository {
|
||||||
if (items.isEmpty()) return@runCatching
|
if (items.isEmpty()) return@runCatching
|
||||||
val profileId = ProfileRepository.activeProfileId
|
val profileId = ProfileRepository.activeProfileId
|
||||||
pushToActiveTargets(profileId = profileId, items = items)
|
pushToActiveTargets(profileId = profileId, items = items)
|
||||||
|
recordSuccessfulPush(profileId = profileId, items = items)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
log.e(e) { "Failed to push watched items" }
|
log.e(e) { "Failed to push watched items" }
|
||||||
}
|
}
|
||||||
|
|
@ -252,11 +266,24 @@ object WatchedRepository {
|
||||||
items = itemsByKey.values
|
items = itemsByKey.values
|
||||||
.map(WatchedItem::normalizedMarkedAt)
|
.map(WatchedItem::normalizedMarkedAt)
|
||||||
.sortedByDescending { it.markedAtEpochMs },
|
.sortedByDescending { it.markedAtEpochMs },
|
||||||
|
lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun recordSuccessfulPush(profileId: Int, items: Collection<WatchedItem>) {
|
||||||
|
if (profileId != currentProfileId) return
|
||||||
|
val latestPushed = items
|
||||||
|
.asSequence()
|
||||||
|
.map { item -> normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs) }
|
||||||
|
.maxOrNull()
|
||||||
|
?: return
|
||||||
|
if (latestPushed <= lastSuccessfulPushEpochMs) return
|
||||||
|
lastSuccessfulPushEpochMs = latestPushed
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
private fun shouldUseTraktWatchedSync(): Boolean =
|
private fun shouldUseTraktWatchedSync(): Boolean =
|
||||||
shouldUseTraktWatchedSync(
|
shouldUseTraktWatchedSync(
|
||||||
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
|
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
|
||||||
|
|
@ -294,6 +321,33 @@ object WatchedRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun mergeWatchedItemsPreservingUnsynced(
|
||||||
|
serverItems: Collection<WatchedItem>,
|
||||||
|
localItems: Collection<WatchedItem>,
|
||||||
|
lastSuccessfulPushEpochMs: Long,
|
||||||
|
pullStartedEpochMs: Long,
|
||||||
|
): Map<String, WatchedItem> {
|
||||||
|
val merged = serverItems
|
||||||
|
.map(WatchedItem::normalizedMarkedAt)
|
||||||
|
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
|
||||||
|
.toMutableMap()
|
||||||
|
|
||||||
|
localItems
|
||||||
|
.map(WatchedItem::normalizedMarkedAt)
|
||||||
|
.forEach { localItem ->
|
||||||
|
val key = watchedItemKey(localItem.type, localItem.id, localItem.season, localItem.episode)
|
||||||
|
if (key in merged) return@forEach
|
||||||
|
val markedAt = localItem.markedAtEpochMs
|
||||||
|
val wasMarkedAfterLastPush = lastSuccessfulPushEpochMs > 0L && markedAt > lastSuccessfulPushEpochMs
|
||||||
|
val wasMarkedDuringPull = pullStartedEpochMs > 0L && markedAt >= pullStartedEpochMs
|
||||||
|
if (wasMarkedAfterLastPush || wasMarkedDuringPull) {
|
||||||
|
merged[key] = localItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
internal fun shouldUseTraktWatchedSync(
|
internal fun shouldUseTraktWatchedSync(
|
||||||
isAuthenticated: Boolean,
|
isAuthenticated: Boolean,
|
||||||
source: WatchProgressSource,
|
source: WatchProgressSource,
|
||||||
|
|
|
||||||
|
|
@ -44,5 +44,57 @@ class WatchedRepositoryTest {
|
||||||
|
|
||||||
assertTrue(result)
|
assertTrue(result)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mergeWatchedItemsPreservingUnsynced_keeps_local_items_marked_after_last_push() {
|
||||||
|
val serverItem = WatchedItem(
|
||||||
|
id = "show",
|
||||||
|
type = "series",
|
||||||
|
name = "Episode 1",
|
||||||
|
season = 1,
|
||||||
|
episode = 1,
|
||||||
|
markedAtEpochMs = 1_000L,
|
||||||
|
)
|
||||||
|
val unsyncedLocalItem = WatchedItem(
|
||||||
|
id = "show",
|
||||||
|
type = "series",
|
||||||
|
name = "Episode 2",
|
||||||
|
season = 1,
|
||||||
|
episode = 2,
|
||||||
|
markedAtEpochMs = 3_000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val merged = mergeWatchedItemsPreservingUnsynced(
|
||||||
|
serverItems = listOf(serverItem),
|
||||||
|
localItems = listOf(serverItem, unsyncedLocalItem),
|
||||||
|
lastSuccessfulPushEpochMs = 2_000L,
|
||||||
|
pullStartedEpochMs = 4_000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
setOf("series:show:1:1", "series:show:1:2"),
|
||||||
|
merged.keys,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mergeWatchedItemsPreservingUnsynced_drops_old_local_items_missing_from_server() {
|
||||||
|
val oldLocalItem = WatchedItem(
|
||||||
|
id = "show",
|
||||||
|
type = "series",
|
||||||
|
name = "Episode 1",
|
||||||
|
season = 1,
|
||||||
|
episode = 1,
|
||||||
|
markedAtEpochMs = 1_000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val merged = mergeWatchedItemsPreservingUnsynced(
|
||||||
|
serverItems = emptyList(),
|
||||||
|
localItems = listOf(oldLocalItem),
|
||||||
|
lastSuccessfulPushEpochMs = 2_000L,
|
||||||
|
pullStartedEpochMs = 4_000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(merged.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue