diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt index 433b6ca..5a4c4c1 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt @@ -551,11 +551,17 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie * title's positions are what this throws away, and a mis-tap on a tile's * menu should not cost them. */ - fun setTitleWatched(title: TitleRecord, watched: Boolean) { - val episodes = title.seasons.flatMap { it.episodes } + fun setTitleWatched(title: TitleRecord, watched: Boolean) = + markEpisodes(title.seasons.flatMap { it.episodes }, watched, title.displayName) + + /** + * Some episodes seen or unseen — a season, or a whole title — with an + * Undo; [what] names them in the message. + */ + fun markEpisodes(episodes: List, watched: Boolean, what: String) { changeWatch( - episodes, - "${title.displayName} marked ${if (watched) "watched" else "unwatched"}", + episodes.filter { it.watched != watched }, + "$what marked ${if (watched) "watched" else "unwatched"}", ) { engine, episode, before -> val duration = before?.durationSecs engine.saveWatchState( diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index 9b275d1..b47d925 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -247,6 +247,7 @@ fun ProtonStreamApp( model::reloadAfterMetadataChange, model::setWatched, body, + onMarkEpisodes = model::markEpisodes, franchise = selected.franchise.mapNotNull { key -> state.titles.firstOrNull { it.key == key } }, diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index 515d883..f809958 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -38,8 +38,10 @@ import androidx.compose.material3.IconButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.RemoveDone import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -97,6 +99,8 @@ internal fun TitleScreen( onMetadataChanged: () -> Unit, onSetWatched: (EpisodeRecord, Boolean) -> Unit, padding: PaddingValues, + /** Several episodes seen or unseen at once, named for the message. */ + onMarkEpisodes: (List, Boolean, String) -> Unit = { _, _, _ -> }, /** Every title of its franchise, in release order, this one included. */ franchise: List = emptyList(), onOpenTitle: (TitleRecord) -> Unit = {}, @@ -343,7 +347,9 @@ internal fun TitleScreen( verticalAlignment = Alignment.CenterVertically, ) { LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry -> + // By position: two seasons can share a label, and a + // repeated key is a crash in a lazy list. + itemsIndexed(title.seasons, key = { index, _ -> index }) { index, entry -> FilterChip( selected = index == seasonIndex, onClick = { seasonIndex = index }, @@ -352,6 +358,15 @@ internal fun TitleScreen( } } season?.let { + // For someone joining the show partway, who would + // otherwise tick every earlier episode one by one. + val seen = it.episodes.all(EpisodeRecord::watched) + IconButton(onClick = { onMarkEpisodes(it.episodes, !seen, "${title.displayName} ${it.label}") }) { + Icon( + if (seen) Icons.Default.RemoveDone else Icons.Default.DoneAll, + contentDescription = "Mark ${it.label} ${if (seen) "unwatched" else "watched"}", + ) + } IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) { Icon(Icons.Default.Download, contentDescription = "Download ${it.label}") } diff --git a/android/app/src/test/screenshots/title-phone-light.png b/android/app/src/test/screenshots/title-phone-light.png index 20fc01b..805fa05 100644 Binary files a/android/app/src/test/screenshots/title-phone-light.png and b/android/app/src/test/screenshots/title-phone-light.png differ diff --git a/android/app/src/test/screenshots/title-phone.png b/android/app/src/test/screenshots/title-phone.png index 13f61e5..7d3330d 100644 Binary files a/android/app/src/test/screenshots/title-phone.png and b/android/app/src/test/screenshots/title-phone.png differ diff --git a/android/app/src/test/screenshots/title-tablet.png b/android/app/src/test/screenshots/title-tablet.png index 55e1b0c..c47aa22 100644 Binary files a/android/app/src/test/screenshots/title-tablet.png and b/android/app/src/test/screenshots/title-tablet.png differ diff --git a/ios/ProtonStream/App/AppModel.swift b/ios/ProtonStream/App/AppModel.swift index a48fbb8..7197257 100644 --- a/ios/ProtonStream/App/AppModel.swift +++ b/ios/ProtonStream/App/AppModel.swift @@ -484,8 +484,15 @@ final class AppModel { /// title's positions are what this throws away, and a mis-tap on a tile's /// menu should not cost them. func setTitleWatched(_ title: TitleRecord, _ watched: Bool) { - showWatched(title.playlist, watched) - changeWatch(title.playlist, "\(title.displayName) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in + markEpisodes(title.playlist, watched, title.displayName) + } + + /// Some episodes seen or unseen — a season, or a whole title — with an + /// Undo; `what` names them in the message. + func markEpisodes(_ episodes: [EpisodeRecord], _ watched: Bool, _ what: String) { + let episodes = episodes.filter { $0.watched != watched } + showWatched(episodes, watched) + changeWatch(episodes, "\(what) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in let duration = before?.durationSecs try engine.saveWatchState( shareId: episode.shareId, diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift index 7790bb7..2238045 100644 --- a/ios/ProtonStream/UI/TitleView.swift +++ b/ios/ProtonStream/UI/TitleView.swift @@ -246,12 +246,19 @@ struct TitleView: View { HStack(spacing: 0) { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - ForEach(Array(title.seasons.enumerated()), id: \.element.label) { index, entry in + // By position: two seasons can share a label. + ForEach(Array(title.seasons.enumerated()), id: \.offset) { index, entry in FilterChip(label: entry.label, selected: index == seasonIndex) { seasonIndex = index } } } } if let season { + // For someone joining the show partway, who would otherwise + // tick every earlier episode one by one. + let seen = season.episodes.allSatisfy(\.watched) + IconButton(seen ? "checkmark.circle.badge.xmark" : "checkmark.circle", "Mark \(season.label) \(seen ? "unwatched" : "watched")") { + model.markEpisodes(season.episodes, !seen, "\(title.displayName) \(season.label)") + } IconButton("arrow.down.circle", "Download \(season.label)") { DownloadCoordinator.shared.enqueue(season.episodes) }