Skip to content

Commit efddba9

Browse files
committed
Improve watched marking parity
1 parent 4e5a325 commit efddba9

6 files changed

Lines changed: 326 additions & 33 deletions

File tree

composeApp/src/commonMain/composeResources/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,6 +1019,7 @@
10191019
<string name="episode_mark_previous_watched">Mark previous as watched</string>
10201020
<string name="episode_mark_season_unwatched">Mark %1$s as unwatched</string>
10211021
<string name="episode_mark_season_watched">Mark %1$s as watched</string>
1022+
<string name="episode_mark_previous_seasons_watched">Mark previous seasons as watched</string>
10221023
<string name="episode_mark_unwatched">Mark as unwatched</string>
10231024
<string name="episode_mark_watched">Mark as watched</string>
10241025
<string name="home_continue_watching_up_next">Up next</string>

composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt

Lines changed: 125 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import com.nuvio.app.features.details.components.DetailProductionSection
7575
import com.nuvio.app.features.details.components.DetailSeriesContent
7676
import com.nuvio.app.features.details.components.DetailTrailersSection
7777
import com.nuvio.app.features.details.components.EpisodeWatchedActionSheet
78+
import com.nuvio.app.features.details.components.SeasonWatchedActionSheet
7879
import com.nuvio.app.features.details.components.TrailerPlayerPopup
7980
import com.nuvio.app.features.home.MetaPreview
8081
import com.nuvio.app.features.library.LibraryRepository
@@ -92,6 +93,7 @@ import com.nuvio.app.features.trailer.TrailerPlaybackResolver
9293
import com.nuvio.app.features.trailer.TrailerPlaybackSource
9394
import com.nuvio.app.features.watched.WatchedRepository
9495
import com.nuvio.app.features.watched.previousReleasedEpisodesBefore
96+
import com.nuvio.app.features.watched.releasedPlayableEpisodes
9597
import com.nuvio.app.features.watched.releasedEpisodesForSeason
9698
import com.nuvio.app.features.watchprogress.CurrentDateProvider
9799
import com.nuvio.app.features.watchprogress.WatchProgressEntry
@@ -151,6 +153,7 @@ fun MetaDetailsScreen(
151153
var autoLoadAttempted by remember(type, id) { mutableStateOf(false) }
152154
var observedOfflineState by remember(type, id) { mutableStateOf(false) }
153155
var selectedEpisodeForActions by remember(type, id) { mutableStateOf<MetaVideo?>(null) }
156+
var selectedSeasonForActions by remember(type, id) { mutableStateOf<Int?>(null) }
154157
val commentsEnabled by remember {
155158
TraktCommentsSettings.ensureLoaded()
156159
TraktCommentsSettings.enabled
@@ -337,7 +340,10 @@ fun MetaDetailsScreen(
337340
LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L))
338341
}
339342
}
340-
val movieProgress = watchProgressUiState.byVideoId[meta.id]
343+
val progressByVideoId = remember(watchProgressUiState.entries) {
344+
watchProgressUiState.byVideoId
345+
}
346+
val movieProgress = progressByVideoId[meta.id]
341347
?.takeUnless { it.isCompleted }
342348
val cwPrefs by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
343349
val seriesAction = remember(watchProgressUiState.entries, watchedUiState.items, meta, todayIsoDate, cwPrefs.upNextFromFurthestEpisode) {
@@ -715,11 +721,12 @@ fun MetaDetailsScreen(
715721
},
716722
onCommentClick = { review -> selectedComment = review },
717723
onTrailerClick = resolveTrailer,
718-
progressByVideoId = watchProgressUiState.byVideoId,
724+
progressByVideoId = progressByVideoId,
719725
watchedKeys = watchedUiState.watchedKeys,
720726
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
721727
onEpisodeClick = onEpisodePlayClick,
722728
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
729+
onSeasonLongPress = { season -> selectedSeasonForActions = season },
723730
onOpenMeta = onOpenMeta,
724731
onCastClick = onCastClick,
725732
onCompanyClick = onCompanyClick,
@@ -776,12 +783,12 @@ fun MetaDetailsScreen(
776783
)
777784

778785
selectedEpisodeForActions?.let { selectedEpisode ->
779-
val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys) {
780-
WatchingState.isEpisodeWatched(
781-
watchedKeys = watchedUiState.watchedKeys,
782-
metaType = meta.type,
783-
metaId = meta.id,
786+
val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys, progressByVideoId) {
787+
isEpisodeWatchedForActions(
788+
meta = meta,
784789
episode = selectedEpisode,
790+
watchedKeys = watchedUiState.watchedKeys,
791+
progressByVideoId = progressByVideoId,
785792
)
786793
}
787794
val previousEpisodes = remember(meta, selectedEpisode, todayIsoDate) {
@@ -796,20 +803,20 @@ fun MetaDetailsScreen(
796803
todayIsoDate = todayIsoDate,
797804
)
798805
}
799-
val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys) {
800-
WatchingState.areEpisodesWatched(
801-
watchedKeys = watchedUiState.watchedKeys,
802-
metaType = meta.type,
803-
metaId = meta.id,
806+
val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
807+
areEpisodesWatchedForActions(
808+
meta = meta,
804809
episodes = previousEpisodes,
810+
watchedKeys = watchedUiState.watchedKeys,
811+
progressByVideoId = progressByVideoId,
805812
)
806813
}
807-
val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys) {
808-
WatchingState.areEpisodesWatched(
809-
watchedKeys = watchedUiState.watchedKeys,
810-
metaType = meta.type,
811-
metaId = meta.id,
814+
val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
815+
areEpisodesWatchedForActions(
816+
meta = meta,
812817
episodes = seasonEpisodes,
818+
watchedKeys = watchedUiState.watchedKeys,
819+
progressByVideoId = progressByVideoId,
813820
)
814821
}
815822
EpisodeWatchedActionSheet(
@@ -850,6 +857,62 @@ fun MetaDetailsScreen(
850857
)
851858
}
852859

860+
selectedSeasonForActions?.let { selectedSeason ->
861+
val seasonLabel = selectedSeasonLabel(selectedSeason)
862+
val seasonEpisodes = remember(meta, selectedSeason, todayIsoDate) {
863+
meta.releasedEpisodesForSeason(
864+
seasonNumber = selectedSeason,
865+
todayIsoDate = todayIsoDate,
866+
)
867+
}
868+
val previousSeasonEpisodes = remember(meta, selectedSeason, todayIsoDate) {
869+
val normalizedSelectedSeason = selectedSeason.coerceAtLeast(0)
870+
meta.releasedPlayableEpisodes(todayIsoDate)
871+
.filter { episode ->
872+
val season = episode.season?.coerceAtLeast(0) ?: 0
873+
season > 0 && season < normalizedSelectedSeason
874+
}
875+
}
876+
val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
877+
areEpisodesWatchedForActions(
878+
meta = meta,
879+
episodes = seasonEpisodes,
880+
watchedKeys = watchedUiState.watchedKeys,
881+
progressByVideoId = progressByVideoId,
882+
)
883+
}
884+
val canMarkPreviousSeasons = remember(previousSeasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
885+
previousSeasonEpisodes.any { episode ->
886+
!isEpisodeWatchedForActions(
887+
meta = meta,
888+
episode = episode,
889+
watchedKeys = watchedUiState.watchedKeys,
890+
progressByVideoId = progressByVideoId,
891+
)
892+
}
893+
}
894+
SeasonWatchedActionSheet(
895+
seasonLabel = seasonLabel,
896+
isSeasonWatched = isSeasonWatched,
897+
canMarkPreviousSeasons = canMarkPreviousSeasons,
898+
onDismiss = { selectedSeasonForActions = null },
899+
onToggleSeasonWatched = {
900+
WatchingActions.toggleSeasonWatched(
901+
meta = meta,
902+
episodes = seasonEpisodes,
903+
areCurrentlyWatched = isSeasonWatched,
904+
)
905+
},
906+
onMarkPreviousSeasonsWatched = {
907+
WatchingActions.togglePreviousEpisodesWatched(
908+
meta = meta,
909+
episodes = previousSeasonEpisodes,
910+
areCurrentlyWatched = false,
911+
)
912+
},
913+
)
914+
}
915+
853916
if (inAppTrailerPlaybackEnabled) {
854917
TrailerPlayerPopup(
855918
visible = selectedTrailer != null,
@@ -970,6 +1033,49 @@ private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean {
9701033
return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow")
9711034
}
9721035

1036+
@Composable
1037+
private fun selectedSeasonLabel(season: Int): String =
1038+
if (season == 0) {
1039+
stringResource(Res.string.episodes_specials)
1040+
} else {
1041+
stringResource(Res.string.episodes_season, season)
1042+
}
1043+
1044+
private fun isEpisodeWatchedForActions(
1045+
meta: MetaDetails,
1046+
episode: MetaVideo,
1047+
watchedKeys: Set<String>,
1048+
progressByVideoId: Map<String, WatchProgressEntry>,
1049+
): Boolean {
1050+
val episodeVideoId = buildPlaybackVideoId(
1051+
parentMetaId = meta.id,
1052+
seasonNumber = episode.season,
1053+
episodeNumber = episode.episode,
1054+
fallbackVideoId = episode.id,
1055+
)
1056+
return progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
1057+
WatchingState.isEpisodeWatched(
1058+
watchedKeys = watchedKeys,
1059+
metaType = meta.type,
1060+
metaId = meta.id,
1061+
episode = episode,
1062+
)
1063+
}
1064+
1065+
private fun areEpisodesWatchedForActions(
1066+
meta: MetaDetails,
1067+
episodes: Collection<MetaVideo>,
1068+
watchedKeys: Set<String>,
1069+
progressByVideoId: Map<String, WatchProgressEntry>,
1070+
): Boolean = episodes.isNotEmpty() && episodes.all { episode ->
1071+
isEpisodeWatchedForActions(
1072+
meta = meta,
1073+
episode = episode,
1074+
watchedKeys = watchedKeys,
1075+
progressByVideoId = progressByVideoId,
1076+
)
1077+
}
1078+
9731079
private fun extractImdbId(value: String?): String? =
9741080
value
9751081
?.trim()
@@ -1026,6 +1132,7 @@ private fun ConfiguredMetaSections(
10261132
blurUnwatchedEpisodes: Boolean,
10271133
onEpisodeClick: (MetaVideo) -> Unit,
10281134
onEpisodeLongPress: (MetaVideo) -> Unit,
1135+
onSeasonLongPress: (Int) -> Unit,
10291136
onOpenMeta: ((MetaPreview) -> Unit)?,
10301137
onCastClick: ((MetaPerson, String?) -> Unit)?,
10311138
onCompanyClick: ((MetaCompany, String) -> Unit)?,
@@ -1120,6 +1227,7 @@ private fun ConfiguredMetaSections(
11201227
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
11211228
onEpisodeClick = onEpisodeClick,
11221229
onEpisodeLongPress = onEpisodeLongPress,
1230+
onSeasonLongPress = onSeasonLongPress,
11231231
)
11241232
}
11251233
}

composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ fun DetailSeriesContent(
100100
blurUnwatchedEpisodes: Boolean = false,
101101
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
102102
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
103+
onSeasonLongPress: ((Int) -> Unit)? = null,
103104
) {
104105
val hasVideos = meta.videos.isNotEmpty()
105106
if (meta.type != "series" && !hasVideos) return
@@ -230,12 +231,14 @@ fun DetailSeriesContent(
230231
currentSeason = currentSeason,
231232
sizing = sizing,
232233
onSelect = { selectedSeasonOverride = it },
234+
onLongPress = onSeasonLongPress,
233235
)
234236
SeasonViewMode.Text -> SeasonTextChipScrollRow(
235237
seasons = seasons,
236238
currentSeason = currentSeason,
237239
sizing = sizing,
238240
onSelect = { selectedSeasonOverride = it },
241+
onLongPress = onSeasonLongPress,
239242
)
240243
}
241244
}
@@ -245,6 +248,7 @@ fun DetailSeriesContent(
245248
currentSeason = currentSeason,
246249
sizing = sizing,
247250
onSelect = { selectedSeasonOverride = it },
251+
onLongPress = onSeasonLongPress,
248252
)
249253
}
250254
}
@@ -372,12 +376,14 @@ private fun SeasonViewModeToggle(
372376
}
373377
}
374378

