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 }) qualityClock: { clock.now })
require(manual.automaticYouTubeQuality, "manual session override does not replace startup configuration") require(manual.automaticYouTubeQuality, "manual session override does not replace startup configuration")
manual.shutdown() 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() 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 = root / "info.json"
metadata.write_text(json.dumps(info)) metadata.write_text(json.dumps(info))
wrapper = root / "yt-dlp" 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)) + " --load-info-json " + shlex.quote(str(metadata))
+ ' "${args[@]:0:${#args[@]}-2}"\n') + ' "${args[@]:0:${#args[@]}-2}"\n')
wrapper.chmod(0o755) 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: try:
result = subprocess.run([str(args.binary.resolve())], env=environment, timeout=90, check=False) result = subprocess.run([str(args.binary.resolve())], env=environment, timeout=90, check=False)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
+71 -2
View File
@@ -63,13 +63,24 @@ struct SystemPlaybackChecks {
require(center.nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] as? Double == 0, require(center.nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] as? Double == 0,
"paused system timeline does not extrapolate") "paused system timeline does not extrapolate")
let playbackKey = try! KeyChord("space") let playbackKey = try! KeyChord("space")
let oppositeIntentRevision = model.state.revision
require(model.handleKey(playbackKey) && model.effectivePlaying, require(model.handleKey(playbackKey) && model.effectivePlaying,
"play binding records intent before backend acknowledgement") "play binding records intent before backend acknowledgement")
require(model.handleKey(playbackKey) && !model.effectivePlaying, require(model.handleKey(playbackKey) && !model.effectivePlaying,
"same binding resolves pause against pending play intent") "same binding resolves pause against pending play intent")
require(model.handleKey(playbackKey, isRepeat: true) && !model.effectivePlaying, require(model.handleKey(playbackKey, isRepeat: true) && !model.effectivePlaying,
"held paired shortcut is consumed without oscillating") "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, require(!model.perform(.pause) && !model.effectivePlaying,
"explicit pause cannot resume an already paused player") "explicit pause cannot resume an already paused player")
@@ -87,7 +98,7 @@ struct SystemPlaybackChecks {
require(model.seekRelative(1), "relative seek accepted") require(model.seekRelative(1), "relative seek accepted")
until("paused seek acknowledged") { until("paused seek acknowledged") {
model.state.status == PLAYER_PAUSED && !model.state.seek_pending && 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") require(model.seekRelative(-100), "backward boundary seek accepted")
until("backward seek clamps at start") { !model.state.seek_pending && model.state.position == 0 } 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") "accepted close releases system playback immediately")
require(!commands.togglePlayPauseCommand.isEnabled && !commands.nextTrackCommand.isEnabled, require(!commands.togglePlayPauseCommand.isEnabled && !commands.nextTrackCommand.isEnabled,
"closed source cannot receive system playback or seek commands") "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")) model.open(directory.appendingPathComponent("hdr.mkv"))
until("decode error acknowledged") { model.state.status == PLAYER_ERROR && !model.isBusy } until("decode error acknowledged") { model.state.status == PLAYER_ERROR && !model.isBusy }
require(center.nowPlayingInfo == nil && !commands.playCommand.isEnabled, 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 ## 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.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/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/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/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/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/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/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. - `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. `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-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`. `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. `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. `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. 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 fileName: String?
@Published private(set) var fileIcon: NSImage? @Published private(set) var fileIcon: NSImage?
@Published private(set) var commandError: String? @Published private(set) var commandError: String?
@Published private(set) var pendingRevision: UInt64? private var transition = PlaybackTransition()
@Published private(set) var resolvingYouTube = false var pendingRevision: UInt64? { transition.pendingRevision }
@Published private(set) var isLiveStream = false var resolvingYouTube: Bool { transition.isResolving }
@Published private(set) var isNetworkMedia = false var isLiveStream: Bool { transition.isLive }
var isNetworkMedia: Bool { transition.isNetwork }
@Published private(set) var youtubeHeights: [Int] = [] @Published private(set) var youtubeHeights: [Int] = []
@Published private(set) var youtubeHeight: Int? @Published private(set) var youtubeHeight: Int?
@Published private(set) var youtubeQuality: Int? @Published private(set) var youtubeQuality: Int?
@@ -29,8 +30,7 @@ final class PlayerModel: ObservableObject {
@Published private(set) var isOnTop = false @Published private(set) var isOnTop = false
let compactWindow: CompactWindowController let compactWindow: CompactWindowController
let keyBindings: KeyBindings let keyBindings: KeyBindings
@Published private(set) var playbackIntent: Bool? var playbackIntent: Bool? { transition.playbackIntent }
private var playbackInFlight: (playing: Bool, revision: UInt64)?
@Published private(set) var fullScreenIntent: Bool? @Published private(set) var fullScreenIntent: Bool?
@Published private(set) var compactIntent: Bool? @Published private(set) var compactIntent: Bool?
var effectivePlaying: Bool { playbackIntent ?? (state.status == PLAYER_PLAYING) } var effectivePlaying: Bool { playbackIntent ?? (state.status == PLAYER_PLAYING) }
@@ -68,18 +68,10 @@ final class PlayerModel: ObservableObject {
private var windowActive = true private var windowActive = true
private var lastAudibleVolume = 1.0 private var lastAudibleVolume = 1.0
private var needsVideoFit = false 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 let qualityClock: () -> Double
private var automaticQuality = AutomaticQuality() private var automaticQuality = AutomaticQuality()
private var qualityNow: Double { qualityClock() } private var qualityNow: Double { qualityClock() }
private let playbackProgress: PlaybackProgress 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 var systemPlayback: SystemPlaybackControls?
private(set) var progressInitializationError: String? private(set) var progressInitializationError: String?
private var reportingEnabled = false private var reportingEnabled = false
@@ -131,7 +123,7 @@ final class PlayerModel: ObservableObject {
systemPlayback = SystemPlaybackControls(model: self) 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 { var canTogglePlayback: Bool {
handle != nil && !isBusy && handle != nil && !isBusy &&
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED) (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 canSeek: Bool { canTogglePlayback && state.can_seek && state.duration.isFinite && state.duration > 0 }
var canStepFrame: Bool { canTogglePlayback && state.has_video && state.can_seek && !isSeeking } var canStepFrame: Bool { canTogglePlayback && state.has_video && state.can_seek && !isSeeking }
var canChangeYouTubeQuality: Bool { 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 canOpen: Bool { handle != nil }
var canClose: Bool { handle != nil && (isBusy || state.status != PLAYER_EMPTY || fileName != 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 canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR }
var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 } var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 }
var bufferedRange: ClosedRange<Double>? { 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.isFinite, state.buffered_end.isFinite,
state.buffered_start >= 0, state.buffered_end > state.buffered_start, state.buffered_start >= 0, state.buffered_end > state.buffered_start,
state.buffered_end <= duration else { return nil } state.buffered_end <= duration else { return nil }
@@ -214,14 +206,14 @@ final class PlayerModel: ObservableObject {
func setAutomaticYouTubeQuality(_ enabled: Bool) { func setAutomaticYouTubeQuality(_ enabled: Bool) {
automaticYouTubeQuality = enabled automaticYouTubeQuality = enabled
automaticQuality = AutomaticQuality() automaticQuality = AutomaticQuality()
if !enabled && resolvingAutomaticQuality { cancelYouTubeResolution() } if !enabled && transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
revealControls() revealControls()
} }
func setYouTubeQuality(_ height: Int?) { func setYouTubeQuality(_ height: Int?) {
// A manual choice is an override, including reselecting the active/default resolution. // A manual choice is an override, including reselecting the active/default resolution.
setAutomaticYouTubeQuality(false) setAutomaticYouTubeQuality(false)
guard canChangeYouTubeQuality, let url = progressURL, let handle, guard canChangeYouTubeQuality, let url = transition.mediaURL, let handle,
height == nil || youtubeHeights.contains(height!) else { return } height == nil || youtubeHeights.contains(height!) else { return }
guard height != youtubeQuality else { return } guard height != youtubeQuality else { return }
let target = height ?? youtubeHeights.filter { $0 <= 1080 }.max() let target = height ?? youtubeHeights.filter { $0 <= 1080 }.max()
@@ -229,180 +221,148 @@ final class PlayerModel: ObservableObject {
youtubeQuality = height youtubeQuality = height
return return
} }
let current = player_get_state(handle) let change = transition.qualityChange(player_get_state(handle), playing: effectivePlaying)
let position = !isLiveStream && current.can_seek ? current.position : nil resolveYouTube(url, height: height, change: change)
resolveYouTube(url, height: height, restoring: (position, effectivePlaying))
} }
private func resolveYouTube(_ url: URL, height: Int?, restoring: (position: Double?, playing: Bool)? = nil, private func resolveYouTube(_ url: URL, height: Int?, change: MediaChange = .open) {
automatic: Bool = false) {
guard let handle else { return } guard let handle else { return }
cancelYouTubeResolution() cancelYouTubeResolution()
if !automatic { automaticQuality = AutomaticQuality() } if !change.automatic { automaticQuality = AutomaticQuality() }
if !automatic && !player_pause(handle) { if !change.automatic && !player_pause(handle) {
commandError = "Could not pause the current media to open YouTube. Please try again." commandError = "Could not pause the current media to open YouTube. Please try again."
revealControls() revealControls()
return return
} }
if !automatic { objectWillChange.send()
playbackIntent = nil let request = transition.beginResolution(change)
playbackInFlight = nil
}
let request = UUID()
youtubeRequest = request
resolvingYouTube = true
resolvingAutomaticQuality = automatic
systemPlayback?.update() systemPlayback?.update()
commandError = nil commandError = nil
revealControls() revealControls()
youtubeTask = Task { [weak self] in let task = Task { [weak self] in
do { do {
let video = try await YouTubeResolver.resolve(url, height: height) let video = try await YouTubeResolver.resolve(url, height: height)
guard !Task.isCancelled, let self, self.youtubeRequest == request, self.handle != nil else { return } guard !Task.isCancelled, let self, let handle = self.handle else { return }
if automatic { self.objectWillChange.send()
guard var change = self.transition.finishResolution(request) else { return }
if change.automatic {
let current = player_get_state(handle) let current = player_get_state(handle)
guard self.automaticYouTubeQuality, current.status == PLAYER_PLAYING, guard self.transition.canApplyAutomaticQuality(current, enabled: self.automaticYouTubeQuality,
!self.isSeeking, !self.seekPending, !current.seek_pending, previewingSeek: self.isSeeking) else {
self.pendingRevision == nil,
self.isLiveStream || !current.duration.isFinite || current.duration <= 0 ||
current.duration - current.position > 15 else {
self.cancelYouTubeResolution()
self.automaticQuality.resetHealth() self.automaticQuality.resetHealth()
return return
} }
let position = !self.isLiveStream && current.can_seek ? current.position : nil change = self.transition.qualityChange(current, playing: true, automatic: true)
self.cancelYouTubeResolution()
self.openResolvedYouTube(video, height: height, restoring: (position, true), automatic: true)
} else {
self.cancelYouTubeResolution()
self.openResolvedYouTube(video, height: height, restoring: restoring)
} }
self.openMedia(.youtube(video, height: height), change: change)
} catch { } catch {
guard !Task.isCancelled, let self, self.youtubeRequest == request else { return } guard !Task.isCancelled, let self else { return }
self.cancelYouTubeResolution() self.objectWillChange.send()
if automatic { guard let change = self.transition.finishResolution(request) else { return }
self.automaticQuality.extractionFailed(at: self.qualityNow) self.failYouTubeChange(change, error: error)
} 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()
}
} }
} }
transition.attachResolution(task, request: request)
} }
private func cancelYouTubeResolution() { private func cancelYouTubeResolution() {
youtubeRequest = nil guard transition.isResolving else { return }
youtubeTask?.cancel() objectWillChange.send()
youtubeTask = nil transition.cancelResolution()
resolvingYouTube = false
resolvingAutomaticQuality = false
} }
private func openResolvedYouTube(_ video: YouTubeVideo, height: Int?, restoring: (position: Double?, playing: Bool)?, private func failYouTubeChange(_ change: MediaChange, error: Error? = nil) {
automatic: Bool = false) { 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 } guard let handle else { return }
saveProgress() saveProgress()
let revision = player_get_state(handle).revision let revision = player_get_state(handle).revision
let initialPosition = video.isLive ? 0 : let savedPosition = change.isQuality ? change.position :
(restoring.map { $0.position ?? 0 } ?? playbackProgress.position(for: video.webpageURL) ?? 0) (source.live ? nil : playbackProgress.position(for: source.url))
let accepted = video.webpageURL.absoluteString.withCString { mediaURL in let accepted: Bool
video.video.url.withCString { videoURL in switch source {
video.video.headers.withCString { videoHeaders in case .file(let url):
func enqueue(audioURL: UnsafePointer<CChar>?, audioHeaders: UnsafePointer<CChar>?) -> Bool { accepted = url.withUnsafeFileSystemRepresentation { path in
var source = PlayerNetworkSource(video_url: videoURL, audio_url: audioURL, guard let path else { return false }
video_headers: videoHeaders, audio_headers: audioHeaders, return player_open(handle, path)
duration: video.duration, is_live: video.isLive, }
media_url: mediaURL, initial_position: initialPosition) case .youtube(let video, _):
return player_open_network(handle, &source) let initialPosition = source.live ? 0 : savedPosition ?? 0
} accepted = video.webpageURL.absoluteString.withCString { mediaURL in
if let audio = video.audio { video.video.url.withCString { videoURL in
return audio.url.withCString { audioURL in video.video.headers.withCString { videoHeaders in
audio.headers.withCString { enqueue(audioURL: audioURL, audioHeaders: $0) } 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 { guard accepted else {
if automatic { switch source {
automaticQuality.extractionFailed(at: qualityNow) case .file:
return 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 return
} }
didAcceptOpen(revision: revision, title: video.title, icon: nil, url: video.webpageURL, transition.acceptOpen(revision: revision, source: source, change: change, savedPosition: savedPosition)
live: video.isLive, restoring: restoring, automatic: automatic) if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
youtubeHeights = video.availableHeights switch source {
youtubeHeight = video.height case .file(let url):
youtubeQuality = height fileName = url.lastPathComponent
} fileIcon = NSWorkspace.shared.icon(forFile: url.path)
youtubeHeights = []
private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false, youtubeHeight = nil
restoring: (position: Double?, playing: Bool)? = nil, automatic: Bool = false) { youtubeQuality = nil
guard let handle else { return } case .youtube(let video, let height):
playbackIntent = nil fileName = video.title
playbackInFlight = nil fileIcon = nil
if automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() } youtubeHeights = video.availableHeights
pendingRevision = revision youtubeHeight = video.height
fileName = title youtubeQuality = height
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)
} }
resumePlaying = restoring?.playing ?? true needsVideoFit = !change.isQuality
lastSavedPosition = nil commandError = transition.needsLocalRestoration || !change.playing || player_play(handle) ? nil :
needsVideoFit = restoring == nil
commandError = resumePosition != nil || !resumePlaying || player_play(handle) ? nil :
"Could not queue playback. Please try again." "Could not queue playback. Please try again."
isSeeking = false isSeeking = false
seekPending = false
seekPosition = 0 seekPosition = 0
window?.representedURL = url window?.representedURL = source.url
window?.title = title window?.title = fileName ?? "terebi"
revealControls() revealControls()
systemPlayback?.update() systemPlayback?.update()
} }
func open(_ url: URL) { func open(_ url: URL) {
guard canOpen, let handle else { return } guard canOpen else { return }
guard url.isFileURL else { guard url.isFileURL else {
openYouTube(url.absoluteString) openYouTube(url.absoluteString)
return return
} }
cancelYouTubeResolution() cancelYouTubeResolution()
saveProgress() openMedia(.file(url))
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)
} }
func togglePlayback() { func togglePlayback() {
@@ -412,33 +372,29 @@ final class PlayerModel: ObservableObject {
@discardableResult @discardableResult
func setPlayback(playing: Bool) -> Bool { func setPlayback(playing: Bool) -> Bool {
guard let handle, canTogglePlayback else { return false } guard let handle, canTogglePlayback else { return false }
if !playing && resolvingAutomaticQuality { cancelYouTubeResolution() } if !playing && transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
if !playing { automaticQuality.resetHealth() } if !playing { automaticQuality.resetHealth() }
if playbackInFlight != nil { switch transition.setPlayback(playing, status: state.status, handle: handle) {
playbackIntent = playing case .unchanged:
return true
case .deferred:
objectWillChange.send()
revealControls() revealControls()
return true 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() { func finishSeeking() {
if resolvingAutomaticQuality { cancelYouTubeResolution() } if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth() automaticQuality.resetHealth()
isSeeking = false isSeeking = false
guard let handle, canSeek else { return } guard let handle, canSeek else { return }
let position = min(max(0, seekPosition), duration) let position = min(max(0, seekPosition), duration)
let accepted = player_seek(handle, position) let accepted = transition.seek(position, handle: handle)
if accepted { seekPending = true }
commandError = accepted ? nil : "Could not queue the seek. Please try again." commandError = accepted ? nil : "Could not queue the seek. Please try again."
revealControls() revealControls()
} }
@@ -452,25 +408,18 @@ final class PlayerModel: ObservableObject {
commandError = "Could not queue closing the file. Please try again." commandError = "Could not queue closing the file. Please try again."
return return
} }
playbackIntent = nil transition.closeMedia()
playbackInFlight = nil
compactIntent = nil compactIntent = nil
compactWindow.restore(resumeFullScreen: false) compactWindow.restore(resumeFullScreen: false)
fileName = nil fileName = nil
fileIcon = nil fileIcon = nil
isLiveStream = false
isNetworkMedia = false
youtubeHeights = [] youtubeHeights = []
youtubeHeight = nil youtubeHeight = nil
youtubeQuality = nil youtubeQuality = nil
progressURL = nil
resumePosition = nil
lastSavedPosition = nil
needsVideoFit = false needsVideoFit = false
window?.aspectRatio = .zero window?.aspectRatio = .zero
commandError = nil commandError = nil
isSeeking = false isSeeking = false
seekPending = false
window?.representedURL = nil window?.representedURL = nil
window?.title = "terebi" window?.title = "terebi"
revealControls() revealControls()
@@ -480,14 +429,11 @@ final class PlayerModel: ObservableObject {
@discardableResult @discardableResult
func seekRelative(_ seconds: Double) -> Bool { func seekRelative(_ seconds: Double) -> Bool {
guard canSeek, let handle, seconds.isFinite else { return false } guard canSeek, let handle, seconds.isFinite else { return false }
if resolvingAutomaticQuality { cancelYouTubeResolution() } if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth() automaticQuality.resetHealth()
let target = min(max(0, (seekPending ? seekPosition : state.position) + seconds), duration) let target = min(max(0, (transition.seekPending ? seekPosition : state.position) + seconds), duration)
let accepted = player_seek(handle, target) let accepted = transition.seek(target, handle: handle)
if accepted { if accepted { seekPosition = target }
seekPosition = target
seekPending = true
}
commandError = accepted ? nil : "Could not queue the seek. Please try again." commandError = accepted ? nil : "Could not queue the seek. Please try again."
revealControls() revealControls()
return accepted return accepted
@@ -496,12 +442,7 @@ final class PlayerModel: ObservableObject {
@discardableResult @discardableResult
func stepFrame(_ direction: Int32) -> Bool { func stepFrame(_ direction: Int32) -> Bool {
guard canStepFrame, let handle else { return false } guard canStepFrame, let handle else { return false }
let accepted = player_step_frame(handle, direction) let accepted = transition.stepFrame(direction, handle: handle)
if accepted {
seekPending = false
playbackIntent = nil
playbackInFlight = nil
}
commandError = accepted ? nil : "Could not queue the frame step. Please try again." commandError = accepted ? nil : "Could not queue the frame step. Please try again."
revealControls() revealControls()
return accepted return accepted
@@ -686,7 +627,7 @@ final class PlayerModel: ObservableObject {
} }
func beginSeeking() { func beginSeeking() {
if resolvingAutomaticQuality { cancelYouTubeResolution() } if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth() automaticQuality.resetHealth()
isSeeking = true isSeeking = true
revealControls() revealControls()
@@ -706,7 +647,7 @@ final class PlayerModel: ObservableObject {
private var shouldHideControls: Bool { private var shouldHideControls: Bool {
canTogglePlayback && state.has_video && fileName != nil && canTogglePlayback && state.has_video && fileName != nil &&
!isSeeking && !seekPending && !controlsHovered && !titleHovered && !controlsDragging && !isSeeking && !transition.seekPending && !controlsHovered && !titleHovered && !controlsDragging &&
(windowActive || isCompact) && errorText == nil && (windowActive || isCompact) && errorText == nil &&
!NSWorkspace.shared.isVoiceOverEnabled !NSWorkspace.shared.isVoiceOverEnabled
} }
@@ -741,17 +682,8 @@ final class PlayerModel: ObservableObject {
} }
private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) { private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) {
guard let handle, let url = progressURL, pendingRevision == nil, guard let handle else { return }
resumePosition == nil, !isLiveStream else { return } transition.saveProgress(snapshot, handle: handle, force: force, history: playbackProgress)
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
} }
private func poll() { private func poll() {
@@ -765,55 +697,28 @@ final class PlayerModel: ObservableObject {
} }
func applyState(_ next: PlayerState) { 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 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 state = next
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR { if let playing = observation.playback { setPlayback(playing: playing) }
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 next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR || if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR ||
(!isBusy && !next.has_video) { (!isBusy && !next.has_video) {
compactWindow.restore(resumeFullScreen: false) compactWindow.restore(resumeFullScreen: false)
} }
if !next.seek_pending || next.status == PLAYER_ERROR { seekPending = false } if !isSeeking && !transition.seekPending {
if !isSeeking && !seekPending {
seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration) seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration)
} }
fitVideoWindow() fitVideoWindow()
if statusChanged { revealControls() } if statusChanged { revealControls() }
if let hideDeadline, ContinuousClock.now >= hideDeadline { hideControls() } if let hideDeadline, ContinuousClock.now >= hideDeadline { hideControls() }
saveProgress(next, force: statusChanged && (next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED))
updateAutomaticQuality() updateAutomaticQuality()
if let saved = resumePosition { if let restored = observation.restored {
resumePosition = nil if let position = restored.position { seekPosition = position }
guard let handle, next.status == PLAYER_PAUSED || next.status == PLAYER_PLAYING else { return } commandError = restored.error
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."
} }
} }
@@ -822,12 +727,12 @@ final class PlayerModel: ObservableObject {
let reserve = bufferedRange.map { max(0, $0.upperBound - state.position) } let reserve = bufferedRange.map { max(0, $0.upperBound - state.position) }
let sample = AutomaticQuality.Sample( let sample = AutomaticQuality.Sample(
enabled: automaticYouTubeQuality, network: isNetworkMedia, enabled: automaticYouTubeQuality, network: isNetworkMedia,
playing: effectivePlaying, opening: isBusy || resolvingYouTube || resumePosition != nil, playing: effectivePlaying, opening: transition.blocksAutomaticQuality(state.status),
seeking: isSeeking || seekPending || state.seek_pending, live: isLiveStream, seeking: isSeeking || transition.seekPending || state.seek_pending, live: isLiveStream,
buffering: state.buffering, position: state.position, duration: state.duration, buffering: state.buffering, position: state.position, duration: state.duration,
ahead: reserve, height: youtubeHeight, heights: youtubeHeights) ahead: reserve, height: youtubeHeight, heights: youtubeHeights)
guard let height = automaticQuality.choose(sample, at: qualityNow), let url = progressURL else { return } guard let height = automaticQuality.choose(sample, at: qualityNow), let url = transition.mediaURL else { return }
resolveYouTube(url, height: height, automatic: true) resolveYouTube(url, height: height, change: .quality(position: nil, playing: true, automatic: true))
} }
private func showReportingError(_ message: String, terminating: Bool = false) { private func showReportingError(_ message: String, terminating: Bool = false) {
@@ -864,6 +769,235 @@ final class PlayerModel: ObservableObject {
handle = nil handle = nil
player_destroy(player) 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 @MainActor