feat(android,ios): mark a whole season watched from the title page

Marking was one episode at a time on the title page, or the whole title
from the library's long-press menu, so joining a show at season three
meant ticking every earlier episode. The season row gains a mark button
beside its download one, with an Undo, through a new markEpisodes that
setTitleWatched now uses too. Episodes already in the asked-for state
are left alone, so Undo restores only what changed.

Season chips were keyed by label, and two seasons with one label is a
crash in a lazy list; they are keyed by position now.
This commit is contained in:
nvrl committed 2026-10-12 22:51:10 +02:00
1 parent 6d1943f2bd
commit 01c252b794
8 files changed
+44 -8

No files matched your search

@@ -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 * title's positions are what this throws away, and a mis-tap on a tile's
* menu should not cost them. * menu should not cost them.
*/ */
fun setTitleWatched(title: TitleRecord, watched: Boolean) { fun setTitleWatched(title: TitleRecord, watched: Boolean) =
val episodes = title.seasons.flatMap { it.episodes } 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<EpisodeRecord>, watched: Boolean, what: String) {
changeWatch( changeWatch(
episodes, episodes.filter { it.watched != watched },
"${title.displayName} marked ${if (watched) "watched" else "unwatched"}", "$what marked ${if (watched) "watched" else "unwatched"}",
) { engine, episode, before -> ) { engine, episode, before ->
val duration = before?.durationSecs val duration = before?.durationSecs
engine.saveWatchState( engine.saveWatchState(
@@ -247,6 +247,7 @@ fun ProtonStreamApp(
model::reloadAfterMetadataChange, model::reloadAfterMetadataChange,
model::setWatched, model::setWatched,
body, body,
onMarkEpisodes = model::markEpisodes,
franchise = selected.franchise.mapNotNull { key -> franchise = selected.franchise.mapNotNull { key ->
state.titles.firstOrNull { it.key == key } state.titles.firstOrNull { it.key == key }
}, },
@@ -38,8 +38,10 @@ import androidx.compose.material3.IconButton
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.outlined.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.Download
import androidx.compose.material.icons.filled.Edit 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.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -97,6 +99,8 @@ internal fun TitleScreen(
onMetadataChanged: () -> Unit, onMetadataChanged: () -> Unit,
onSetWatched: (EpisodeRecord, Boolean) -> Unit, onSetWatched: (EpisodeRecord, Boolean) -> Unit,
padding: PaddingValues, padding: PaddingValues,
/** Several episodes seen or unseen at once, named for the message. */
onMarkEpisodes: (List<EpisodeRecord>, Boolean, String) -> Unit = { _, _, _ -> },
/** Every title of its franchise, in release order, this one included. */ /** Every title of its franchise, in release order, this one included. */
franchise: List<TitleRecord> = emptyList(), franchise: List<TitleRecord> = emptyList(),
onOpenTitle: (TitleRecord) -> Unit = {}, onOpenTitle: (TitleRecord) -> Unit = {},
@@ -343,7 +347,9 @@ internal fun TitleScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) { 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( FilterChip(
selected = index == seasonIndex, selected = index == seasonIndex,
onClick = { seasonIndex = index }, onClick = { seasonIndex = index },
@@ -352,6 +358,15 @@ internal fun TitleScreen(
} }
} }
season?.let { 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) }) { IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) {
Icon(Icons.Default.Download, contentDescription = "Download ${it.label}") Icon(Icons.Default.Download, contentDescription = "Download ${it.label}")
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 631 KiB

After

Width:  |  Height:  |  Size: 632 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 KiB

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+9 -2
View File
@@ -484,8 +484,15 @@ final class AppModel {
/// title's positions are what this throws away, and a mis-tap on a tile's /// title's positions are what this throws away, and a mis-tap on a tile's
/// menu should not cost them. /// menu should not cost them.
func setTitleWatched(_ title: TitleRecord, _ watched: Bool) { func setTitleWatched(_ title: TitleRecord, _ watched: Bool) {
showWatched(title.playlist, watched) markEpisodes(title.playlist, watched, title.displayName)
changeWatch(title.playlist, "\(title.displayName) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in }
/// 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 let duration = before?.durationSecs
try engine.saveWatchState( try engine.saveWatchState(
shareId: episode.shareId, shareId: episode.shareId,
+8 -1
View File
@@ -246,12 +246,19 @@ struct TitleView: View {
HStack(spacing: 0) { HStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) { 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 } FilterChip(label: entry.label, selected: index == seasonIndex) { seasonIndex = index }
} }
} }
} }
if let season { 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)") { IconButton("arrow.down.circle", "Download \(season.label)") {
DownloadCoordinator.shared.enqueue(season.episodes) DownloadCoordinator.shared.enqueue(season.episodes)
} }