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
* 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<EpisodeRecord>, 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(
@@ -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 }
},
@@ -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<EpisodeRecord>, Boolean, String) -> Unit = { _, _, _ -> },
/** Every title of its franchise, in release order, this one included. */
franchise: List<TitleRecord> = 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}")
}
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