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.
This commit is contained in:
nvrl committed 2026-10-13 15:48:59 +02:00
1 parent 5c15f45ace
commit 5c0519a226
4 files changed
+49 -39

No files matched your search

@@ -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 * at its duration where one is known, so the progress bar agrees with the
* tick. * tick.
*/ */
fun setWatched(episode: EpisodeRecord, watched: Boolean) { /** One episode's tick, with the same Undo a season's has. */
viewModelScope.launch { fun setWatched(episode: EpisodeRecord, watched: Boolean) = markEpisodes(listOf(episode), watched, episode.label)
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()
}
}
fun removeAllOffline() { fun removeAllOffline() {
viewModelScope.launch { viewModelScope.launch {
@@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.itemsIndexed
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -391,6 +394,14 @@ internal fun TitleScreen(
onPause = { DownloadCoordinator.pause(context, it) }, onPause = { DownloadCoordinator.pause(context, it) },
onResume = { DownloadCoordinator.resume(context, it) }, onResume = { DownloadCoordinator.resume(context, it) },
onSetWatched = { onSetWatched(episode, 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, onPause: (RetainedDownload) -> Unit,
onResume: (RetainedDownload) -> Unit, onResume: (RetainedDownload) -> Unit,
onSetWatched: (Boolean) -> 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 || val running = download?.status == RetainedDownload.STATUS_RUNNING ||
download?.status == RetainedDownload.STATUS_QUEUED download?.status == RetainedDownload.STATUS_QUEUED
val heading = episode.heading(position) val heading = episode.heading(position)
Column( Column(
Modifier Modifier
.fillMaxWidth() .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), .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) { Row(verticalAlignment = Alignment.CenterVertically) {
// With metadata off the provider has no still, and Proton's own // 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 // thumbnail is a frame of this very episode. Where it was left sits
+2 -18
View File
@@ -737,25 +737,9 @@ final class AppModel {
/// Unwatching rewinds, matching the desktop client. Marking one seen puts /// Unwatching rewinds, matching the desktop client. Marking one seen puts
/// the position at its duration where one is known, so the progress bar /// the position at its duration where one is known, so the progress bar
/// agrees with the tick. /// agrees with the tick.
/// One episode's tick, with the same Undo a season's has.
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) { func setWatched(_ episode: EpisodeRecord, _ watched: Bool) {
showWatched([episode], watched) markEpisodes([episode], watched, episode.label)
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()
}
} }
/// Draw a watched mark the moment it is tapped, before the bridge has /// Draw a watched mark the moment it is tapped, before the bridge has
+14 -2
View File
@@ -72,7 +72,11 @@ struct TitleView: View {
position: position, position: position,
download: downloads.first { $0.shareId == episode.shareId && $0.linkId == episode.linkId }, download: downloads.first { $0.shareId == episode.shareId && $0.linkId == episode.linkId },
onPlay: { onPlay(playlist, playlist.firstIndex { $0.linkId == episode.linkId } ?? 0) }, 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) .padding(.horizontal, side)
} }
@@ -393,7 +397,11 @@ private struct EpisodeRow: View {
let download: RetainedDownload? let download: RetainedDownload?
let onPlay: () -> Void let onPlay: () -> Void
let onSetWatched: (Bool) -> 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 @Environment(\.scheme) private var scheme
@State private var holding = false
private var subline: String { private var subline: String {
let size: String? = episode.size.map { formatBytes($0) } let size: String? = episode.size.map { formatBytes($0) }
@@ -460,7 +468,11 @@ private struct EpisodeRow: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 6) .padding(.vertical, 6)
.contentShape(Rectangle()) .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)
} }
} }