feat: add independent CLI launches and preserve shared video bookmarks

This commit is contained in:
2026-10-11 12:47:08 +02:00
parent 537e8e2624
commit 0dce073287
10 changed files with 254 additions and 40 deletions
+106
View File
@@ -4,11 +4,116 @@ from pathlib import Path
import plistlib
import selectors
import shutil
import shlex
import subprocess
import sys
import tempfile
import time
import uuid
from streaming import StreamingServer
def check_cli_instances(root, app, fixtures):
cli = root / "terebi"
shutil.copy2(app_source.parent / "terebi", cli)
extractor = shutil.which("yt-dlp")
runtime = shutil.which("deno") or shutil.which("node")
canonical = "https://www.youtube.com/watch?v=aqz-KE-bpKQ"
def invoke(arguments):
result = subprocess.run([str(cli), *arguments], capture_output=True, text=True, timeout=20)
if result.returncode:
raise RuntimeError(f"CLI {arguments} failed: {result.returncode}; {result.stdout}{result.stderr}")
return result
def wait_states(count, ready, message):
deadline = time.monotonic() + 30
states = {}
while time.monotonic() < deadline:
states = {int(path.stem.split("-")[1]): json.loads(path.read_text())
for path in root.glob("state-*.json")}
complete = len(states) == count and all(
set(state["pids"]) == set(states) and state["windows"] == 1 for state in states.values())
if any(state["error"] for state in states.values()):
raise RuntimeError(f"{message}: native playback error: {states}")
if complete and ready(states):
return states
time.sleep(0.02) # Infrastructure deadline; assertions use native events/PTS, not wall dates.
raise RuntimeError(f"{message}: native state deadline: {states}")
with StreamingServer(fixtures) as server:
if extractor and runtime:
metadata = root / "youtube.json"
metadata.write_text(json.dumps({
"id": "aqz-KE-bpKQ", "title": "Independent YouTube fixture", "duration": 120,
"is_live": False, "webpage_url": canonical, "extractor": "youtube",
"extractor_key": "Youtube", "formats": [{
"format_id": "180", "height": 180, "width": 320,
"url": server.base_url + "/media/cache.mp4", "protocol": "http", "ext": "mp4",
"vcodec": "mpeg4", "acodec": "aac", "dynamic_range": "SDR",
"http_headers": {"X-Terebi-Fixture": "combined"}}]}))
wrapper = root / "yt-dlp"
wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\nexec " + shlex.quote(extractor)
+ " --load-info-json " + shlex.quote(str(metadata))
+ ' "${args[@]:0:${#args[@]}-2}"\n')
wrapper.chmod(0o755)
settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")}
(app / "Contents/Resources/cli-check.json").write_text(json.dumps(settings))
movie = fixtures / "cache.mp4"
audio = root / "audio with spaces.wav"
shutil.copy2(fixtures / "audio.wav", audio)
invoke([str(movie)])
first = wait_states(1, lambda states: all(state["media"] == movie.name and state["status"] == 3
and state["position"] > 0 and state["hasVideo"] for state in states.values()),
"cold default CLI opens one actual player window")
first_pid = next(iter(first))
invoke([])
wait_states(1, lambda states: states[first_pid]["media"] == movie.name,
"warm default CLI reuses the existing process without replacing media")
invoke(["--new-instance"])
second = wait_states(2, lambda states: any(state["media"] == "" and state["status"] == 0
for pid, state in states.items() if pid != first_pid),
"empty independent CLI creates exactly one extra player")
empty_pid = next(pid for pid in second if pid != first_pid)
invoke(["--new-instance", str(audio)])
third = wait_states(3, lambda states: any(state["media"] == audio.name and state["status"] == 3
and state["hasAudio"] and not state["hasVideo"]
for pid, state in states.items() if pid not in second),
"independent local media reaches only the freshly launched process")
if third[first_pid]["media"] != movie.name or third[first_pid]["status"] != 3 or third[empty_pid]["media"]:
raise RuntimeError(f"independent launch disturbed an existing player: {third}")
count = 3
if extractor and runtime:
invoke(["--new-instance", "--url", canonical])
fourth = wait_states(4, lambda states: any(
state["media"] == "Independent YouTube fixture" and state["status"] == 3
and state["position"] > 0 and state["hasAudio"] and state["hasVideo"]
for pid, state in states.items() if pid not in third),
"independent YouTube playback reaches only one new registered process")
if fourth[first_pid]["media"] != movie.name or fourth[first_pid]["status"] != 3 or fourth[empty_pid]["media"]:
raise RuntimeError(f"independent YouTube launch disturbed existing playback: {fourth}")
count = 4
else:
print("SKIP independent YouTube playback: yt-dlp and a supported JS runtime are required")
before = wait_states(count, lambda _: True, "all independent processes remain registered")
invoke(["--new-instance", "--help"])
wait_states(count, lambda _: True, "help never creates an extra application")
replacement = root / "replacement.mp4"
shutil.copy2(fixtures / "av.mp4", replacement)
invoke([str(replacement)])
after = wait_states(count, lambda states: sum(state["media"] == replacement.name
for state in states.values()) == 1,
"default media delivery still reuses exactly one existing process")
changed = [pid for pid in before if before[pid]["media"] != after[pid]["media"]]
if len(changed) != 1:
raise RuntimeError(f"default media open changed multiple players: {before}; {after}")
print("PASS actual CLI instances: cold/default reuse, independent empty/local/YouTube, one window per PID, targeted media, original playback retained, help, warm replacement")
app_source, library, fixtures = map(lambda path: Path(path).resolve(), sys.argv[1:])
with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
@@ -39,6 +144,7 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier:
raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}")
print("PASS packaged bootstrap: registered app PID/identity, in-process player load, actual playback and Now Playing")
check_cli_instances(root, app, fixtures)
finally:
os.close(descriptor)
subprocess.run(
+48
View File
@@ -4,6 +4,10 @@ import MediaPlayer
@_cdecl("terebi_app_main")
@MainActor
public func checkAppStartup() {
if let configuration = Bundle.main.url(forResource: "cli-check", withExtension: "json") {
CLIInstanceCheck.run(configuration)
return
}
AppStartupCheck.run()
}
@@ -57,3 +61,47 @@ private enum AppStartupCheck {
NSApp.terminate(nil)
}
}
@MainActor
private enum CLIInstanceCheck {
static var timer: Timer?
static func run(_ configuration: URL) {
let settings = try! JSONSerialization.jsonObject(with: Data(contentsOf: configuration)) as! [String: String]
let directory = URL(fileURLWithPath: settings["directory"]!, isDirectory: true)
setenv("PATH", settings["path"]!, 1)
_ = NSApplication.shared
PlayerModel.shared.setVolume(0)
// Exercise the real SwiftUI scene and delegate without displaying test windows.
_ = NotificationCenter.default.addObserver(forName: NSWindow.didUpdateNotification, object: nil, queue: .main) { _ in
MainActor.assumeIsolated { for window in NSApp.windows { window.alphaValue = 0 } }
}
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
MainActor.assumeIsolated {
let model = PlayerModel.shared
let pid = ProcessInfo.processInfo.processIdentifier
let windows = NSApp.windows.filter { !($0 is NSPanel) && $0.contentView != nil }
for window in windows { window.alphaValue = 0 }
let applications = NSWorkspace.shared.runningApplications.filter {
!$0.isTerminated && $0.bundleURL == Bundle.main.bundleURL
}
let snapshot: [String: Any] = [
"pid": pid,
"pids": applications.map(\.processIdentifier).sorted(),
"media": model.fileName ?? "",
"status": model.state.status.rawValue,
"position": model.state.position,
"windows": windows.count,
"hasAudio": model.state.has_audio,
"hasVideo": model.state.has_video,
"error": model.errorText ?? "",
]
do {
let data = try JSONSerialization.data(withJSONObject: snapshot)
try data.write(to: directory.appendingPathComponent("state-\(pid).json"), options: .atomic)
} catch { fatalError("Could not publish CLI instance check: \(error)") }
}
}
TerebiApp.main()
}
}
+20 -1
View File
@@ -20,7 +20,9 @@ struct PlaybackProgressChecks {
try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: file)
let youtube = URL(string: "https://www.youtube.com/watch?v=jNQXAC9IVRw")!
var progress = PlaybackProgress(defaults: defaults)
let progress = PlaybackProgress(defaults: defaults)
let independent = PlaybackProgress(defaults: UserDefaults(suiteName: suite)!)
let independentFile = directory.appendingPathComponent("independent.mp4")
progress.save(123.25, for: file)
progress.save(18, for: otherFile)
progress.save(9.5, for: youtube)
@@ -29,12 +31,20 @@ struct PlaybackProgressChecks {
require(reopened.position(for: alias) == 123.25, "symlink and original file share progress")
require(reopened.position(for: otherFile) == 18, "local files keep separate positions")
require(reopened.position(for: youtube) == 9.5, "canonical YouTube URL persists independently of local files")
independent.save(27, for: independentFile)
let shared = PlaybackProgress(defaults: defaults)
require(shared.position(for: file) == 123.25 && shared.position(for: youtube) == 9.5,
"an independently initialized player cannot overwrite another video's bookmarks")
require(shared.position(for: independentFile) == 27,
"independent player progress is also retained")
progress.save(nil, for: alias)
let completed = PlaybackProgress(defaults: defaults)
require(completed.position(for: file) == nil, "completion clears progress through either file identity")
require(completed.position(for: otherFile) == 18 && completed.position(for: youtube) == 9.5,
"clearing one video preserves other videos")
require(completed.position(for: independentFile) == 27,
"clearing progress in one player preserves a different player's bookmark")
for position in [Double.nan, Double.infinity, -Double.infinity, -1, 0] {
progress.save(position, for: file)
require(PlaybackProgress(defaults: defaults).position(for: file) == nil,
@@ -44,6 +54,15 @@ struct PlaybackProgressChecks {
defer { isolated.removePersistentDomain(forName: suite + ".isolated") }
require(PlaybackProgress(defaults: isolated).position(for: youtube) == nil,
"injected preference domains remain isolated")
isolated.set([file.standardizedFileURL.absoluteString: 42.5, youtube.absoluteString: 15],
forKey: "playbackPositions")
let migrated = PlaybackProgress(defaults: isolated)
require(migrated.position(for: alias) == 42.5 && migrated.position(for: youtube) == 15,
"existing local and YouTube bookmarks survive the shared-history migration")
migrated.save(43, for: file)
let migratedAgain = PlaybackProgress(defaults: isolated)
require(migratedAgain.position(for: file) == 43 && migratedAgain.position(for: youtube) == 15,
"opening another player preserves both migrated bookmarks and newer progress")
print("Playback progress checks passed")
}
}
+23 -4
View File
@@ -13,12 +13,14 @@ struct PlayerCommandChecks {
let movie = directory.appendingPathComponent("video with spaces.mp4")
let optionFile = directory.appendingPathComponent("--help")
let colonFile = directory.appendingPathComponent("part:1.mp4")
for file in [movie, optionFile, colonFile] { try Data().write(to: file) }
let instanceFile = directory.appendingPathComponent("--new-instance")
for file in [movie, optionFile, colonFile, instanceFile] { try Data().write(to: file) }
func opened(_ arguments: [String]) throws -> URL? {
guard case .launch(let url) = try PlayerCommand.parse(arguments, directory: directory) else {
func opened(_ arguments: [String], newInstance: Bool = false) throws -> URL? {
guard case .launch(let url, let independent) = try PlayerCommand.parse(arguments, directory: directory) else {
fatalError("FAIL: expected a launch command")
}
require(independent == newInstance, "launch mode preserves default reuse or explicit independence")
return url
}
require(try opened([movie.lastPathComponent]) == movie, "relative paths resolve against invocation directory, preserving spaces")
@@ -27,10 +29,27 @@ struct PlayerCommandChecks {
require(try opened([colonFile.path]) == colonFile, "colons in filesystem paths are not URL schemes")
let canonical = URL(string: "https://www.youtube.com/watch?v=jNQXAC9IVRw")!
require(try opened(["https://youtu.be/jNQXAC9IVRw"]) == canonical, "YouTube positional URLs canonicalize for resume identity")
require(try opened(["--new-instance"], newInstance: true) == nil, "an independent launch can omit media")
require(try opened(["--new-instance", movie.lastPathComponent], newInstance: true) == movie,
"independent local launches preserve relative paths and spaces")
require(try opened(["--new-instance", "--url", canonical.absoluteString], newInstance: true) == canonical,
"independent explicit YouTube launches retain canonical resume identity")
require(try opened(["--new-instance", "https://youtu.be/jNQXAC9IVRw"], newInstance: true) == canonical,
"independent positional YouTube launches retain canonical resume identity")
require(try opened(["--new-instance", "--", "--help"], newInstance: true) == optionFile,
"independent launch preserves the filename terminator")
require(try opened(["--", "--new-instance"]) == instanceFile,
"an escaped instance flag is a filename, not a launch option")
guard case .help = try PlayerCommand.parse(["--new-instance", "--help"]) else {
fatalError("FAIL: help must not launch an independent player")
}
for arguments in [["--url"], ["--url", canonical.absoluteString, movie.path], ["--"],
[movie.path, movie.path], ["--unknown"], ["missing.mp4"], [directory.path],
["file://remote-host/video.mp4"], ["https://example.com/video.mp4"]] {
["file://remote-host/video.mp4"], ["https://example.com/video.mp4"],
["--new-instance", "--new-instance"], ["--new-instance", "--url"],
["--new-instance", "--"], ["--new-instance", movie.path, movie.path],
["--new-instance", "--unknown"]] {
do {
_ = try PlayerCommand.parse(arguments, directory: directory)
fatalError("FAIL: accepted an invalid command")
+1 -1
View File
@@ -90,6 +90,7 @@ check-fixtures: | check-dependencies
# Literal frame PTS: 0,.1,.2,.3,.4,.65,.9,1.15,1.4,1.5,1.6,1.7; reordered B-frames across short GOPs.
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=10:duration=1.2 -f lavfi -i sine=frequency=440:sample_rate=44100:duration=2 -vf "settb=1/1000,setpts='if(lt(N,5),N*100,if(lt(N,9),400+(N-4)*250,1400+(N-8)*100))'" -fps_mode vfr -enc_time_base:v 1:1000 -c:v mpeg4 -g 4 -bf 2 -video_track_timescale 1000 -c:a aac $(BUILD_DIR)/fixtures/frame-step.mp4
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=160x90:rate=12 -t 1 -vf 'format=yuv420p10le,setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc' -c:v ffv1 $(BUILD_DIR)/fixtures/hdr.mkv
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 120 -c:v mpeg4 -q:v 2 -g 24 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/cache.mp4
$(BUILD_DIR)/Output-check.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | $(BUILD_DIR) check-dependencies
$(CXX) $(CXXFLAGS) -fobjc-arc -DPLAYER_AUDIO_CHECK -c $< -o $@
@@ -145,7 +146,6 @@ check-network: $(BUILD_DIR)/network-playback-check check-fixtures
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/frame-step.mp4 -an -c:v copy $(BUILD_DIR)/fixtures/frame-step-video.mp4
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/frame-step.mp4 -vn -c:a copy $(BUILD_DIR)/fixtures/frame-step-audio.m4a
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 30 -c:v mpeg4 -q:v 2 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/streaming.mp4
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 120 -c:v mpeg4 -q:v 2 -g 24 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/cache.mp4
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/av.mp4 -c:v libx264 -g 24 -keyint_min 24 -sc_threshold 0 -c:a aac -hls_time 1 -hls_playlist_type vod -hls_segment_filename $(BUILD_DIR)/fixtures/network-vod-%d.ts $(BUILD_DIR)/fixtures/network-vod.m3u8
python3 Checks/streaming.py $(BUILD_DIR)/network-playback-check $(BUILD_DIR)/fixtures
+9 -5
View File
@@ -43,12 +43,14 @@ The declared app executable stays running after preflight and loads `Contents/Fr
.build/terebi 'https://youtu.be/jNQXAC9IVRw'
.build/terebi --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw'
.build/terebi -- -video.mp4
.build/terebi --new-instance
.build/terebi --new-instance --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw'
.build/terebi --help
```
One local file (including a local `file://` URL) or YouTube video URL is accepted. Paths are relative to the shell's current directory; quote spaces and URL query strings. `--` treats the following argument as a filename. With no arguments, the CLI opens or activates the player without replacing its media. To use `terebi` by name, add the build directory to your shell's PATH: `export PATH="$PWD/.build:$PATH"`.
One local file (including a local `file://` URL) or YouTube video URL is accepted. Paths are relative to the shell's current directory; quote spaces and URL query strings. `--` treats the following argument as a filename. With no arguments, the CLI opens or activates the player without replacing its media. Put **`--new-instance` before the media arguments** to launch a separate player process/window instead: it works without media, with a local file or positional YouTube URL, and with `--url` or `--`. Existing playback stays untouched. To use `terebi` by name, add the build directory to your shell's PATH: `export PATH="$PWD/.build:$PATH"`.
The launcher finds `terebi.app` beside its own executable, following symlinks, and uses macOS application/URL delivery to reuse the running player and its existing window. Keep the executable and app bundle together; this is not a standalone distribution. Media opens use the same autoplay and saved-progress behavior as the UI. Relaunch an older running build once to load the new external-open handling.
The launcher finds `terebi.app` beside its own executable, following symlinks. By default, macOS application/URL delivery reuses a running player and its existing window. With `--new-instance`, one fresh application launch carries any media URL as a startup argument, so it reaches that process without a second app launch or changing another player's media. Keep the executable and app bundle together; this is not a standalone distribution. Media opens use the same autoplay and saved-progress behavior as the UI. Instances share preferences and saved progress; each has independent playback and a window. Quit all running players after installing this update so every process uses the updated startup/history handling.
Help exits with status **0** without opening the GUI or loading FFmpeg. Invalid arguments, unsupported URLs, missing files, and directories report to stderr and exit **64**; unavailable FFmpeg libraries and application-launch failures exit **69**. A successful launch exits **0** immediately after macOS accepts the request, not when playback finishes. Later extraction/decoding failures appear in the player window; this is a GUI launcher, not a headless player or remote playback-control interface.
@@ -82,6 +84,8 @@ Reopening a local video or YouTube video automatically resumes its saved positio
Progress is stored locally in UserDefaults, checkpointed every five seconds of playback progress, and saved on pause, close, replacement, and normal quit. Only authoritative, acknowledged positions are saved—not an unfinished seek or slider drag. Finished videos clear their bookmark and reopen from the beginning; a bookmark beyond the current video's duration also restarts at the beginning. Failed opens preserve existing bookmarks. Live/non-seekable inputs and audio-only media do not record progress.
Each video's bookmark has its own UserDefaults key, so separate player processes cannot erase unrelated bookmarks by rewriting a cached history dictionary. Existing bookmarks migrate automatically from the old dictionary. If two instances play the same video, the most recent checkpoint wins; progress is shared, not a separate bookmark per window.
## Playback UI
The layout follows [IINA's video-first window](https://iina.io/img/screenshot.png) and the supplied screenshots: a 32-point frosted titlebar with native traffic lights and file icon, plus a dark frosted 440 × 68-point floating controller. AppKit materials blur the video beneath the chrome; muted icons, an orange volume track, a narrow seek thumb, and zero-padded time labels follow the reference. Shared vertical padding and an optical offset for symbol/slider insets balance the controller's visible top and bottom margins in both play and pause states. The controller starts centered with a four-percent bottom inset and keeps its relative position when the window changes size.
@@ -152,15 +156,15 @@ 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 opt-in persistence. The quality clock is injected; tests use isolated preferences 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-progress` verifies persisted fractional positions, independent local/YouTube identities, symlink aliases, completion clearing, invalid-position rejection, and isolated preference domains. 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-cli` checks relative/escaped paths, option-like filenames, YouTube identity, argument cardinality, unknown options, missing files/directories, non-local file URLs, and unsupported stream URLs. It is included in `make check`.
`make check-cli` checks relative/escaped paths, option-like filenames, YouTube identity, default reuse versus explicit `--new-instance`, independent empty/local/YouTube launches, help without launch, argument cardinality, unknown/duplicate options, missing files/directories, non-local file URLs, and unsupported stream URLs. It is included in `make check`.
`make check-runtime` exercises missing/invalid dependency metadata, unavailable ABI libraries, existing but unloadable dylibs, omitted library families, and successful native library loading using isolated app metadata. It is included in `make check`; no installed FFmpeg files are modified.
`make check-media` exercises actual playback through the app model, idempotent play/pause, 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 buffer readout, persisted opt-in/explicit disable, 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-startup` launches an isolated app bundle through LaunchServices using the production bootstrap and a check-only player library with actual native playback. It verifies that the playing process retains a real registered app PID and bundle identity—the prerequisite for third-party Now Playing discovery. It is included in `make check`; it does not require Vorssaint, show a playback window, or touch real playback history. Cold Finder/CLI opens, warm local/YouTube opens, saved-position restoration, same-window reuse, and Vorssaint's adapter discovery were additionally smoke-checked through the real GUI.
`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 SwiftUI scene and delegate: cold/default reuse, independent empty/local/YouTube launches, exactly one window per PID, media delivered only to the new process, original playback retained, help without launch, and warm default replacement. YouTube uses gated-capable 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, audio muted, and bundle/preferences isolated. It does not require Vorssaint or touch real playback history. Finder/Space behavior and Vorssaint discovery still need a check in the visible app.
Continuous audio uses resampled sample counts for its output timeline; packet timestamp quantization and rounded resampler delay must not insert silence or drop samples between blocks.
+14 -8
View File
@@ -2,28 +2,34 @@ import Foundation
struct PlaybackProgress {
private let defaults: UserDefaults
private var positions: [String: Double]
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
positions = defaults.dictionary(forKey: "playbackPositions") as? [String: Double] ?? [:]
// Migrate the old shared dictionary once; separate keys prevent cross-player lost updates.
if let positions = defaults.dictionary(forKey: "playbackPositions") as? [String: Double] {
for (identity, position) in positions where position.isFinite && position > 0 {
let key = "playbackPosition:" + identity
if defaults.object(forKey: key) == nil { defaults.set(position, forKey: key) }
}
defaults.removeObject(forKey: "playbackPositions")
}
}
func position(for url: URL) -> Double? {
guard let position = positions[key(for: url)], position.isFinite, position > 0 else { return nil }
guard let position = defaults.object(forKey: key(for: url)) as? Double,
position.isFinite, position > 0 else { return nil }
return position
}
mutating func save(_ position: Double?, for url: URL) {
func save(_ position: Double?, for url: URL) {
let key = key(for: url)
let position = position.flatMap { $0.isFinite && $0 > 0 ? $0 : nil }
guard positions[key] != position else { return }
positions[key] = position
defaults.set(positions, forKey: "playbackPositions")
guard (defaults.object(forKey: key) as? Double) != position else { return }
defaults.set(position, forKey: key)
}
private func key(for url: URL) -> String {
// YouTube callers supply the canonical webpage URL, never an expiring media URL.
return url.isFileURL ? url.standardizedFileURL.resolvingSymlinksInPath().absoluteString : url.absoluteString
return "playbackPosition:" + (url.isFileURL ? url.standardizedFileURL.resolvingSymlinksInPath().absoluteString : url.absoluteString)
}
}
+5 -1
View File
@@ -55,7 +55,7 @@ final class PlayerModel: ObservableObject {
private let qualityClock: () -> Double
private var automaticQuality = AutomaticQuality()
private var qualityNow: Double { qualityClock() }
private var playbackProgress: PlaybackProgress
private let playbackProgress: PlaybackProgress
private var progressURL: URL?
private var resumePosition: Double?
private var resumePlaying = true
@@ -1369,6 +1369,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
NSApp.activate(ignoringOtherApps: true)
NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(openDocuments(_:withReplyEvent:)),
forEventClass: AEEventClass(kCoreEventClass), andEventID: AEEventID(kAEOpenDocuments))
let arguments = Array(CommandLine.arguments.dropFirst())
if arguments.count == 2, arguments[0] == "--open-media", let url = URL(string: arguments[1]) {
PlayerModel.shared.open(url)
}
}
// Let SwiftUI create the initial window for a Finder/document launch.
+21 -17
View File
@@ -2,34 +2,38 @@ import Foundation
enum PlayerCommand {
case help
case launch(URL?)
case launch(URL?, newInstance: Bool)
static let usage = """
Usage: terebi [FILE | YOUTUBE_URL]
terebi --url YOUTUBE_URL
terebi -- FILE
Usage: terebi [--new-instance] [FILE | YOUTUBE_URL]
terebi [--new-instance] --url YOUTUBE_URL
terebi [--new-instance] -- FILE
terebi --help
Open one local media file or YouTube video and start playback, resuming saved
video progress when available. With no arguments, open or activate the player.
Quote paths and URLs containing spaces or shell metacharacters.
--url URL Open a YouTube video URL.
-- Treat the following argument as a filename, even with a leading '-'.
-h, --help Show this help without launching the player.
--new-instance Launch a separate player, leaving existing playback untouched.
--url URL Open a YouTube video URL.
-- Treat the following argument as a filename, even with a leading '-'.
-h, --help Show this help without launching the player.
Reuses the running player when available. The command exits once macOS accepts
the launch; playback and extraction errors appear in the player window.
Reuses the running player unless --new-instance is supplied before the media
arguments. The command exits once macOS accepts the launch; playback and
extraction errors appear in the player window.
"""
static func parse(_ arguments: [String], directory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) throws -> PlayerCommand {
if arguments.isEmpty { return .launch(nil) }
if arguments == ["--help"] || arguments == ["-h"] { return .help }
if arguments.first == "--url" {
guard arguments.count == 2 else { throw ArgumentError("--url requires exactly one YouTube URL.") }
return .launch(try YouTubeResolver.validateURL(arguments[1]))
}
var inputs = arguments
let newInstance = inputs.first == "--new-instance"
if newInstance { inputs.removeFirst() }
if inputs.isEmpty { return .launch(nil, newInstance: newInstance) }
if inputs == ["--help"] || inputs == ["-h"] { return .help }
if inputs.first == "--url" {
guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one YouTube URL.") }
return .launch(try YouTubeResolver.validateURL(inputs[1]), newInstance: newInstance)
}
let filenameOnly = inputs.first == "--"
if filenameOnly {
inputs.removeFirst()
@@ -48,7 +52,7 @@ enum PlayerCommand {
}
file = url
} else {
return .launch(try YouTubeResolver.validateURL(input))
return .launch(try YouTubeResolver.validateURL(input), newInstance: newInstance)
}
} else {
let base = directory.hasDirectoryPath ? directory : URL(fileURLWithPath: directory.path, isDirectory: true)
@@ -58,7 +62,7 @@ enum PlayerCommand {
FileManager.default.isReadableFile(atPath: file.path) else {
throw ArgumentError("File does not exist or is not a readable regular file.")
}
return .launch(file.standardizedFileURL)
return .launch(file.standardizedFileURL, newInstance: newInstance)
}
private struct ArgumentError: LocalizedError {
+7 -3
View File
@@ -14,7 +14,7 @@ enum TerebiCLI {
switch command {
case .help:
print(PlayerCommand.usage)
case .launch(let url):
case .launch(let url, let newInstance):
guard let executable = Bundle.main.executableURL else {
fail("Could not locate the CLI executable.", code: 69)
}
@@ -24,10 +24,14 @@ enum TerebiCLI {
fail(error, code: 69)
}
let configuration = NSWorkspace.OpenConfiguration()
configuration.createsNewApplicationInstance = newInstance
if newInstance, let url {
configuration.arguments = ["--open-media", url.absoluteString]
}
do {
// Launch without media first so SwiftUI creates its singleton window.
// Launch an application, not a document, so SwiftUI creates its singleton window.
_ = try await NSWorkspace.shared.openApplication(at: app, configuration: configuration)
if let url {
if !newInstance, let url {
_ = try await NSWorkspace.shared.open([url], withApplicationAt: app, configuration: configuration)
}
} catch {