379+
@OptIn(ExperimentalFoundationApi::class)
375380
@Composable
376381
private fun SeasonTextChipScrollRow(
377382
seasons: List<Int>,
378383
currentSeason: Int,
379384
sizing: SeriesContentSizing,
380385
onSelect: (Int) -> Unit,
386+
onLongPress: ((Int) -> Unit)?,
381387
) {
382388
val seasonListState = rememberLazyListState()
383389
var hasPositionedSeasonRow by remember(seasons) { mutableStateOf(false) }
@@ -411,7 +417,10 @@ private fun SeasonTextChipScrollRow(
411417
Color.Transparent
412418
},
413419
)
414-
.clickable { onSelect(season) }
420+
.combinedClickable(
421+
onClick = { onSelect(season) },
422+
onLongClick = onLongPress?.let { handler -> { handler(season) } },
423+
)
415424
.padding(
416425
horizontal = sizing.seasonChipHorizontalPadding,
417426
vertical = sizing.seasonChipVerticalPadding,
@@ -443,6 +452,7 @@ private fun SeasonPosterScrollRow(
443452
currentSeason: Int,
444453
sizing: SeriesContentSizing,
445454
onSelect: (Int) -> Unit,
455+
onLongPress: ((Int) -> Unit)?,
446456
) {
447457
val seasonListState = rememberLazyListState()
448458
var hasPositionedSeasonRow by remember(seasons) { mutableStateOf(false) }
@@ -475,23 +485,29 @@ private fun SeasonPosterScrollRow(
475485
isSelected = season == currentSeason,
476486
sizing = sizing,
477487
onClick = { onSelect(season) },
488+
onLongClick = onLongPress?.let { handler -> { handler(season) } },
478489
)
479490
}
480491
}
481492
}
482493

494+
@OptIn(ExperimentalFoundationApi::class)
483495
@Composable
484496
private fun SeasonPosterButton(
485497
label: String,
486498
imageUrl: String?,
487499
isSelected: Boolean,
488500
sizing: SeriesContentSizing,
489501
onClick: () -> Unit,
502+
onLongClick: (() -> Unit)?,
490503
) {
491504
Column(
492505
modifier = Modifier
493506
.width(sizing.seasonPosterWidth)
494-
.clickable(onClick = onClick),
507+
.combinedClickable(
508+
onClick = onClick,
509+
onLongClick = onLongClick,
510+
),
495511
verticalArrangement = Arrangement.spacedBy(8.dp),
496512
) {
497513
Box(

0 commit comments

Comments
 (0)