From 8eba97579f373eb5feb9f114e1ae6debc50f347e Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 23:20:26 +0200 Subject: [PATCH] refactor: concentrate playback transition ownership --- CONTEXT.md | 20 + Checks/automatic-quality-playback.swift | 75 ++- Checks/automatic-quality.py | 23 +- Checks/system-playback.swift | 73 ++- README.md | 8 +- Sources/App/TerebiApp.swift | 598 +++++++++++++++--------- 6 files changed, 559 insertions(+), 238 deletions(-) create mode 100644 CONTEXT.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..9ee0c5f --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,20 @@ +# Terebi playback + +Terebi plays local media and YouTube videos. These terms distinguish a playback request from the state the player has actually reached. + +## Language + +**Playback transition**: +A requested change of media, playback state, position, or quality that may still be awaiting completion. Acceptance of a request does not mean playback has reached the requested state. + +**Playback intent**: +The user's latest requested playing or paused state, which can differ from the state playback has acknowledged. + +**Acknowledged position**: +The playback position confirmed by the player after any pending seek or restoration has completed. A seek target or slider preview is not an acknowledged position. + +**Restoration**: +Resuming a video's saved position, or preserving position and playing or paused state while changing its quality. + +**Media identity**: +The identity of the video being played, independent of its selected quality or extracted stream address. For a YouTube video, this is its canonical video webpage URL. diff --git a/Checks/automatic-quality-playback.swift b/Checks/automatic-quality-playback.swift index 95553dd..67a5ab5 100644 --- a/Checks/automatic-quality-playback.swift +++ b/Checks/automatic-quality-playback.swift @@ -75,7 +75,80 @@ struct AutomaticQualityPlaybackChecks { qualityClock: { clock.now }) require(manual.automaticYouTubeQuality, "manual session override does not replace startup configuration") manual.shutdown() + // Control only the external extractor; media decoding and all model transitions stay real. + guard let controlPath = ProcessInfo.processInfo.environment["TEREBI_QUALITY_CONTROL"] else { + require(false, "isolated extraction controls are available") + return + } + let control = URL(fileURLWithPath: controlPath, isDirectory: true) + func mark(_ name: String) { + require(FileManager.default.createFile(atPath: control.appendingPathComponent(name).path, + contents: Data()), "publish extraction control " + name) + } + func marked(_ name: String) -> Bool { + FileManager.default.fileExists(atPath: control.appendingPathComponent(name).path) + } + model.setYouTubeQuality(720) + until(model, "manual selection opens real healthy 720p media") { + model.youtubeHeight == 720 && model.canChangeYouTubeQuality && model.state.status == PLAYER_PLAYING && + !model.state.seek_pending && (model.bufferedRange.map { $0.upperBound - model.state.position >= 20 } ?? false) + } + let selectedTitle = model.fileName + let selectedQuality = model.youtubeQuality + mark("gate-1080") + clock.now = 3000 + model.setAutomaticYouTubeQuality(true) + polled(model) + let healthyPosition = model.state.position + until(model, "healthy native playback continues before the controlled upshift") { + model.state.position > healthyPosition + 0.02 + } + clock.now = 3060 + until(model, "automatic upshift reaches the selected extraction gate") { + marked("entered-1080") && model.resolvingYouTube + } + require(model.setPlayback(playing: false), "pause is accepted during automatic extraction") + mark("release-1080") + until(model, "cancelled extraction finishes and native pause is acknowledged") { + marked("finished-1080") && !model.resolvingYouTube && model.canTogglePlayback && + model.state.status == PLAYER_PAUSED && !model.state.seek_pending + } + let cancelledPosition = model.state.position + for _ in 0..<3 { + polled(model) + require(model.state.status == PLAYER_PAUSED && abs(model.state.position - cancelledPosition) < 0.05 && + model.state.has_video && model.state.has_audio && + model.youtubeHeight == 720 && model.youtubeQuality == selectedQuality && + model.fileName == selectedTitle && model.errorText == nil, + "late extraction cannot reopen different quality or resume deliberately paused media") + } + try! FileManager.default.removeItem(at: control.appendingPathComponent("gate-1080")) + require(model.seekRelative(37 - model.state.position), "original paused media accepts a nonzero seek") + until(model, "native original media acknowledges the seek") { + model.canChangeYouTubeQuality && model.state.status == PLAYER_PAUSED && + !model.state.seek_pending && abs(model.state.position - 37) < 0.05 + } + mark("fail-1080") + for playing in [false, true] { + require(model.setPlayback(playing: playing), "original playback state is selectable before quality failure") + until(model, "original native playback reaches the requested state before quality failure") { + model.canChangeYouTubeQuality && model.state.status == (playing ? PLAYER_PLAYING : PLAYER_PAUSED) && + (!playing || model.state.position > 37.1) + } + let originalPosition = model.state.position + model.setYouTubeQuality(1080) + until(model, "failed manual quality switch restores the original native playback state") { + model.errorText != nil && !model.resolvingYouTube && model.canTogglePlayback && + model.state.status == (playing ? PLAYER_PLAYING : PLAYER_PAUSED) && + (!playing || model.state.position > originalPosition + 0.1) + } + require(model.state.has_video && model.state.has_audio && model.youtubeHeight == 720 && + model.youtubeQuality == selectedQuality && model.fileName == selectedTitle && + model.state.position >= originalPosition - 0.05 && + model.state.position < originalPosition + (playing ? 2 : 0.05), + "failed manual quality switch retains original selected media and playback progress") + } model.closeFile() - print("PASS native adaptive playback: default-off/stalled downshift/healthy upshift/progress preservation/current+default manual override/session-only settings") + print("PASS native adaptive playback: default-off/stalled downshift/healthy upshift/progress preservation/current+default manual override/session-only settings/cancelled extraction pause/failed manual quality playing+paused preservation") } } diff --git a/Checks/automatic-quality.py b/Checks/automatic-quality.py index b20ecbc..10162c9 100755 --- a/Checks/automatic-quality.py +++ b/Checks/automatic-quality.py @@ -42,11 +42,30 @@ def main(): metadata = root / "info.json" metadata.write_text(json.dumps(info)) wrapper = root / "yt-dlp" - wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\nexec " + shlex.quote(extractor) + control = root / "control" + control.mkdir() + wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\n" + + "control=" + shlex.quote(str(control)) + "\n" + + 'if [[ "$*" == *"[height=1080]"* ]]; then\n' + + ' if [[ -f "$control/fail-1080" ]]; then\n' + + ' echo "Fixture extraction deliberately failed" >&2; exit 1\n' + + ' fi\n' + + ' if [[ -f "$control/gate-1080" ]]; then\n' + + " " + shlex.quote(extractor) + " --load-info-json " + shlex.quote(str(metadata)) + + ' "${args[@]:0:${#args[@]}-2}" > "$control/output-1080.json"\n' + + ' trap \': > "$control/finished-1080"; exit 0\' TERM\n' + + ' : > "$control/entered-1080"\n' + + ' while [[ ! -f "$control/release-1080" ]]; do sleep 0.01; done\n' + + ' cat "$control/output-1080.json" || true\n' + + ' : > "$control/finished-1080"\n' + + ' exit 0\n' + + ' fi\n' + + 'fi\nexec ' + shlex.quote(extractor) + " --load-info-json " + shlex.quote(str(metadata)) + ' "${args[@]:0:${#args[@]}-2}"\n') wrapper.chmod(0o755) - environment = dict(os.environ, PATH=str(root) + os.pathsep + os.environ.get("PATH", "")) + environment = dict(os.environ, PATH=str(root) + os.pathsep + os.environ.get("PATH", ""), + TEREBI_QUALITY_CONTROL=str(control)) try: result = subprocess.run([str(args.binary.resolve())], env=environment, timeout=90, check=False) except subprocess.TimeoutExpired: diff --git a/Checks/system-playback.swift b/Checks/system-playback.swift index 6ff10dd..32d7da6 100644 --- a/Checks/system-playback.swift +++ b/Checks/system-playback.swift @@ -63,13 +63,24 @@ struct SystemPlaybackChecks { require(center.nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] as? Double == 0, "paused system timeline does not extrapolate") let playbackKey = try! KeyChord("space") + let oppositeIntentRevision = model.state.revision require(model.handleKey(playbackKey) && model.effectivePlaying, "play binding records intent before backend acknowledgement") require(model.handleKey(playbackKey) && !model.effectivePlaying, "same binding resolves pause against pending play intent") require(model.handleKey(playbackKey, isRepeat: true) && !model.effectivePlaying, "held paired shortcut is consumed without oscillating") - until("rapid opposite intents settle to paused") { model.state.status == PLAYER_PAUSED } + until("rapid opposite intents acknowledge play before the final pause") { + model.state.revision != oppositeIntentRevision && model.state.status == PLAYER_PLAYING + } + let playingRevision = model.state.revision + until("rapid opposite intents settle to an acknowledged native pause") { + model.state.revision != playingRevision && model.state.status == PLAYER_PAUSED && + !model.effectivePlaying && center.playbackState == .paused + } + require(center.nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] as? Double == 0, + "latest pause intent stops the system timeline after native acknowledgement") + let settledPosition = model.state.position require(!model.perform(.pause) && !model.effectivePlaying, "explicit pause cannot resume an already paused player") @@ -87,7 +98,7 @@ struct SystemPlaybackChecks { require(model.seekRelative(1), "relative seek accepted") until("paused seek acknowledged") { model.state.status == PLAYER_PAUSED && !model.state.seek_pending && - abs(model.state.position - pausedPosition - 1) < 0.05 + abs(model.state.position - settledPosition - 1) < 0.05 } require(model.seekRelative(-100), "backward boundary seek accepted") until("backward seek clamps at start") { !model.state.seek_pending && model.state.position == 0 } @@ -114,6 +125,64 @@ struct SystemPlaybackChecks { "accepted close releases system playback immediately") require(!commands.togglePlayPauseCommand.isEnabled && !commands.nextTrackCommand.isEnabled, "closed source cannot receive system playback or seek commands") + + let localURL = directory.appendingPathComponent("av.mp4") + let progress = PlaybackProgress(defaults: defaults) + let savedPosition = seekableState.duration / 2 + progress.save(savedPosition, for: localURL) + var openRevision = model.state.revision + model.open(localURL) + until("local saved position is restored before native autoplay") { + guard model.state.revision != openRevision else { return false } + if model.state.status == PLAYER_PLAYING && !model.state.seek_pending { + require(model.state.position >= savedPosition, + "local autoplay never acknowledges playback before the saved position") + return model.state.position < savedPosition + 0.5 + } + require(progress.position(for: localURL) == savedPosition, + "pre-restoration snapshots cannot overwrite saved history") + return false + } + require(model.setPlayback(playing: false), "restored local playback can pause") + until("restored local pause acknowledged") { model.state.status == PLAYER_PAUSED && !model.state.seek_pending } + let restoredPosition = model.state.position + model.closeFile() + require(progress.position(for: localURL) == restoredPosition, + "close persists acknowledged restored position") + openRevision = model.state.revision + model.open(localURL) + until("close and reopen resumes the persisted native position") { + guard model.state.revision != openRevision && model.state.status == PLAYER_PLAYING && + !model.state.seek_pending else { return false } + require(model.state.position >= restoredPosition, + "reopened local autoplay starts at acknowledged persisted progress") + return model.state.position < restoredPosition + 0.5 + } + model.closeFile() + let invalidPosition = seekableState.duration + 1 + progress.save(invalidPosition, for: localURL) + openRevision = model.state.revision + model.open(localURL) + until("duration-exceeded bookmark is discarded and native playback advances") { + model.state.revision != openRevision && model.state.status == PLAYER_PLAYING && + !model.state.seek_pending && model.state.position > 0.1 && model.state.position < savedPosition + } + require((progress.position(for: localURL) ?? 0) < seekableState.duration, + "duration-exceeded bookmark is removed from exposed history") + model.closeFile() + let replacementPosition = progress.position(for: localURL)! + require(replacementPosition > 0 && replacementPosition < savedPosition, + "close replaces invalid history with acknowledged playback progress") + openRevision = model.state.revision + model.open(localURL) + until("reopening no longer repeats a duration-exceeded restoration") { + guard model.state.revision != openRevision && model.state.status == PLAYER_PLAYING && + !model.state.seek_pending else { return false } + require(model.state.position >= replacementPosition, + "replacement bookmark restores before reopened autoplay") + return model.state.position < replacementPosition + 0.5 + } + model.closeFile() model.open(directory.appendingPathComponent("hdr.mkv")) until("decode error acknowledged") { model.state.status == PLAYER_ERROR && !model.isBusy } require(center.nowPlayingInfo == nil && !commands.playCommand.isEnabled, diff --git a/README.md b/README.md index 6c39198..dd58b31 100644 --- a/README.md +++ b/README.md @@ -206,12 +206,14 @@ Only implemented actions have controls; there are no placeholder playlist, subti ## Where to change things +Playback terms are defined in [CONTEXT.md](CONTEXT.md). + - `Sources/Core/player.h`: the C interface and authoritative state snapshot. Playback commands enqueue work; poll state for completion. `player_open` accepts local files; `player_open_network` copies selected HTTP(S) URLs, per-input headers, duration, live status, canonical `media_url`, and `initial_position`. Initial network restoration completes before reporting ready state. `player_set_progress_file` initializes opt-in reporting before opening media; its errors and `player_get_progress_error` are separate from playback errors. `player_shutdown` stops the worker and drains reporting, returning final reporting errors before handle destruction. `can_seek` reflects both selected inputs; `buffering` reports a stalled network clock. `buffered_start`/`buffered_end` report a conservative retained/playable interval in normalized seconds, constrained by required tracks; unavailable/local/pending ranges are `0, 0`, the start may include reusable history before `position`, and the end is no later than `duration`. `seek_pending` is set synchronously on acceptance and clears when queued seeks/steps and both network input seeks finish. `player_step_frame` accepts only `-1` or `+1`, requires ready seekable video, and publishes the selected frame's timestamp as `position`. Volume set/get is synchronous and lock-free. Callers own the opaque handle and must stop using it before destruction. - `Sources/Core/player.cpp`: demux/decode worker for local files and decode/timeline worker for network sources, bounded decoded buffers, authored timestamps, audio-master synchronization, paused previews, exact frame stepping, independent decoder/resampler drain, seeking, errors, and shutdown. Seeks stop output and flush decoder/output state; network inputs replay retained packets or seek/refill on cache misses. - `Sources/Core/progress_reporter.hpp` and `.cpp`: shared JSONL schema, canonical identity, acknowledged snapshots, eligibility, clock-injected cadence/threshold detection, lifecycle records, and asynchronous file writer. File creation/write uses POSIX facilities; reporting policy is independent of AppKit/Swift. - `Sources/Core/network_input.hpp`: network-only demux readers with a shared compressed-byte budget, playback-anchored read-ahead/history, keyframe/audio-preroll seek indices, conservative reordered-video timestamp frontiers, cached replay, asynchronous miss seeks, fifteen-second I/O deadlines, and interruption on miss/replacement/close/destruction. Cache hits leave outstanding fetch-frontier I/O intact. Remote input protocols exclude local-file access through manifests. - `Sources/Core/output.hpp` and `Sources/macOS/Output.mm`: native output contract, allocation-free/nonblocking Core Audio callback and bounded audio ring, latency-aware presentation clock, retained frame mailbox, and Metal YUV conversion. The view can outlive the player safely. -- `Sources/App/TerebiApp.swift`: MainActor state polling, file/YouTube panels and Finder/drop handling, cancellable URL resolution, manual/automatic YouTube quality switching with position/playback-state restoration, autoplay, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration. New media opens autoplay; quality switches preserve play/pause state. The C interface opens paused for callers that need explicit control. +- `Sources/App/TerebiApp.swift`: MainActor `PlayerModel` retains the control interface. Private `PlaybackTransition` owns extraction lifetime, open/seek acknowledgement, playback intent, local restoration, and checkpoint eligibility. `MediaSource`/`MediaChange` feed one opening path for files, YouTube, and quality changes. Native observations checkpoint before consuming local restoration; network restoration and JSONL reporting remain in C++. Panels, controls, visibility, video sizing, and window integration remain presentation. - `Sources/App/PlayerActions.swift`: native event-to-chord adaptation, editing/accessibility eligibility, explicit context-menu dispatch, and scene-focused SwiftUI commands sharing the model's action bindings. - `Sources/App/AutomaticQuality.swift`: clock-injected quality decisions using reserve/stall hysteresis, stepwise resolution choices, cooldown and extraction-failure backoff. Ordinary state revisions do not reset health; source/seek/pause transitions do. - `Sources/App/PlaybackProgress.swift`: local per-video position persistence and canonical file identities, using injectable UserDefaults. The player model checkpoints authoritative positions, supplies saved YouTube positions as backend open intent, and queues local restoration against the ready source. @@ -243,6 +245,8 @@ Generates short fixtures and exercises the real playback interface and native ou `make check-quality` runs deterministic policy checks with explicit monotonic clock values: downward/upward hysteresis, sparse available resolutions, the 1080p cap, cooldown, pause/seek/open/local/default-off suppression, recorded EOF, live/unknown reserve, frozen progress, and failure backoff. `make check-adaptive` exercises the actual app model with gated loopback 1080p/720p media and offline yt-dlp selection: default-off behavior, a sustained-stall downshift, healthy upshift, position preservation, current/default manual overrides, and session-only opt-in/configuration restoration. The quality clock is injected; tests use isolated playback history and muted playback. Both targets are included in `make check`; the native adaptive portion is skipped if yt-dlp is absent. No external network is required. +Adaptive transition checks also gate a real extraction, cancel it by pausing, and verify that late completion cannot reopen quality or resume paused playback. Failed manual quality changes preserve the original media, position, and prior playing/paused state. + `make check-progress` verifies persisted fractional positions, independent local/YouTube identities, symlink aliases, completion clearing, invalid-position rejection, isolated preference domains, independently initialized writers preserving each other's videos, and migration of existing bookmarks. It is included in `make check` and does not touch real playback history. `make check-reporting` decodes real JSONL files with an injected clock: initial/period boundaries, pending versus acknowledged seeks, threshold/pause/end/close facts, quality/replacement identity, nullable duration and eligibility, exclusive private creation, existing/symlink/concurrent rejection, and isolated OS write failure. `make check-network` also exercises the real backend's acknowledged initial restoration, audio-only/live eligibility, failed/cancelled opens, final drain, and continued playback/seek/replacement after a failed writer. Both are included in `make check`. @@ -253,6 +257,8 @@ Generates short fixtures and exercises the real playback interface and native ou `make check-media` exercises actual playback through the app model, idempotent play/pause, immediate and rapid opposite playback intents, held-key suppression, paused seeking and start-boundary clamping, non-seekable state transitions, immediate hiding of a stale buffered snapshot on accepted close, and Now Playing cleanup on close/error/shutdown. It also checks default-off/configured settings, session-only overrides, and retained-range independence from the display preference. It is included in `make check` and uses isolated playback history with muted audio. Native MediaPlayer command dispatch was also smoke-checked; physical media-key routing and macOS's choice among competing media apps still need a keyboard check in the running app. +Playback transition checks wait for native acknowledgement of rapid opposite intents, restore local bookmarks before autoplay, persist/resume the acknowledged position across close/reopen, and discard duration-exceeded bookmarks without preventing playback. + `make check-startup` launches an isolated app bundle through LaunchServices using the production bootstrap and a check-only player library with actual native playback. It verifies the registered app PID/bundle identity, then runs the real CLI against the production startup/SwiftUI scene/delegate: missing-config defaults without creation, nested settings/key overrides, actual plain/modified shortcuts, rapid opposite intents and held-key suppression, same-window compact entry/restoration, removed old bindings, real text insertion without playback interception, native menu equivalents and Play/Pause selections, malformed-file exit 78, cold/warm `--no-config`, unchanged file bytes and existing session settings, cold/default reuse, independent empty/local/YouTube launches, exactly one player window per PID, targeted media, retained original playback, help without launch, and warm default replacement. YouTube uses loopback media and actual offline yt-dlp selection; that portion requires yt-dlp and a supported JS runtime but no external network. It is included in `make check`; windows are transparent and ignore mouse input, audio muted, and configuration/bundle/history isolated. Native menus receive their normal lazy-population/validation callbacks before direct selection. Finder/Space behavior, visible controls/menu presentation and Vorssaint discovery still need a check in the app. Startup checks also exercise actual CLI-to-GUI reporting after the launcher exits: two independent private streams, canonical identities, acknowledged resume/seek/pause, quality reopenings, replacement/end/close/quit, failed resolution, local-media exclusions, and existing/dangling-destination preflight errors. Report readers tolerate duplicates and partial trailing lines; watched facts are derived from eligible native positions, not launcher success. diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 17bbb3a..c6c032f 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -12,10 +12,11 @@ final class PlayerModel: ObservableObject { @Published private(set) var fileName: String? @Published private(set) var fileIcon: NSImage? @Published private(set) var commandError: String? - @Published private(set) var pendingRevision: UInt64? - @Published private(set) var resolvingYouTube = false - @Published private(set) var isLiveStream = false - @Published private(set) var isNetworkMedia = false + private var transition = PlaybackTransition() + var pendingRevision: UInt64? { transition.pendingRevision } + var resolvingYouTube: Bool { transition.isResolving } + var isLiveStream: Bool { transition.isLive } + var isNetworkMedia: Bool { transition.isNetwork } @Published private(set) var youtubeHeights: [Int] = [] @Published private(set) var youtubeHeight: Int? @Published private(set) var youtubeQuality: Int? @@ -29,8 +30,7 @@ final class PlayerModel: ObservableObject { @Published private(set) var isOnTop = false let compactWindow: CompactWindowController let keyBindings: KeyBindings - @Published private(set) var playbackIntent: Bool? - private var playbackInFlight: (playing: Bool, revision: UInt64)? + var playbackIntent: Bool? { transition.playbackIntent } @Published private(set) var fullScreenIntent: Bool? @Published private(set) var compactIntent: Bool? var effectivePlaying: Bool { playbackIntent ?? (state.status == PLAYER_PLAYING) } @@ -68,18 +68,10 @@ final class PlayerModel: ObservableObject { private var windowActive = true private var lastAudibleVolume = 1.0 private var needsVideoFit = false - private var seekPending = false - private var youtubeTask: Task? - private var youtubeRequest: UUID? - private var resolvingAutomaticQuality = false private let qualityClock: () -> Double private var automaticQuality = AutomaticQuality() private var qualityNow: Double { qualityClock() } private let playbackProgress: PlaybackProgress - private var progressURL: URL? - private var resumePosition: Double? - private var resumePlaying = true - private var lastSavedPosition: Double? private var systemPlayback: SystemPlaybackControls? private(set) var progressInitializationError: String? private var reportingEnabled = false @@ -131,7 +123,7 @@ final class PlayerModel: ObservableObject { systemPlayback = SystemPlaybackControls(model: self) } - var isBusy: Bool { (resolvingYouTube && !resolvingAutomaticQuality) || pendingRevision != nil || state.status == PLAYER_OPENING } + var isBusy: Bool { transition.isBusy(state.status) } var canTogglePlayback: Bool { handle != nil && !isBusy && (state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED) @@ -139,14 +131,14 @@ final class PlayerModel: ObservableObject { var canSeek: Bool { canTogglePlayback && state.can_seek && state.duration.isFinite && state.duration > 0 } var canStepFrame: Bool { canTogglePlayback && state.has_video && state.can_seek && !isSeeking } var canChangeYouTubeQuality: Bool { - canTogglePlayback && !youtubeHeights.isEmpty && !isSeeking && !seekPending && !state.seek_pending + canTogglePlayback && !youtubeHeights.isEmpty && !isSeeking && !transition.seekPending && !state.seek_pending } var canOpen: Bool { handle != nil } var canClose: Bool { handle != nil && (isBusy || state.status != PLAYER_EMPTY || fileName != nil) } var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR } var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 } var bufferedRange: ClosedRange? { - guard fileName != nil, canSeek, !seekPending, !state.seek_pending, + guard fileName != nil, canSeek, !transition.seekPending, !state.seek_pending, state.buffered_start.isFinite, state.buffered_end.isFinite, state.buffered_start >= 0, state.buffered_end > state.buffered_start, state.buffered_end <= duration else { return nil } @@ -214,14 +206,14 @@ final class PlayerModel: ObservableObject { func setAutomaticYouTubeQuality(_ enabled: Bool) { automaticYouTubeQuality = enabled automaticQuality = AutomaticQuality() - if !enabled && resolvingAutomaticQuality { cancelYouTubeResolution() } + if !enabled && transition.resolvingAutomaticQuality { cancelYouTubeResolution() } revealControls() } func setYouTubeQuality(_ height: Int?) { // A manual choice is an override, including reselecting the active/default resolution. setAutomaticYouTubeQuality(false) - guard canChangeYouTubeQuality, let url = progressURL, let handle, + guard canChangeYouTubeQuality, let url = transition.mediaURL, let handle, height == nil || youtubeHeights.contains(height!) else { return } guard height != youtubeQuality else { return } let target = height ?? youtubeHeights.filter { $0 <= 1080 }.max() @@ -229,180 +221,148 @@ final class PlayerModel: ObservableObject { youtubeQuality = height return } - let current = player_get_state(handle) - let position = !isLiveStream && current.can_seek ? current.position : nil - resolveYouTube(url, height: height, restoring: (position, effectivePlaying)) + let change = transition.qualityChange(player_get_state(handle), playing: effectivePlaying) + resolveYouTube(url, height: height, change: change) } - private func resolveYouTube(_ url: URL, height: Int?, restoring: (position: Double?, playing: Bool)? = nil, - automatic: Bool = false) { + private func resolveYouTube(_ url: URL, height: Int?, change: MediaChange = .open) { guard let handle else { return } cancelYouTubeResolution() - if !automatic { automaticQuality = AutomaticQuality() } - if !automatic && !player_pause(handle) { + if !change.automatic { automaticQuality = AutomaticQuality() } + if !change.automatic && !player_pause(handle) { commandError = "Could not pause the current media to open YouTube. Please try again." revealControls() return } - if !automatic { - playbackIntent = nil - playbackInFlight = nil - } - let request = UUID() - youtubeRequest = request - resolvingYouTube = true - resolvingAutomaticQuality = automatic + objectWillChange.send() + let request = transition.beginResolution(change) systemPlayback?.update() commandError = nil revealControls() - youtubeTask = Task { [weak self] in + let task = Task { [weak self] in do { let video = try await YouTubeResolver.resolve(url, height: height) - guard !Task.isCancelled, let self, self.youtubeRequest == request, self.handle != nil else { return } - if automatic { + guard !Task.isCancelled, let self, let handle = self.handle else { return } + self.objectWillChange.send() + guard var change = self.transition.finishResolution(request) else { return } + if change.automatic { let current = player_get_state(handle) - guard self.automaticYouTubeQuality, current.status == PLAYER_PLAYING, - !self.isSeeking, !self.seekPending, !current.seek_pending, - self.pendingRevision == nil, - self.isLiveStream || !current.duration.isFinite || current.duration <= 0 || - current.duration - current.position > 15 else { - self.cancelYouTubeResolution() + guard self.transition.canApplyAutomaticQuality(current, enabled: self.automaticYouTubeQuality, + previewingSeek: self.isSeeking) else { self.automaticQuality.resetHealth() return } - let position = !self.isLiveStream && current.can_seek ? current.position : nil - self.cancelYouTubeResolution() - self.openResolvedYouTube(video, height: height, restoring: (position, true), automatic: true) - } else { - self.cancelYouTubeResolution() - self.openResolvedYouTube(video, height: height, restoring: restoring) + change = self.transition.qualityChange(current, playing: true, automatic: true) } + self.openMedia(.youtube(video, height: height), change: change) } catch { - guard !Task.isCancelled, let self, self.youtubeRequest == request else { return } - self.cancelYouTubeResolution() - if automatic { - self.automaticQuality.extractionFailed(at: self.qualityNow) - } else { - self.commandError = error.localizedDescription - if restoring?.playing == true, let handle = self.handle, !player_play(handle) { - self.commandError = error.localizedDescription + "\nCould not resume the previous playback." - } - self.revealControls() - } + guard !Task.isCancelled, let self else { return } + self.objectWillChange.send() + guard let change = self.transition.finishResolution(request) else { return } + self.failYouTubeChange(change, error: error) } } + transition.attachResolution(task, request: request) } private func cancelYouTubeResolution() { - youtubeRequest = nil - youtubeTask?.cancel() - youtubeTask = nil - resolvingYouTube = false - resolvingAutomaticQuality = false + guard transition.isResolving else { return } + objectWillChange.send() + transition.cancelResolution() } - private func openResolvedYouTube(_ video: YouTubeVideo, height: Int?, restoring: (position: Double?, playing: Bool)?, - automatic: Bool = false) { + private func failYouTubeChange(_ change: MediaChange, error: Error? = nil) { + if change.automatic { + automaticQuality.extractionFailed(at: qualityNow) + return + } + commandError = error?.localizedDescription ?? "Could not queue the YouTube video to open. Please try again." + if change.isQuality && change.playing, let handle, !player_play(handle) { + commandError = error.map { $0.localizedDescription + "\nCould not resume the previous playback." } ?? + "Could not queue the YouTube video or resume the previous playback. Please try again." + } + revealControls() + } + + private func openMedia(_ source: MediaSource, change: MediaChange = .open) { guard let handle else { return } saveProgress() let revision = player_get_state(handle).revision - let initialPosition = video.isLive ? 0 : - (restoring.map { $0.position ?? 0 } ?? playbackProgress.position(for: video.webpageURL) ?? 0) - let accepted = video.webpageURL.absoluteString.withCString { mediaURL in - video.video.url.withCString { videoURL in - video.video.headers.withCString { videoHeaders in - func enqueue(audioURL: UnsafePointer?, audioHeaders: UnsafePointer?) -> Bool { - var source = PlayerNetworkSource(video_url: videoURL, audio_url: audioURL, - video_headers: videoHeaders, audio_headers: audioHeaders, - duration: video.duration, is_live: video.isLive, - media_url: mediaURL, initial_position: initialPosition) - return player_open_network(handle, &source) - } - if let audio = video.audio { - return audio.url.withCString { audioURL in - audio.headers.withCString { enqueue(audioURL: audioURL, audioHeaders: $0) } + let savedPosition = change.isQuality ? change.position : + (source.live ? nil : playbackProgress.position(for: source.url)) + let accepted: Bool + switch source { + case .file(let url): + accepted = url.withUnsafeFileSystemRepresentation { path in + guard let path else { return false } + return player_open(handle, path) + } + case .youtube(let video, _): + let initialPosition = source.live ? 0 : savedPosition ?? 0 + accepted = video.webpageURL.absoluteString.withCString { mediaURL in + video.video.url.withCString { videoURL in + video.video.headers.withCString { videoHeaders in + func enqueue(audioURL: UnsafePointer?, audioHeaders: UnsafePointer?) -> Bool { + var input = PlayerNetworkSource(video_url: videoURL, audio_url: audioURL, + video_headers: videoHeaders, audio_headers: audioHeaders, + duration: video.duration, is_live: video.isLive, + media_url: mediaURL, initial_position: initialPosition) + return player_open_network(handle, &input) } + if let audio = video.audio { + return audio.url.withCString { audioURL in + audio.headers.withCString { enqueue(audioURL: audioURL, audioHeaders: $0) } + } + } + return enqueue(audioURL: nil, audioHeaders: nil) } - return enqueue(audioURL: nil, audioHeaders: nil) } } } guard accepted else { - if automatic { - automaticQuality.extractionFailed(at: qualityNow) - return + switch source { + case .file: + commandError = "Could not queue the file to open. Please try again." + case .youtube: + failYouTubeChange(change) } - commandError = "Could not queue the YouTube video to open. Please try again." - if restoring?.playing == true, !player_play(handle) { - commandError = "Could not queue the YouTube video or resume the previous playback. Please try again." - } - revealControls() return } - didAcceptOpen(revision: revision, title: video.title, icon: nil, url: video.webpageURL, - live: video.isLive, restoring: restoring, automatic: automatic) - youtubeHeights = video.availableHeights - youtubeHeight = video.height - youtubeQuality = height - } - - private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false, - restoring: (position: Double?, playing: Bool)? = nil, automatic: Bool = false) { - guard let handle else { return } - playbackIntent = nil - playbackInFlight = nil - if automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() } - pendingRevision = revision - fileName = title - fileIcon = icon - isLiveStream = live - isNetworkMedia = !url.isFileURL - youtubeHeights = [] - youtubeHeight = nil - youtubeQuality = nil - progressURL = url - if !url.isFileURL { - // Network restoration is part of the backend open transaction, so - // reporting cannot observe a pre-resume position as initial state. - resumePosition = nil - } else if let restoring { - resumePosition = restoring.position - } else { - resumePosition = live ? nil : playbackProgress.position(for: url) + transition.acceptOpen(revision: revision, source: source, change: change, savedPosition: savedPosition) + if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() } + switch source { + case .file(let url): + fileName = url.lastPathComponent + fileIcon = NSWorkspace.shared.icon(forFile: url.path) + youtubeHeights = [] + youtubeHeight = nil + youtubeQuality = nil + case .youtube(let video, let height): + fileName = video.title + fileIcon = nil + youtubeHeights = video.availableHeights + youtubeHeight = video.height + youtubeQuality = height } - resumePlaying = restoring?.playing ?? true - lastSavedPosition = nil - needsVideoFit = restoring == nil - commandError = resumePosition != nil || !resumePlaying || player_play(handle) ? nil : + needsVideoFit = !change.isQuality + commandError = transition.needsLocalRestoration || !change.playing || player_play(handle) ? nil : "Could not queue playback. Please try again." isSeeking = false - seekPending = false seekPosition = 0 - window?.representedURL = url - window?.title = title + window?.representedURL = source.url + window?.title = fileName ?? "terebi" revealControls() systemPlayback?.update() } func open(_ url: URL) { - guard canOpen, let handle else { return } + guard canOpen else { return } guard url.isFileURL else { openYouTube(url.absoluteString) return } cancelYouTubeResolution() - saveProgress() - let revision = player_get_state(handle).revision - let accepted = url.withUnsafeFileSystemRepresentation { path in - guard let path else { return false } - return player_open(handle, path) - } - guard accepted else { - commandError = "Could not queue the file to open. Please try again." - return - } - didAcceptOpen(revision: revision, title: url.lastPathComponent, - icon: NSWorkspace.shared.icon(forFile: url.path), url: url) + openMedia(.file(url)) } func togglePlayback() { @@ -412,33 +372,29 @@ final class PlayerModel: ObservableObject { @discardableResult func setPlayback(playing: Bool) -> Bool { guard let handle, canTogglePlayback else { return false } - if !playing && resolvingAutomaticQuality { cancelYouTubeResolution() } + if !playing && transition.resolvingAutomaticQuality { cancelYouTubeResolution() } if !playing { automaticQuality.resetHealth() } - if playbackInFlight != nil { - playbackIntent = playing + switch transition.setPlayback(playing, status: state.status, handle: handle) { + case .unchanged: + return true + case .deferred: + objectWillChange.send() revealControls() return true + case .submitted(let accepted): + commandError = accepted ? nil : "Could not queue playback. Please try again." + revealControls() + return accepted } - guard effectivePlaying != playing else { return true } - let revision = player_get_state(handle).revision - let accepted = playing ? player_play(handle) : player_pause(handle) - if accepted { - playbackIntent = playing - playbackInFlight = (playing, revision) - } - commandError = accepted ? nil : "Could not queue playback. Please try again." - revealControls() - return accepted } func finishSeeking() { - if resolvingAutomaticQuality { cancelYouTubeResolution() } + if transition.resolvingAutomaticQuality { cancelYouTubeResolution() } automaticQuality.resetHealth() isSeeking = false guard let handle, canSeek else { return } let position = min(max(0, seekPosition), duration) - let accepted = player_seek(handle, position) - if accepted { seekPending = true } + let accepted = transition.seek(position, handle: handle) commandError = accepted ? nil : "Could not queue the seek. Please try again." revealControls() } @@ -452,25 +408,18 @@ final class PlayerModel: ObservableObject { commandError = "Could not queue closing the file. Please try again." return } - playbackIntent = nil - playbackInFlight = nil + transition.closeMedia() compactIntent = nil compactWindow.restore(resumeFullScreen: false) fileName = nil fileIcon = nil - isLiveStream = false - isNetworkMedia = false youtubeHeights = [] youtubeHeight = nil youtubeQuality = nil - progressURL = nil - resumePosition = nil - lastSavedPosition = nil needsVideoFit = false window?.aspectRatio = .zero commandError = nil isSeeking = false - seekPending = false window?.representedURL = nil window?.title = "terebi" revealControls() @@ -480,14 +429,11 @@ final class PlayerModel: ObservableObject { @discardableResult func seekRelative(_ seconds: Double) -> Bool { guard canSeek, let handle, seconds.isFinite else { return false } - if resolvingAutomaticQuality { cancelYouTubeResolution() } + if transition.resolvingAutomaticQuality { cancelYouTubeResolution() } automaticQuality.resetHealth() - let target = min(max(0, (seekPending ? seekPosition : state.position) + seconds), duration) - let accepted = player_seek(handle, target) - if accepted { - seekPosition = target - seekPending = true - } + let target = min(max(0, (transition.seekPending ? seekPosition : state.position) + seconds), duration) + let accepted = transition.seek(target, handle: handle) + if accepted { seekPosition = target } commandError = accepted ? nil : "Could not queue the seek. Please try again." revealControls() return accepted @@ -496,12 +442,7 @@ final class PlayerModel: ObservableObject { @discardableResult func stepFrame(_ direction: Int32) -> Bool { guard canStepFrame, let handle else { return false } - let accepted = player_step_frame(handle, direction) - if accepted { - seekPending = false - playbackIntent = nil - playbackInFlight = nil - } + let accepted = transition.stepFrame(direction, handle: handle) commandError = accepted ? nil : "Could not queue the frame step. Please try again." revealControls() return accepted @@ -686,7 +627,7 @@ final class PlayerModel: ObservableObject { } func beginSeeking() { - if resolvingAutomaticQuality { cancelYouTubeResolution() } + if transition.resolvingAutomaticQuality { cancelYouTubeResolution() } automaticQuality.resetHealth() isSeeking = true revealControls() @@ -706,7 +647,7 @@ final class PlayerModel: ObservableObject { private var shouldHideControls: Bool { canTogglePlayback && state.has_video && fileName != nil && - !isSeeking && !seekPending && !controlsHovered && !titleHovered && !controlsDragging && + !isSeeking && !transition.seekPending && !controlsHovered && !titleHovered && !controlsDragging && (windowActive || isCompact) && errorText == nil && !NSWorkspace.shared.isVoiceOverEnabled } @@ -741,17 +682,8 @@ final class PlayerModel: ObservableObject { } private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) { - guard let handle, let url = progressURL, pendingRevision == nil, - resumePosition == nil, !isLiveStream else { return } - let current = snapshot ?? player_get_state(handle) - guard current.status == PLAYER_PAUSED || current.status == PLAYER_PLAYING || current.status == PLAYER_ENDED, - current.has_video, current.can_seek, !current.seek_pending, - current.duration.isFinite, current.duration > 0, - current.position.isFinite, current.position >= 0 else { return } - let position = current.status == PLAYER_ENDED ? 0 : min(current.position, current.duration) - guard force || lastSavedPosition == nil || abs(position - lastSavedPosition!) >= 5 else { return } - playbackProgress.save(position > 0 ? position : nil, for: url) - lastSavedPosition = position + guard let handle else { return } + transition.saveProgress(snapshot, handle: handle, force: force, history: playbackProgress) } private func poll() { @@ -765,55 +697,28 @@ final class PlayerModel: ObservableObject { } func applyState(_ next: PlayerState) { - if let revision = pendingRevision { - guard next.revision != revision, next.status != PLAYER_OPENING else { return } - pendingRevision = nil - } - defer { systemPlayback?.update() } let statusChanged = state.status != next.status + let observation = transition.advance(next, + forceCheckpoint: statusChanged && (next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED), + handle: handle, history: playbackProgress) + guard observation.ready else { return } + defer { systemPlayback?.update() } state = next - if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR { - playbackIntent = nil - playbackInFlight = nil - } else if let flight = playbackInFlight, next.revision != flight.revision, - next.status == PLAYER_ENDED || (next.status == PLAYER_PLAYING) == flight.playing, - next.status == PLAYER_PLAYING || next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED { - let desired = playbackIntent - playbackIntent = nil - playbackInFlight = nil - if let desired, desired != flight.playing { setPlayback(playing: desired) } - } + if let playing = observation.playback { setPlayback(playing: playing) } if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR || (!isBusy && !next.has_video) { compactWindow.restore(resumeFullScreen: false) } - if !next.seek_pending || next.status == PLAYER_ERROR { seekPending = false } - if !isSeeking && !seekPending { + if !isSeeking && !transition.seekPending { seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration) } fitVideoWindow() if statusChanged { revealControls() } if let hideDeadline, ContinuousClock.now >= hideDeadline { hideControls() } - saveProgress(next, force: statusChanged && (next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED)) updateAutomaticQuality() - if let saved = resumePosition { - resumePosition = nil - guard let handle, next.status == PLAYER_PAUSED || next.status == PLAYER_PLAYING else { return } - if next.has_video && next.can_seek && next.duration.isFinite && next.duration > 0 { - if saved < next.duration { - guard player_seek(handle, saved) else { - // Preserve the stored bookmark if restoration could not be queued. - progressURL = nil - commandError = "Could not resume saved progress. Please try opening the video again." - return - } - seekPosition = saved - seekPending = true - } else if let url = progressURL { - playbackProgress.save(nil, for: url) - } - } - commandError = !resumePlaying || player_play(handle) ? nil : "Could not queue playback. Please try again." + if let restored = observation.restored { + if let position = restored.position { seekPosition = position } + commandError = restored.error } } @@ -822,12 +727,12 @@ final class PlayerModel: ObservableObject { let reserve = bufferedRange.map { max(0, $0.upperBound - state.position) } let sample = AutomaticQuality.Sample( enabled: automaticYouTubeQuality, network: isNetworkMedia, - playing: effectivePlaying, opening: isBusy || resolvingYouTube || resumePosition != nil, - seeking: isSeeking || seekPending || state.seek_pending, live: isLiveStream, + playing: effectivePlaying, opening: transition.blocksAutomaticQuality(state.status), + seeking: isSeeking || transition.seekPending || state.seek_pending, live: isLiveStream, buffering: state.buffering, position: state.position, duration: state.duration, ahead: reserve, height: youtubeHeight, heights: youtubeHeights) - guard let height = automaticQuality.choose(sample, at: qualityNow), let url = progressURL else { return } - resolveYouTube(url, height: height, automatic: true) + guard let height = automaticQuality.choose(sample, at: qualityNow), let url = transition.mediaURL else { return } + resolveYouTube(url, height: height, change: .quality(position: nil, playing: true, automatic: true)) } private func showReportingError(_ message: String, terminating: Bool = false) { @@ -864,6 +769,235 @@ final class PlayerModel: ObservableObject { handle = nil player_destroy(player) } + + private enum MediaChange { + case open + case quality(position: Double?, playing: Bool, automatic: Bool) + + var automatic: Bool { + if case .quality(_, _, let automatic) = self { return automatic } + return false + } + var playing: Bool { + if case .quality(_, let playing, _) = self { return playing } + return true + } + var position: Double? { + if case .quality(let position, _, _) = self { return position } + return nil + } + var isQuality: Bool { + if case .quality = self { return true } + return false + } + } + + private enum MediaSource { + case file(URL) + case youtube(YouTubeVideo, height: Int?) + + var url: URL { + switch self { + case .file(let url): return url + case .youtube(let video, _): return video.webpageURL + } + } + var live: Bool { + if case .youtube(let video, _) = self { return video.isLive } + return false + } + } + + private struct PlaybackTransition { + enum PlaybackSubmission { + case unchanged + case deferred + case submitted(Bool) + } + + private struct PlaybackRequest { + var desired: Bool + let submitted: Bool + let revision: UInt64 + } + private struct Resolution { + let id: UUID + let change: MediaChange + var task: Task? + } + + private(set) var pendingRevision: UInt64? + private(set) var seekPending = false + private(set) var mediaURL: URL? + private(set) var isLive = false + private(set) var isNetwork = false + private var playback: PlaybackRequest? + private var resolution: Resolution? + private var restoration: (position: Double, playing: Bool)? + private var lastSavedPosition: Double? + + var playbackIntent: Bool? { playback?.desired } + var isResolving: Bool { resolution != nil } + var resolvingAutomaticQuality: Bool { resolution?.change.automatic == true } + + func isBusy(_ status: PlayerStatus) -> Bool { + (isResolving && !resolvingAutomaticQuality) || pendingRevision != nil || status == PLAYER_OPENING + } + + func qualityChange(_ state: PlayerState, playing: Bool, automatic: Bool = false) -> MediaChange { + .quality(position: !isLive && state.can_seek ? state.position : nil, + playing: playing, automatic: automatic) + } + + mutating func beginResolution(_ change: MediaChange) -> UUID { + if !change.automatic { playback = nil } + let id = UUID() + resolution = Resolution(id: id, change: change) + return id + } + + mutating func attachResolution(_ task: Task, request: UUID) { + guard resolution?.id == request else { task.cancel(); return } + resolution?.task = task + } + + mutating func finishResolution(_ request: UUID) -> MediaChange? { + guard let resolution, resolution.id == request else { return nil } + let change = resolution.change + cancelResolution() + return change + } + + mutating func cancelResolution() { + resolution?.task?.cancel() + resolution = nil + } + + func canApplyAutomaticQuality(_ state: PlayerState, enabled: Bool, previewingSeek: Bool) -> Bool { + enabled && state.status == PLAYER_PLAYING && !previewingSeek && !seekPending && + !state.seek_pending && pendingRevision == nil && + (isLive || !state.duration.isFinite || state.duration <= 0 || state.duration - state.position > 15) + } + + func blocksAutomaticQuality(_ status: PlayerStatus) -> Bool { + isBusy(status) || isResolving || restoration != nil + } + + mutating func acceptOpen(revision: UInt64, source: MediaSource, change: MediaChange, + savedPosition: Double?) { + playback = nil + pendingRevision = revision + isLive = source.live + isNetwork = !source.url.isFileURL + mediaURL = source.url + // Network restoration is acknowledged by the backend open transaction. + restoration = isNetwork ? nil : savedPosition.map { ($0, change.playing) } + lastSavedPosition = nil + seekPending = false + } + + mutating func advance(_ next: PlayerState, forceCheckpoint: Bool, handle: OpaquePointer?, + history: PlaybackProgress) + -> (ready: Bool, playback: Bool?, restored: (position: Double?, error: String?)?) { + if let revision = pendingRevision { + guard next.revision != revision, next.status != PLAYER_OPENING else { return (false, nil, nil) } + pendingRevision = nil + } + var followUp: Bool? + if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR { + playback = nil + } else if let request = playback, next.revision != request.revision, + next.status == PLAYER_ENDED || (next.status == PLAYER_PLAYING) == request.submitted, + next.status == PLAYER_PLAYING || next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED { + playback = nil + if request.desired != request.submitted { followUp = request.desired } + } + if !next.seek_pending || next.status == PLAYER_ERROR { seekPending = false } + // Checkpoint before consuming local restoration: ready is not yet resumed. + let restored: (position: Double?, error: String?)? + if let handle { + saveProgress(next, handle: handle, force: forceCheckpoint, history: history) + restored = restoreLocalPosition(next, handle: handle, history: history) + } else { + restored = nil + } + return (true, followUp, restored) + } + + mutating func setPlayback(_ playing: Bool, status: PlayerStatus, handle: OpaquePointer) -> PlaybackSubmission { + if playback != nil { + playback?.desired = playing + return .deferred + } + guard (status == PLAYER_PLAYING) != playing else { return .unchanged } + let revision = player_get_state(handle).revision + let accepted = playing ? player_play(handle) : player_pause(handle) + if accepted { playback = PlaybackRequest(desired: playing, submitted: playing, revision: revision) } + return .submitted(accepted) + } + + mutating func seek(_ position: Double, handle: OpaquePointer) -> Bool { + let accepted = player_seek(handle, position) + if accepted { seekPending = true } + return accepted + } + + mutating func stepFrame(_ direction: Int32, handle: OpaquePointer) -> Bool { + let accepted = player_step_frame(handle, direction) + if accepted { + seekPending = false + playback = nil + } + return accepted + } + + mutating func closeMedia() { + playback = nil + isLive = false + isNetwork = false + mediaURL = nil + restoration = nil + lastSavedPosition = nil + seekPending = false + } + + mutating func saveProgress(_ snapshot: PlayerState?, handle: OpaquePointer, + force: Bool, history: PlaybackProgress) { + guard let url = mediaURL, pendingRevision == nil, restoration == nil, !isLive else { return } + let state = snapshot ?? player_get_state(handle) + guard state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED, + state.has_video, state.can_seek, !state.seek_pending, + state.duration.isFinite, state.duration > 0, + state.position.isFinite, state.position >= 0 else { return } + let position = state.status == PLAYER_ENDED ? 0 : min(state.position, state.duration) + guard force || lastSavedPosition == nil || abs(position - lastSavedPosition!) >= 5 else { return } + history.save(position > 0 ? position : nil, for: url) + lastSavedPosition = position + } + + mutating func restoreLocalPosition(_ state: PlayerState, handle: OpaquePointer, + history: PlaybackProgress) -> (position: Double?, error: String?)? { + guard let saved = restoration else { return nil } + restoration = nil + guard state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING else { return nil } + var position: Double? + if state.has_video && state.can_seek && state.duration.isFinite && state.duration > 0 { + if saved.position < state.duration { + guard seek(saved.position, handle: handle) else { + mediaURL = nil + return (nil, "Could not resume saved progress. Please try opening the video again.") + } + position = saved.position + } else if let url = mediaURL { + history.save(nil, for: url) + } + } + let accepted = !saved.playing || player_play(handle) + return (position, accepted ? nil : "Could not queue playback. Please try again.") + } + + var needsLocalRestoration: Bool { restoration != nil } + } } @MainActor