From 5c0519a226cf3498b4e8872cd56dd1a032a62f8e Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 13 Oct 2026 15:48:59 +0200 Subject: [PATCH] feat(android,ios): mark watched up to an episode, and undo an episode's tick A long press on an episode row offers "Mark watched up to here", as a right click on the desktop's tick does. It is for someone joining a show partway. Ticking one episode now goes through the same path as a season, so it gets the same Undo. --- .../io/narl/protonstream/ui/AppViewModel.kt | 20 ++---------- .../io/narl/protonstream/ui/TitleScreen.kt | 32 ++++++++++++++++++- ios/ProtonStream/App/AppModel.swift | 20 ++---------- ios/ProtonStream/UI/TitleView.swift | 16 ++++++++-- 4 files changed, 49 insertions(+), 39 deletions(-) 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 1e4c409..dd0db67 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 @@ -811,24 +811,8 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie * at its duration where one is known, so the progress bar agrees with the * tick. */ - fun setWatched(episode: EpisodeRecord, watched: Boolean) { - viewModelScope.launch { - runCatching { - withContext(Dispatchers.IO) { - val engine = NativeRuntime.engine() - val duration = engine.watchState(episode.shareId, episode.linkId)?.durationSecs - engine.saveWatchState( - episode.shareId, - episode.linkId, - if (watched) duration ?: 0.0 else 0.0, - duration, - watched, - ) - } - }.onFailure(::reportError) - reload() - } - } + /** One episode's tick, with the same Undo a season's has. */ + fun setWatched(episode: EpisodeRecord, watched: Boolean) = markEpisodes(listOf(episode), watched, episode.label) fun removeAllOffline() { viewModelScope.launch { 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 581ce09..a6fc0b8 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 @@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.ui.graphics.graphicsLayer import kotlin.coroutines.cancellation.CancellationException import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -30,6 +31,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.grid.items import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -391,6 +394,14 @@ internal fun TitleScreen( onPause = { DownloadCoordinator.pause(context, it) }, onResume = { DownloadCoordinator.resume(context, it) }, onSetWatched = { onSetWatched(episode, it) }, + onWatchedUpTo = { + val upTo = playlist.indexOfFirst { it.linkId == episode.linkId } + onMarkEpisodes( + playlist.take(upTo + 1), + true, + "${title.displayName} up to ${episode.label}", + ) + }, ) } } @@ -538,16 +549,35 @@ private fun EpisodeRow( onPause: (RetainedDownload) -> Unit, onResume: (RetainedDownload) -> Unit, onSetWatched: (Boolean) -> Unit, + onWatchedUpTo: () -> Unit = {}, ) { + // A long press, for someone joining a show partway: everything before + // this one ticked at once rather than one by one. + var menu by remember { mutableStateOf(false) } val running = download?.status == RetainedDownload.STATUS_RUNNING || download?.status == RetainedDownload.STATUS_QUEUED val heading = episode.heading(position) Column( Modifier .fillMaxWidth() - .clickable(enabled = playerReady, onClick = onPlay) + .combinedClickable( + enabled = playerReady, + onClick = onPlay, + onLongClick = { menu = true }, + onLongClickLabel = "More for this episode", + ) .padding(horizontal = 16.dp, vertical = 6.dp), ) { + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem( + text = { Text("Mark watched up to here") }, + leadingIcon = { Icon(Icons.Default.DoneAll, contentDescription = null) }, + onClick = { + menu = false + onWatchedUpTo() + }, + ) + } Row(verticalAlignment = Alignment.CenterVertically) { // With metadata off the provider has no still, and Proton's own // thumbnail is a frame of this very episode. Where it was left sits diff --git a/ios/ProtonStream/App/AppModel.swift b/ios/ProtonStream/App/AppModel.swift index 2182cd5..6c32adc 100644 --- a/ios/ProtonStream/App/AppModel.swift +++ b/ios/ProtonStream/App/AppModel.swift @@ -737,25 +737,9 @@ final class AppModel { /// Unwatching rewinds, matching the desktop client. Marking one seen puts /// the position at its duration where one is known, so the progress bar /// agrees with the tick. + /// One episode's tick, with the same Undo a season's has. func setWatched(_ episode: EpisodeRecord, _ watched: Bool) { - showWatched([episode], watched) - Task { - do { - try await run { engine in - let duration = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)?.durationSecs - try engine.saveWatchState( - shareId: episode.shareId, - linkId: episode.linkId, - positionSecs: watched ? duration ?? 0 : 0, - durationSecs: duration, - watched: watched - ) - } - } catch { - reportError(error) - } - reload() - } + markEpisodes([episode], watched, episode.label) } /// Draw a watched mark the moment it is tapped, before the bridge has diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift index 560a7f8..8044b1a 100644 --- a/ios/ProtonStream/UI/TitleView.swift +++ b/ios/ProtonStream/UI/TitleView.swift @@ -72,7 +72,11 @@ struct TitleView: View { position: position, download: downloads.first { $0.shareId == episode.shareId && $0.linkId == episode.linkId }, onPlay: { onPlay(playlist, playlist.firstIndex { $0.linkId == episode.linkId } ?? 0) }, - onSetWatched: { model.setWatched(episode, $0) } + onSetWatched: { model.setWatched(episode, $0) }, + onWatchedUpTo: { + let upTo = playlist.firstIndex { $0.linkId == episode.linkId } ?? 0 + model.markEpisodes(Array(playlist.prefix(upTo + 1)), true, "\(title.displayName) up to \(episode.label)") + } ) .padding(.horizontal, side) } @@ -393,7 +397,11 @@ private struct EpisodeRow: View { let download: RetainedDownload? let onPlay: () -> Void let onSetWatched: (Bool) -> Void + /// Everything up to and including this one, for someone joining a show + /// partway. + let onWatchedUpTo: () -> Void @Environment(\.scheme) private var scheme + @State private var holding = false private var subline: String { let size: String? = episode.size.map { formatBytes($0) } @@ -460,7 +468,11 @@ private struct EpisodeRow: View { .padding(.horizontal, 16) .padding(.vertical, 6) .contentShape(Rectangle()) - .tapAndHold(onPlay) + .tapAndHold(onPlay) { holding = true } + .confirmationDialog(episode.heading(position), isPresented: $holding) { + Button("Mark watched up to here", action: onWatchedUpTo) + } + .accessibilityAction(named: "Mark watched up to here", onWatchedUpTo) } }