refactor: concentrate playback transition ownership

This commit is contained in:
2026-10-11 23:20:26 +02:00
parent cc99a2a4f8
commit 8eba97579f
6 changed files with 559 additions and 238 deletions
+20
View File
@@ -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.
+74 -1
View File
@@ -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")
}
}
+21 -2
View File
@@ -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:
+71 -2
View File
@@ -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,
+7 -1
View File
@@ -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.
+366 -232
View File
@@ -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<Void, Never>?
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<Double>? {
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<CChar>?, audioHeaders: UnsafePointer<CChar>?) -> 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<CChar>?, audioHeaders: UnsafePointer<CChar>?) -> 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<Void, Never>?
}
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<Void, Never>, 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