feat: show skip offer countdown and transitions

This commit is contained in:
2026-10-22 14:09:53 +02:00
parent 70672321a1
commit 53369424e6
13 changed files with 453 additions and 40 deletions
+52
View File
@@ -331,6 +331,48 @@ def check_cli_instances(root, app, fixtures):
wait_states(count, lambda _: True, "original players survive independent resolved GUI quits") wait_states(count, lambda _: True, "original players survive independent resolved GUI quits")
print("PASS actual resolved CLI handoff: two isolated GUI episodes, authenticated encrypted HLS, independent default VTT, v2 progress and quit") print("PASS actual resolved CLI handoff: two isolated GUI episodes, authenticated encrypted HLS, independent default VTT, v2 progress and quit")
def check_stream_queue_instance():
# DEBUG-queue-startup: distinguish queue startup from ordinary empty startup.
invoke(["--new-instance", "--no-config"])
empty_states = wait_states(1, lambda _: True, "diagnostic empty startup creates its window")
quit_instance(next(iter(empty_states)), 1)
wait_states(0, lambda _: True, "diagnostic empty startup quits")
session = (root / "stream-queue-session").resolve()
session.mkdir(mode=0o700)
paths = [session / "first.jsonl", session / "second.jsonl"]
urls = [reported_urls[0], canonical] # Deliberately not alphabetical URL order.
manifest = session / "queue.json"
manifest.write_text(json.dumps({"version": 1, "items": [
{"url": url, "progress_file": str(path)} for url, path in zip(urls, paths)]}))
invoke(["--new-instance", "--no-config", "--queue-file", str(manifest)])
states = wait_states(1, lambda snapshots: all(
state["queueURLs"] == urls and state["queueIndex"] == 0 and not state["busy"]
and state["status"] == 3 and state["hasVideo"] and state["position"] > 0.2
for state in snapshots.values()), "actual queue CLI delivers ordered native stream playback")
pid = next(iter(states))
if paths[1].exists():
raise RuntimeError("unselected queue entry must not create or write its report")
input_request(pid, 1, lambda state: state["queueIndex"] == 1 and not state["busy"]
and state["status"] == 3, "native queue manual next", action="queueAdvance", offset=1)
input_request(pid, 1, lambda state: state["queueIndex"] == 0 and not state["busy"]
and state["status"] == 3, "native queue previous revisits owned report", action="queueAdvance", offset=-1)
states = input_request(pid, 1, lambda state: state["status"] == 2 and not state["busy"],
"pause queue first item", action="pause")
target = states[pid]["duration"] - 0.3
input_request(pid, 1, lambda state: at_position(state, target),
"acknowledged queue end seek", action="seek", position=target)
input_request(pid, 1, lambda state: state["queueIndex"] == 1 and not state["busy"]
and state["status"] == 3 and state["queueURLs"] == urls,
"natural end advances without losing supplied queue", action="play")
quit_instance(pid, 1)
for path, url in zip(paths, urls):
rows = wait_report(path, {url}, lambda rows: bool(rows) and rows[-1]["event"] == "closed",
"queue quit drains independent stable report")
if len(rows) < 4:
raise RuntimeError(f"queue revisit must append without clobbering earlier records: {path}: {rows}")
wait_states(0, lambda _: True, "queue GUI quit leaves no stale instance")
print("PASS actual queue CLI: ordered lazy streams, manual next/previous, natural end advance and private report revisits")
resolved_fixtures(fixtures) resolved_fixtures(fixtures)
with StartupStreamingServer(fixtures) as server: with StartupStreamingServer(fixtures) as server:
if extractor and runtime: if extractor and runtime:
@@ -365,9 +407,16 @@ def check_cli_instances(root, app, fixtures):
runtime_settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")} runtime_settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")}
(app / "Contents/Resources/cli-check.json").write_text(json.dumps(runtime_settings)) (app / "Contents/Resources/cli-check.json").write_text(json.dumps(runtime_settings))
subprocess.run(["codesign", "--force", "--sign", "-", str(app)], check=True) subprocess.run(["codesign", "--force", "--sign", "-", str(app)], check=True)
if os.environ.get("TEREBI_CHECK_STREAM_QUEUE_ONLY") == "1":
if not extractor or not runtime:
raise RuntimeError("stream queue CLI smoke requires bundled extractor and runtime fixtures")
check_stream_queue_instance()
return
if os.environ.get("TEREBI_CHECK_RESOLVED_ONLY") == "1": if os.environ.get("TEREBI_CHECK_RESOLVED_ONLY") == "1":
check_resolved_instances(server, 0) check_resolved_instances(server, 0)
return return
if extractor and runtime:
check_stream_queue_instance()
movie = fixtures / "cache.mp4" movie = fixtures / "cache.mp4"
audio = root / "audio with spaces.wav" audio = root / "audio with spaces.wav"
shutil.copy2(fixtures / "audio.wav", audio) shutil.copy2(fixtures / "audio.wav", audio)
@@ -721,6 +770,9 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK) descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK)
launch = None launch = None
try: try:
if os.environ.get("TEREBI_CHECK_STREAM_QUEUE_ONLY") == "1":
check_cli_instances(root, app, fixtures)
sys.exit(0)
with selectors.DefaultSelector() as selector: with selectors.DefaultSelector() as selector:
selector.register(descriptor, selectors.EVENT_READ) selector.register(descriptor, selectors.EVENT_READ)
launch = subprocess.Popen( launch = subprocess.Popen(
+6
View File
@@ -925,6 +925,8 @@ private enum CLIInstanceCheck {
case "queue": case "queue":
model.showsQueue = command["visible"] as! Bool model.showsQueue = command["visible"] as! Bool
model.revealControls() model.revealControls()
case "queueAdvance":
model.advanceQueue(command["offset"] as! Int)
case "caption": case "caption":
guard let view = window.contentView, let selector = captionSelector(in: view), guard let view = window.contentView, let selector = captionSelector(in: view),
let cell = selector.cell as? NSPopUpButtonCell, let menu = selector.menu else { let cell = selector.cell as? NSPopUpButtonCell, let menu = selector.menu else {
@@ -1013,6 +1015,8 @@ private enum CLIInstanceCheck {
"canChangeQuality": model.canChangeYouTubeQuality, "canChangeQuality": model.canChangeYouTubeQuality,
"qualityHeights": model.youtubeHeights, "qualityHeights": model.youtubeHeights,
"windows": windows.count, "windows": windows.count,
"allWindows": NSApp.windows.map { ["class": NSStringFromClass(type(of: $0)),
"visible": $0.isVisible, "main": $0.canBecomeMain, "content": $0.contentView != nil] as [String: Any] },
"hasAudio": model.state.has_audio, "hasAudio": model.state.has_audio,
"hasVideo": model.state.has_video, "hasVideo": model.state.has_video,
"error": model.errorText ?? "", "error": model.errorText ?? "",
@@ -1031,6 +1035,8 @@ private enum CLIInstanceCheck {
"editorText": editorText, "editorText": editorText,
"compact": model.isCompact, "compact": model.isCompact,
"showsQueue": model.showsQueue, "showsQueue": model.showsQueue,
"queueURLs": model.queue.items.map(\.url.absoluteString),
"queueIndex": model.queue.items.firstIndex { $0.id == model.queue.currentID } ?? -1,
"window": windowSnapshot(windows.first), "window": windowSnapshot(windows.first),
"onTop": model.isOnTop, "onTop": model.isOnTop,
] ]
+13
View File
@@ -105,6 +105,19 @@ struct PlaybackQueueChecks {
"replace discards previous queue and starts new selection") "replace discards previous queue and starts new selection")
queue.replace([]) queue.replace([])
require(queue.items.isEmpty && queue.currentID == nil, "empty replacement clears queue") require(queue.items.isEmpty && queue.currentID == nil, "empty replacement clears queue")
let youtube = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
let miruro = URL(string: "https://barelystarted.miruro.tv/watch/fixture/episode?ep=2")!
queue.replaceStreams([(url: miruro, label: "Episode 2"), (url: youtube, label: "YouTube")])
require(queue.items.map(\.url) == [miruro, youtube], "stream order is preserved without file filtering or sorting")
require(queue.currentItem?.displayName == "Episode 2", "stream rows have descriptive labels")
let streamID = queue.currentID!
queue.updateCurrentLabel("Resolved episode title")
require(queue.currentID == streamID && queue.currentItem?.displayName == "Resolved episode title",
"metadata updates labels without changing queue identity")
require(queue.advance(1) == youtube && queue.advance(-1) == miruro, "stream navigation uses supplied order")
queue.move(fromOffsets: IndexSet(integer: 0), toOffset: 2)
require(queue.currentID == streamID && queue.items.map(\.url) == [youtube, miruro], "stream reorder preserves selection")
require(queue.remove(streamID) == nil && queue.items.map(\.url) == [youtube], "removing current final stream stops")
print("playback queue checks passed") print("playback queue checks passed")
} }
} }
+63
View File
@@ -134,6 +134,69 @@ struct PlayerCommandChecks {
fatalError("FAIL: FIFO source accepted") fatalError("FAIL: FIFO source accepted")
} catch {} } catch {}
let queueFile = directory.appendingPathComponent("queue.json")
let resolvedDirectory = realpath(directory.path, nil)!
defer { free(resolvedDirectory) }
let privateDirectory = URL(fileURLWithPath: String(cString: resolvedDirectory), isDirectory: true)
require(chmod(privateDirectory.path, 0o700) == 0, "queue reports require a private owned parent")
let youtubeReport = privateDirectory.appendingPathComponent("youtube.jsonl").path
let miruroReport = privateDirectory.appendingPathComponent("miruro.jsonl").path
let entries: [[String: Any]] = [
["url": miruro.absoluteString, "progress_file": miruroReport],
["url": canonical.absoluteString, "progress_file": youtubeReport]
]
func saveQueue(_ value: [String: Any]) throws {
try JSONSerialization.data(withJSONObject: value).write(to: queueFile)
}
try saveQueue(["version": 1, "items": entries])
guard case .launchQueue(let deliveredQueue, let queueIndependent, let queueBypass) =
try PlayerCommand.parse(["--new-instance", "--no-config", "--queue-file", queueFile.path]) else {
fatalError("FAIL: expected stream queue launch")
}
require(deliveredQueue == queueFile && queueIndependent && queueBypass, "queue CLI preserves independent delivery")
let parsedQueue = try StreamQueue.load(from: queueFile)
require(parsedQueue.entries.map(\.url) == [miruro, canonical], "stream queue order is supplied, never filename-sorted")
require(parsedQueue.entries[0].identity.hasPrefix("miruro:") &&
parsedQueue.entries[1].identity == canonical.absoluteString, "stable identities exclude slugs and transport URLs")
let duplicateYouTube = ["url": "https://youtu.be/jNQXAC9IVRw", "progress_file": miruroReport]
let duplicateMiruro = ["url": miruro.absoluteString.replacingOccurrences(of: "the-apothecary-diaries-season-3", with: "different-slug"),
"progress_file": youtubeReport]
let invalidQueues: [[String: Any]] = [
["version": 2, "items": entries], ["version": true, "items": entries],
["version": 1, "items": []], ["version": 1],
["version": 1, "items": Array(repeating: entries[0], count: 4097)],
["version": 1, "items": [entries[1], duplicateYouTube]],
["version": 1, "items": [entries[0], duplicateMiruro]],
["version": 1, "items": [entries[0], ["url": canonical.absoluteString, "progress_file": miruroReport]]],
["version": 1, "items": [entries[0], ["url": canonical.absoluteString,
"progress_file": privateDirectory.resolvingSymlinksInPath().appendingPathComponent("miruro.jsonl").path]]],
["version": 1, "items": [["url": "https://cdn.example/expired.m3u8", "progress_file": youtubeReport]]],
["version": 1, "items": [["url": canonical.absoluteString, "progress_file": "relative.jsonl"]]],
["version": 1, "items": [["url": canonical.absoluteString, "progress_file": movie.path]]]
]
for document in invalidQueues {
try saveQueue(document)
do {
_ = try PlayerCommand.parse(["--new-instance", "--queue-file", queueFile.path])
fatalError("FAIL: malformed queue must never launch or degrade to single playback")
} catch {}
}
try saveQueue(["version": 1, "items": entries])
for arguments in [
["--queue-file", queueFile.path], ["--new-instance", "--queue-file"],
["--new-instance", "--queue-file", "queue.json"],
["--new-instance", "--queue-file", queueFile.path, "--url", canonical.absoluteString],
["--new-instance", "--queue-file", queueFile.path, "--progress-file", report.path],
["--new-instance", "--queue-file", queueFile.path, "--queue-file", queueFile.path]
] {
do { _ = try PlayerCommand.parse(arguments); fatalError("FAIL: invalid queue option combination") }
catch {}
}
require(chmod(privateDirectory.path, 0o755) == 0, "make report parent nonprivate for rejection check")
do { _ = try StreamQueue.load(from: queueFile); fatalError("FAIL: public report directory accepted") }
catch {}
require(chmod(privateDirectory.path, 0o700) == 0, "restore private check directory")
for arguments in [["--url"], ["--url", canonical.absoluteString, movie.path], ["--"], for arguments in [["--url"], ["--url", canonical.absoluteString, movie.path], ["--"],
[movie.path, movie.path], ["--unknown"], ["missing.mp4"], [directory.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"],
+35
View File
@@ -314,6 +314,41 @@ int main(int argc, char **argv) {
require(symlink(existingPath.c_str(), linkPath.c_str()) == 0 && rejected(linkPath), "symlink destination fails initialization"); require(symlink(existingPath.c_str(), linkPath.c_str()) == 0 && rejected(linkPath), "symlink destination fails initialization");
require([[NSData dataWithContentsOfFile:nativePath(existingPath)] isEqual:sentinel], "rejection never changes an existing destination"); require([[NSData dataWithContentsOfFile:nativePath(existingPath)] isEqual:sentinel], "rejection never changes an existing destination");
const auto queueFirst = root + "/queue-first.jsonl", queueSecond = root + "/queue-second.jsonl";
{
playback::ProgressReports reports;
reports.registerFile(firstURL, queueFirst);
reports.registerFile(secondURL, queueSecond);
reports.begin(firstURL);
reports.observe(sample(PLAYER_PAUSED, 12));
reports.begin(secondURL);
reports.observe(sample(PLAYER_ENDED, 100));
reports.begin(firstURL);
reports.observe(sample(PLAYER_PAUSED, 24));
reports.begin("miruro:unregistered:1");
reports.observe(sample(PLAYER_PAUSED, 55));
reports.finish();
require(reports.error().empty(), "queue reports can revisit their owned session without reopening arbitrary files");
}
auto firstRows = records(queueFirst), secondRows = records(queueSecond);
require(firstRows.count == 4 && secondRows.count == 2, "queue switches drain old reports and revisits append");
expect(firstRows[0], @"progress", firstURL, 12, true);
expect(firstRows[2], @"progress", firstURL, 24, true);
expect(secondRows[0], @"ended", secondURL, 100, true);
const auto replacedPath = root + "/replaced.jsonl";
{
ProgressReporter reporter(replacedPath, [] { return 100.; });
reporter.begin(firstURL);
reporter.observe(sample(PLAYER_PAUSED, 1));
reporter.finish();
require(rename(replacedPath.c_str(), (replacedPath + ".original").c_str()) == 0, "retain original inode while replacing path");
require([sentinel writeToFile:nativePath(replacedPath) atomically:NO], "replace report with unrelated sentinel");
bool refused = false;
try { reporter.resume(); } catch (...) { refused = true; }
require(refused && [[NSData dataWithContentsOfFile:nativePath(replacedPath)] isEqual:sentinel],
"revisiting never appends to a replaced report inode");
}
const auto concurrentPath = root + "/concurrent.jsonl"; const auto concurrentPath = root + "/concurrent.jsonl";
std::atomic<int> ready{0}, successes{0}, failures{0}; std::atomic<int> ready{0}, successes{0}, failures{0};
std::atomic<bool> start{false}; std::atomic<bool> start{false};
+111
View File
@@ -94,6 +94,113 @@ struct ResolvedPlaybackCheck {
} }
print("PASS: default VTT selected, forward/backward seek and acknowledged cue timing") print("PASS: default VTT selected, forward/backward seek and acknowledged cue timing")
if !input.hasPrefix("https://") { try await segmentSkips(model, clock: clock, file: file) } if !input.hasPrefix("https://") { try await segmentSkips(model, clock: clock, file: file) }
if !input.hasPrefix("https://") {
model.shutdown()
try await streamQueueLifecycle(source: source)
}
}
static func streamQueueLifecycle(source: ResolvedSource) async throws {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("terebi.queue-check." + UUID().uuidString).resolvingSymlinksInPath()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700])
defer { try? FileManager.default.removeItem(at: directory) }
let youtubeURL = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
let episodeURL = URL(string: "https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1")!
let firstReport = directory.appendingPathComponent("youtube.jsonl")
let secondReport = directory.appendingPathComponent("miruro.jsonl")
let queue = StreamQueue(entries: [
.init(url: youtubeURL, identity: youtubeURL.absoluteString, progressFile: firstReport, label: "First"),
.init(url: episodeURL, identity: source.identity, progressFile: secondReport, label: "Second")
])
let suite = "terebi.queue-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
var youtubeRequests = 0
var episodeRequests = 0
var stale: CheckedContinuation<YouTubeVideo, Never>?
let video = YouTubeVideo(title: "Queue YouTube fixture",
video: .init(url: source.url, headers: source.headers), audio: nil,
duration: 0, isLive: false, webpageURL: youtubeURL, height: nil, availableHeights: [])
let model = PlayerModel(defaults: defaults, streamQueue: queue, youtubeResolver: { _, _ in
youtubeRequests += 1
if youtubeRequests == 1 {
return await withCheckedContinuation { stale = $0 }
}
return video
}, miruroResolver: { _, _ in
episodeRequests += 1
return source
})
defer { model.shutdown() }
require(model.progressInitializationError == nil, "queue report registration")
model.openStreamQueue(queue)
try await until("only selected first stream resolves", model: model) { stale != nil }
require(episodeRequests == 0 && model.queue.items.map(\.url) == [youtubeURL, episodeURL],
"stream queue is lazy and preserves supplied order")
let firstID = model.queue.items[0].id
let secondID = model.queue.items[1].id
model.selectQueueItem(secondID)
try await until("manual stream selection decodes native media", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
}
stale!.resume(returning: video)
stale = nil
// Yield to the canceled resolver; it must not open or redirect its report.
for _ in 0..<10 { await Task.yield() }
require(model.queue.currentID == secondID && model.fileName == source.title &&
!FileManager.default.fileExists(atPath: firstReport.path), "stale extraction cannot replace selected media or report")
model.advanceQueue(-1)
try await until("previous resolves YouTube afresh", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == video.title
}
require(youtubeRequests == 2 && model.queue.currentID == firstID, "manual previous preserves queue and resolves current item")
require(model.setPlayback(playing: false), "pause first queued stream")
try await until("queue pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
require(model.seekRelative(max(0, model.duration - 0.3) - model.state.position), "seek first stream near natural end")
try await until("end seek acknowledged", model: model) {
!model.state.seek_pending && !model.isBusy && abs(model.state.position - max(0, model.duration - 0.3)) < 0.1
}
require(model.setPlayback(playing: true), "play first stream to natural end")
try await until("natural end advances and freshly resolves second stream", model: model) {
model.queue.currentID == secondID && !model.isBusy && model.state.status == PLAYER_PLAYING &&
model.fileName == source.title && episodeRequests == 2
}
require(model.setPlayback(playing: false), "pause second stream for restoration")
try await until("second paused", model: model) { model.state.status == PLAYER_PAUSED }
require(model.seekRelative(0.8 - model.state.position), "save an acknowledged second-stream bookmark")
try await until("second bookmark acknowledged", model: model) {
!model.state.seek_pending && abs(model.state.position - 0.8) < 0.1
}
model.advanceQueue(-1)
try await until("revisit first report session", model: model) {
!model.isBusy && model.fileName == video.title && model.state.status == PLAYER_PLAYING
}
model.advanceQueue(1)
try await until("second revisit resumes acknowledged bookmark", model: model) {
!model.isBusy && model.fileName == source.title && model.state.status == PLAYER_PLAYING &&
model.state.position >= 0.7
}
require(youtubeRequests == 3 && episodeRequests == 3, "every revisit resolves fresh transport metadata")
model.shutdown()
func records(_ file: URL) throws -> [[String: Any]] {
try String(contentsOf: file, encoding: .utf8).split(separator: "\n").map {
try JSONSerialization.jsonObject(with: Data($0.utf8)) as! [String: Any]
}
}
let youtubeRows = try records(firstReport)
let episodeRows = try records(secondReport)
require(youtubeRows.count >= 4 && youtubeRows.allSatisfy {
$0["version"] as? Int == 1 && $0["media_url"] as? String == youtubeURL.absoluteString
}, "YouTube report appends on revisit without another identity")
require(youtubeRows.contains { $0["event"] as? String == "ended" }, "natural end retains its acknowledged completion snapshot")
require(episodeRows.count >= 6 && episodeRows.allSatisfy {
let identity = $0["media_identity"] as? [String: Any]
return $0["version"] as? Int == 2 && identity?["media_id"] as? String == "fixture-ID" &&
identity?["episode"] as? Int == 1
}, "Miruro report is isolated through selection, advance and revisits")
print("PASS: ordered lazy mixed stream queue, stale cancellation, native end advance, fresh revisits/resume and report isolation")
} }
static func segmentSkips(_ model: PlayerModel, clock: Clock, file: URL) async throws { static func segmentSkips(_ model: PlayerModel, clock: Clock, file: URL) async throws {
@@ -107,10 +214,14 @@ struct ResolvedPlaybackCheck {
} }
model.supplyMetadataForCheck(metadata, episode: 1) model.supplyMetadataForCheck(metadata, episode: 1)
require(model.segmentToSkip == opening, "late metadata offers opening at acknowledged paused position") require(model.segmentToSkip == opening, "late metadata offers opening at acknowledged paused position")
require(model.skipOfferRemainingFraction == 1, "late entry starts with a full countdown background")
clock.now = 105
require(model.skipOfferRemainingFraction == 0.5, "paused countdown drains by elapsed offer time, not media position")
clock.now = 109.999 clock.now = 109.999
require(model.segmentToSkip == opening, "opening remains available before ten seconds") require(model.segmentToSkip == opening, "opening remains available before ten seconds")
clock.now = 110 clock.now = 110
require(model.segmentToSkip == nil && !model.skipSegment(), "offer expires at ten seconds while paused") require(model.segmentToSkip == nil && !model.skipSegment(), "offer expires at ten seconds while paused")
require(model.skipOfferRemainingFraction == 0, "expired offer has no remaining background")
clock.now = 111 clock.now = 111
try await Task.sleep(for: .milliseconds(100)) try await Task.sleep(for: .milliseconds(100))
require(model.segmentToSkip == nil, "polling does not reoffer ignored opening") require(model.segmentToSkip == nil, "polling does not reoffer ignored opening")
+4 -3
View File
@@ -1,6 +1,6 @@
# Terebi # Terebi
A native macOS player for local media, YouTube videos and Miruro episodes, with a local-file queue, saved playback positions, compact mode and Chromecast support. A native macOS player for local media, YouTube videos and Miruro episodes, with ordered playback queues, saved playback positions, compact mode and Chromecast support.
## Build and install ## Build and install
@@ -23,8 +23,9 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self
- **Local files:** use **⌘O**, drag files onto the player, or choose Finder's **Open With**. Multiple files form a queue in natural filename order, with automatic advance. Use **View → Show Queue** to play, reorder or remove items; **+** or dropping files onto the sidebar appends them. The queue lasts only for the session; opening other media replaces it. - **Local files:** use **⌘O**, drag files onto the player, or choose Finder's **Open With**. Multiple files form a queue in natural filename order, with automatic advance. Use **View → Show Queue** to play, reorder or remove items; **+** or dropping files onto the sidebar appends them. The queue lasts only for the session; opening other media replaces it.
- **Streams:** use **Open Stream…** (**⇧⌘O**) or drop a supported link. YouTube supports individual videos, not playlists or channels. Miruro links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number; arbitrary streaming sites are not supported. - **Streams:** use **Open Stream…** (**⇧⌘O**) or drop a supported link. YouTube supports individual videos, not playlists or channels. Miruro links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number; arbitrary streaming sites are not supported.
- **Stream queues:** integrations can supply an ordered YouTube/Miruro queue with `--new-instance --queue-file /absolute/queue.json`. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Only the selected item resolves, including fresh resolution on revisits; local-file batches still use natural filename order.
- **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints. - **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints.
- **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Nothing skips automatically. - **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. Its background fill drains as the offer expires, with a short entrance/exit transition; Reduce Motion uses stepped countdown updates without the transition. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Nothing skips automatically.
- **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress. - **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
- **Compact mode:** press **P** for a floating player; double-click it to restore. Drag noninteractive areas to move the window. Use the titlebar pin to keep the normal window on top. - **Compact mode:** press **P** for a floating player; double-click it to restore. Drag noninteractive areas to move the window. Use the titlebar pin to keep the normal window on top.
- **Quality:** YouTube defaults to SDR up to 1080p. Use **gear → Quality** for other available resolutions or automatic quality. - **Quality:** YouTube defaults to SDR up to 1080p. Use **gear → Quality** for other available resolutions or automatic quality.
@@ -64,7 +65,7 @@ terebi --help
The CLI opens one file or supported stream, reusing the running player by default. With no arguments, it opens or activates the player. `--new-instance` starts a separate player; `--no-config` uses built-in settings on a fresh launch. Put these options before the media argument. Before installation, use `.build/terebi` instead of `terebi`. The CLI opens one file or supported stream, reusing the running player by default. With no arguments, it opens or activates the player. `--new-instance` starts a separate player; `--no-config` uses built-in settings on a fresh launch. Put these options before the media argument. Before installation, use `.build/terebi` instead of `terebi`.
For native episode-URL playback, external resolved sources and progress reporting, see the [integration contract](docs/miruro-playback-handoff.md). For native episode-URL playback, ordered stream queues, external resolved sources and progress reporting, see the [integration contract](docs/miruro-playback-handoff.md).
## Configuration ## Configuration
+9 -5
View File
@@ -35,6 +35,7 @@ struct StreamQueue: Sendable {
var identities = Set<String>() var identities = Set<String>()
var reports = Set<String>() var reports = Set<String>()
let entries = try document.items.map { item -> Entry in let entries = try document.items.map { item -> Entry in
guard item.url == item.url.trimmingCharacters(in: .whitespacesAndNewlines) else { throw InvalidQueue() }
let stream = try StreamURL.parse(item.url) let stream = try StreamURL.parse(item.url)
let url: URL let url: URL
let identity: String let identity: String
@@ -56,12 +57,15 @@ struct StreamQueue: Sendable {
var directoryInfo = stat() var directoryInfo = stat()
guard lstat(parent.path, &directoryInfo) == 0, guard lstat(parent.path, &directoryInfo) == 0,
(directoryInfo.st_mode & S_IFMT) == S_IFDIR, (directoryInfo.st_mode & S_IFMT) == S_IFDIR,
directoryInfo.st_uid == geteuid(), (directoryInfo.st_mode & 0o777) == 0o700, directoryInfo.st_uid == geteuid(), (directoryInfo.st_mode & 0o7777) == 0o700,
parent.resolvingSymlinksInPath().path == parent.path, let resolvedParent = realpath(parent.path, nil) else { throw InvalidQueue() }
reports.insert(report.path).inserted else { throw InvalidQueue() } defer { free(resolvedParent) }
let canonicalReport = URL(fileURLWithPath: String(cString: resolvedParent), isDirectory: true)
.appendingPathComponent(report.lastPathComponent)
guard reports.insert(canonicalReport.path).inserted else { throw InvalidQueue() }
var existing = stat() var existing = stat()
guard lstat(report.path, &existing) != 0, errno == ENOENT else { throw InvalidQueue() } guard lstat(canonicalReport.path, &existing) != 0, errno == ENOENT else { throw InvalidQueue() }
return Entry(url: url, identity: identity, progressFile: report, label: label) return Entry(url: url, identity: identity, progressFile: canonicalReport, label: label)
} }
return StreamQueue(entries: entries) return StreamQueue(entries: entries)
} catch { throw InvalidQueue() } } catch { throw InvalidQueue() }
+115 -15
View File
@@ -25,7 +25,9 @@ func compactProgressText(position: Double, duration: Double, format: CompactTitl
final class PlayerModel: ObservableObject { final class PlayerModel: ObservableObject {
static var startupConfiguration = PlayerConfiguration() static var startupConfiguration = PlayerConfiguration()
static var startupProgressFile: String? static var startupProgressFile: String?
static let shared = PlayerModel(configuration: startupConfiguration, progressFile: startupProgressFile) static var startupStreamQueue: StreamQueue?
static let shared = PlayerModel(configuration: startupConfiguration, progressFile: startupProgressFile,
streamQueue: startupStreamQueue)
@Published private(set) var state = PlayerState() @Published private(set) var state = PlayerState()
@Published private(set) var fileName: String? @Published private(set) var fileName: String?
@@ -77,6 +79,10 @@ final class PlayerModel: ObservableObject {
currentSkipInterval == skipOffer.interval else { return nil } currentSkipInterval == skipOffer.interval else { return nil }
return skipOffer.interval return skipOffer.interval
} }
var skipOfferRemainingFraction: Double {
guard segmentToSkip != nil, let skipOffer else { return 0 }
return min(1, max(0, (skipOffer.deadline - skipClock()) / 10))
}
private func updateSegmentSkip() { private func updateSegmentSkip() {
guard let interval = currentSkipInterval else { guard let interval = currentSkipInterval else {
@@ -170,16 +176,22 @@ final class PlayerModel: ObservableObject {
private var reportingEnabled = false private var reportingEnabled = false
private var reportingErrorShown = false private var reportingErrorShown = false
private var reportingErrorBuffer = [CChar]() private var reportingErrorBuffer = [CChar]()
private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo
private let miruroResolver: @MainActor (String, Int) async throws -> ResolvedSource
private var reportingErrorMessage: String { private var reportingErrorMessage: String {
reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) } reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
} }
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(), init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
progressFile: String? = nil, progressFile: String? = nil, streamQueue: StreamQueue? = nil,
youtubeResolver: @escaping @MainActor (URL, Int?) async throws -> YouTubeVideo = { try await YouTubeResolver.resolve($0, height: $1) },
miruroResolver: @escaping @MainActor (String, Int) async throws -> ResolvedSource = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) },
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }, qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) { skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
self.qualityClock = qualityClock self.qualityClock = qualityClock
self.skipClock = skipClock self.skipClock = skipClock
self.youtubeResolver = youtubeResolver
self.miruroResolver = miruroResolver
keyBindings = configuration.keys keyBindings = configuration.keys
compactTitleProgress = configuration.compactTitleProgress compactTitleProgress = configuration.compactTitleProgress
automaticYouTubeQuality = configuration.automaticQuality automaticYouTubeQuality = configuration.automaticQuality
@@ -206,6 +218,25 @@ final class PlayerModel: ObservableObject {
progressInitializationError = "Could not create the media player to initialize progress reporting." progressInitializationError = "Could not create the media player to initialize progress reporting."
} }
} }
if let streamQueue {
reportingErrorBuffer = [CChar](repeating: 0, count: 512)
for entry in streamQueue.entries {
guard let player else {
progressInitializationError = "Could not create the media player to initialize queue reports."
break
}
let registered = entry.identity.withCString { identity in
entry.progressFile.path.withCString { path in
player_register_progress_file(player, identity, path, &reportingErrorBuffer)
}
}
if !registered {
progressInitializationError = reportingErrorMessage
break
}
reportingEnabled = true
}
}
compactWindow.onChange = { [weak self] in compactWindow.onChange = { [weak self] in
guard let self else { return } guard let self else { return }
if !self.compactWindow.isTransitioning { if !self.compactWindow.isTransitioning {
@@ -349,9 +380,23 @@ final class PlayerModel: ObservableObject {
} }
showsYouTubeInput = false showsYouTubeInput = false
clearQueue() clearQueue()
resolveStream(stream, change: .open)
}
func openStreamQueue(_ streamQueue: StreamQueue) {
guard canOpen, progressInitializationError == nil else { return }
queueCastDevice = nil
queue.replaceStreams(streamQueue.entries.map { (url: $0.url, label: $0.label) })
showsQueue = queue.items.count > 1
showsYouTubeInput = false
openQueueSelection(keepingReceiver: false)
}
private func resolveStream(_ stream: StreamURL, change: MediaChange) {
replaceMetadata(for: nil)
switch stream { switch stream {
case .youtube(let url): resolveYouTube(url, height: nil) case .youtube(let url): resolveYouTube(url, height: nil, change: change)
case .miruro(let mediaID, let episode): resolveMiruro(mediaID: mediaID, episode: episode) case .miruro(let mediaID, let episode): resolveMiruro(mediaID: mediaID, episode: episode, change: change)
} }
} }
@@ -427,7 +472,7 @@ final class PlayerModel: ObservableObject {
resolveYouTube(url, height: height, change: change) resolveYouTube(url, height: height, change: change)
} }
private func resolveMiruro(mediaID: String, episode: Int) { private func resolveMiruro(mediaID: String, episode: Int, change: MediaChange = .open) {
guard let handle else { return } guard let handle else { return }
if castDevice != nil { stopCasting(resume: false) } if castDevice != nil { stopCasting(resume: false) }
cancelStreamResolution() cancelStreamResolution()
@@ -438,13 +483,17 @@ final class PlayerModel: ObservableObject {
return return
} }
objectWillChange.send() objectWillChange.send()
let request = transition.beginResolution(.open) let request = transition.beginResolution(change)
systemPlayback?.update() systemPlayback?.update()
commandError = nil commandError = nil
revealControls() revealControls()
let resolver = miruroResolver
let task = Task { [weak self] in let task = Task { [weak self] in
do { do {
let source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode) let source = try await resolver(mediaID, episode)
guard source.identity == "miruro:\(mediaID):\(episode)" else {
throw CocoaError(.fileReadCorruptFile)
}
guard !Task.isCancelled, let self else { return } guard !Task.isCancelled, let self else { return }
self.objectWillChange.send() self.objectWillChange.send()
guard let change = self.transition.finishResolution(request) else { return } guard let change = self.transition.finishResolution(request) else { return }
@@ -475,9 +524,11 @@ final class PlayerModel: ObservableObject {
systemPlayback?.update() systemPlayback?.update()
commandError = nil commandError = nil
revealControls() revealControls()
let resolver = youtubeResolver
let task = 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 resolver(url, height)
guard video.webpageURL == url else { throw CocoaError(.fileReadCorruptFile) }
guard !Task.isCancelled, let self, let handle = self.handle else { return } guard !Task.isCancelled, let self, let handle = self.handle else { return }
self.objectWillChange.send() self.objectWillChange.send()
guard var change = self.transition.finishResolution(request) else { return } guard var change = self.transition.finishResolution(request) else { return }
@@ -544,6 +595,7 @@ final class PlayerModel: ObservableObject {
self.fileName = title self.fileName = title
self.window?.title = title self.window?.title = title
self.casting.updateTitle(title) self.casting.updateTitle(title)
if self.queue.currentID != nil { self.queue.updateCurrentLabel(title) }
self.systemPlayback?.update() self.systemPlayback?.update()
} }
self.metadataTask = nil self.metadataTask = nil
@@ -635,6 +687,7 @@ final class PlayerModel: ObservableObject {
youtubeHeight = nil youtubeHeight = nil
youtubeQuality = nil youtubeQuality = nil
} }
if let fileName, !source.url.isFileURL { queue.updateCurrentLabel(fileName) }
selectSubtitle(nil) selectSubtitle(nil)
if case .resolved(let resolved) = source { if case .resolved(let resolved) = source {
subtitleTracks = resolved.subtitles subtitleTracks = resolved.subtitles
@@ -706,7 +759,13 @@ final class PlayerModel: ObservableObject {
queuePlaybackRevision = nil queuePlaybackRevision = nil
queueCompletionHandled = true queueCompletionHandled = true
cancelStreamResolution() cancelStreamResolution()
openMedia(.file(url), change: .queue(playing: receiver == nil)) let change = MediaChange.queue(playing: receiver == nil)
if url.isFileURL {
openMedia(.file(url), change: change)
} else {
do { resolveStream(try StreamURL.parse(url.absoluteString), change: change) }
catch { commandError = error.localizedDescription }
}
queueCastDevice = receiver queueCastDevice = receiver
} }
@@ -2905,15 +2964,15 @@ private struct PlayerContent: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Remove from Queue") .help("Remove from Queue")
.accessibilityLabel("Remove \(item.url.lastPathComponent) from Queue") .accessibilityLabel("Remove \(item.displayName) from Queue")
} }
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
Text(item.url.deletingPathExtension().lastPathComponent) Text(item.url.isFileURL ? item.url.deletingPathExtension().lastPathComponent : item.displayName)
.font(.system(size: 13, weight: .medium)) .font(.system(size: 13, weight: .medium))
.lineLimit(2) .lineLimit(2)
.lineSpacing(2) .lineSpacing(2)
.truncationMode(.middle) .truncationMode(.middle)
if !item.url.pathExtension.isEmpty { if item.url.isFileURL && !item.url.pathExtension.isEmpty {
Text(item.url.pathExtension.uppercased()) Text(item.url.pathExtension.uppercased())
.font(.system(size: 10, weight: .medium)) .font(.system(size: 10, weight: .medium))
.tracking(0.6) .tracking(0.6)
@@ -2933,7 +2992,7 @@ private struct PlayerContent: View {
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { model.selectQueueItem(item.id) } .onTapGesture { model.selectQueueItem(item.id) }
.help(item.url.path + "\nClick to play; drag to reorder.") .help((item.url.isFileURL ? item.url.path : item.displayName) + "\nClick to play; drag to reorder.")
.contextMenu { .contextMenu {
Button("Play") { model.selectQueueItem(item.id) } Button("Play") { model.selectQueueItem(item.id) }
Button("Remove from Queue") { model.removeQueueItem(item.id) } Button("Remove from Queue") { model.removeQueueItem(item.id) }
@@ -3276,6 +3335,7 @@ private struct PlayerContent: View {
.allowsHitTesting(!model.isCompact && model.controlsVisible) .allowsHitTesting(!model.isCompact && model.controlsVisible)
.accessibilityHidden(model.isCompact || !model.controlsVisible) .accessibilityHidden(model.isCompact || !model.controlsVisible)
} }
ZStack(alignment: .topTrailing) {
if let interval = model.segmentToSkip { if let interval = model.segmentToSkip {
Button { Button {
model.skipSegment() model.skipSegment()
@@ -3289,11 +3349,30 @@ private struct PlayerContent: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundStyle(Color(SunsetPalette.text)) .foregroundStyle(Color(SunsetPalette.text))
.background(PlayerGlass(cornerRadius: 8).clipShape(RoundedRectangle(cornerRadius: 8))) .background {
ZStack(alignment: .leading) {
PlayerGlass(cornerRadius: 8)
TimelineView(.animation(minimumInterval: reduceMotion ? 1 : 1.0 / 30)) { _ in
GeometryReader { geometry in
let remaining = model.skipOfferRemainingFraction
Color(SunsetPalette.accent).opacity(0.25)
.frame(width: geometry.size.width *
(reduceMotion ? ceil(remaining * 10) / 10 : remaining))
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 8))
.allowsHitTesting(false)
.accessibilityHidden(true)
}
.transition(reduceMotion ? .identity : .opacity.combined(with: .move(edge: .top)))
}
}
.padding(.trailing, model.isCompact ? 8 : 16) .padding(.trailing, model.isCompact ? 8 : 16)
.padding(.top, 44) .padding(.top, 44)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
} .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: model.segmentToSkip?.kind)
.allowsHitTesting(model.segmentToSkip != nil)
if dropTargeted { if dropTargeted {
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color(SunsetPalette.accent), lineWidth: 2) .strokeBorder(Color(SunsetPalette.accent), lineWidth: 2)
@@ -3467,6 +3546,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
if let index = arguments.firstIndex(of: "--source-file"), index + 1 < arguments.count { if let index = arguments.firstIndex(of: "--source-file"), index + 1 < arguments.count {
PlayerModel.shared.openResolvedSource(URL(fileURLWithPath: arguments[index + 1])) PlayerModel.shared.openResolvedSource(URL(fileURLWithPath: arguments[index + 1]))
} }
if let streamQueue = PlayerModel.startupStreamQueue {
PlayerModel.shared.openStreamQueue(streamQueue)
}
} }
// Let SwiftUI create the initial window for a Finder/document launch. // Let SwiftUI create the initial window for a Finder/document launch.
@@ -3521,6 +3603,24 @@ public func terebiAppMain() {
exit(78) exit(78)
} }
let arguments = Array(CommandLine.arguments.dropFirst()) let arguments = Array(CommandLine.arguments.dropFirst())
if let index = arguments.firstIndex(of: "--queue-file") {
do {
guard index + 1 < arguments.count, arguments[index + 1].hasPrefix("/"),
arguments.filter({ $0 == "--queue-file" }).count == 1,
!arguments.contains("--progress-file"), !arguments.contains("--source-file"),
!arguments.contains("--open-media") else {
throw CocoaError(.fileReadCorruptFile)
}
PlayerModel.startupStreamQueue = try StreamQueue.load(from: URL(fileURLWithPath: arguments[index + 1]))
_ = NSApplication.shared
if let error = PlayerModel.shared.progressInitializationError {
throw NSError(domain: "Terebi.Queue", code: 73, userInfo: [NSLocalizedDescriptionKey: error])
}
} catch {
FileHandle.standardError.write(Data("terebi: \(error.localizedDescription)\n".utf8))
exit(73)
}
}
if let index = arguments.firstIndex(of: "--progress-file") { if let index = arguments.firstIndex(of: "--progress-file") {
guard index + 1 < arguments.count, arguments[index + 1].hasPrefix("/"), guard index + 1 < arguments.count, arguments[index + 1].hasPrefix("/"),
arguments.filter({ $0 == "--progress-file" }).count == 1 else { arguments.filter({ $0 == "--progress-file" }).count == 1 else {
+2 -2
View File
@@ -293,12 +293,12 @@ ProgressReports::ProgressReports(const std::string &path)
: single(std::make_unique<ProgressReporter>(path)), active(single.get()) {} : single(std::make_unique<ProgressReporter>(path)), active(single.get()) {}
void ProgressReports::registerFile(const std::string &identity, const std::string &path) { void ProgressReports::registerFile(const std::string &identity, const std::string &path) {
std::lock_guard lock(mutex);
if (single || !ProgressReporter::validMediaIdentity(identity) || entries.size() >= 4096 || if (single || !ProgressReporter::validMediaIdentity(identity) || entries.size() >= 4096 ||
path.empty() || path.front() != '/' || path.find('\0') != std::string::npos || path.empty() || path.front() != '/' || path.find('\0') != std::string::npos ||
entries.contains(identity)) entries.contains(identity))
throw std::runtime_error("Invalid or duplicate queue progress registration"); throw std::runtime_error("Invalid or duplicate queue progress registration");
for (const auto &[key, entry] : entries) if (!paths.insert(path).second) throw std::runtime_error("Duplicate queue progress report path");
if (entry.path == path) throw std::runtime_error("Duplicate queue progress report path");
entries.emplace(identity, Entry{path, nullptr}); entries.emplace(identity, Entry{path, nullptr});
} }
+2
View File
@@ -9,6 +9,7 @@
#include <string> #include <string>
#include <map> #include <map>
#include <memory> #include <memory>
#include <set>
#include <sys/types.h> #include <sys/types.h>
#include <string_view> #include <string_view>
#include <thread> #include <thread>
@@ -82,6 +83,7 @@ private:
struct Entry { std::string path; std::unique_ptr<ProgressReporter> reporter; }; struct Entry { std::string path; std::unique_ptr<ProgressReporter> reporter; };
std::unique_ptr<ProgressReporter> single; std::unique_ptr<ProgressReporter> single;
std::map<std::string, Entry> entries; std::map<std::string, Entry> entries;
std::set<std::string> paths;
ProgressReporter *active = nullptr; ProgressReporter *active = nullptr;
mutable std::mutex mutex; mutable std::mutex mutex;
std::string failure; std::string failure;
+1 -1
View File
@@ -22,7 +22,7 @@
- audited SDK 27.0 / Swift 6.4 with the macOS 15 minimum retained; migrated Cast discovery to NWBrowser and production activation to activate(). Real Bonjour identity/name/update/order/deduplication/removal/restart and routable-endpoint checks, loopback Cast transitions, focused startup-error alerts and a cooperating-launcher production smoke for cold start/CLI reuse/document handoff/keyboard playback passed. The existing full CLI snapshot-harness timeout remains; macOS 15 runtime and physical-Cast verification remain unverified. Observation, glass and the existing native bridges were left unchanged. - audited SDK 27.0 / Swift 6.4 with the macOS 15 minimum retained; migrated Cast discovery to NWBrowser and production activation to activate(). Real Bonjour identity/name/update/order/deduplication/removal/restart and routable-endpoint checks, loopback Cast transitions, focused startup-error alerts and a cooperating-launcher production smoke for cold start/CLI reuse/document handoff/keyboard playback passed. The existing full CLI snapshot-harness timeout remains; macOS 15 runtime and physical-Cast verification remain unverified. Observation, glass and the existing native bridges were left unchanged.
- [x] fix the readme. it's currently bloated with irrelevant implementation detail and other such nonsense. it should do the job of a readme only, nothing more. - [x] fix the readme. it's currently bloated with irrelevant implementation detail and other such nonsense. it should do the job of a readme only, nothing more.
- [x] add a skip button when current playback is inside either an opening or an ending segment. - [x] add a skip button when current playback is inside either an opening or an ending segment.
- Skip Opening/Ending uses validated metadata, seeks to the segment end and disappears after ten seconds (including while paused) or on leaving the segment; ignored segments stay dismissed until close/media replacement. Native HLS, exact-EOF, controlled-clock dismissal and loopback Cast checks passed. Button placement and physical-Cast verification remain unverified; rapid play/seek probes encountered Core Audio startup errors on Bluetooth output. - Skip Opening/Ending uses validated metadata, seeks to the segment end and disappears after ten seconds (including while paused) or on leaving the segment; ignored segments stay dismissed until close/media replacement. A draining background shows remaining offer time, with 200 ms fade/slide transitions; Reduce Motion uses stepped countdown updates without transitions. Native HLS, exact-EOF, controlled-clock countdown/dismissal and loopback Cast checks passed. Button placement, animation feel and physical-Cast verification remain unverified; rapid play/seek probes encountered Core Audio startup errors on Bluetooth output.
- [ ] improve: subtitles. - [ ] improve: subtitles.
- decrease padding on captions - decrease padding on captions
- decrease font size of captions by 10 percent - decrease font size of captions by 10 percent
+26
View File
@@ -25,6 +25,32 @@ The slug `episode` is deliberately non-identifying. Use the returned episode num
Create a private reporting-session directory (`0700`) and supply a **nonexistent** progress-file destination. Terebi exclusively creates the report (`0600`), rejecting existing destinations and symlinks. CLI success means macOS accepted the launch, not that playback succeeded. Tubular consumes reporting independently of launcher lifetime; launcher exit, a final record or Tubular shutdown does not establish that the player stopped. Preserve retained sessions and restart/partial-line recovery. Create a private reporting-session directory (`0700`) and supply a **nonexistent** progress-file destination. Terebi exclusively creates the report (`0600`), rejecting existing destinations and symlinks. CLI success means macOS accepted the launch, not that playback succeeded. Tubular consumes reporting independently of launcher lifetime; launcher exit, a final record or Tubular shutdown does not establish that the player stopped. Preserve retained sessions and restart/partial-line recovery.
### Ordered stream queues
```sh
terebi --new-instance --queue-file '/absolute/private/session/queue.json'
```
The manifest is bounded to 8 MiB, version 1, and 1–4096 entries in playback order:
```json
{
"version": 1,
"items": [
{"url": "https://www.youtube.com/watch?v=jNQXAC9IVRw", "progress_file": "/absolute/private/youtube-session/progress.jsonl"},
{"url": "https://barelystarted.miruro.tv/watch/OPAQUE_ID/episode?ep=1", "progress_file": "/absolute/private/episode-session/progress.jsonl"}
]
}
```
Use canonical YouTube video pages or supported Miruro episode pages, never expiring source addresses. Stable identities must be unique even across alternate YouTube forms or different Miruro slugs. Reports must have distinct absolute nonexistent destinations, each under a canonical, nonsymlink, user-owned `0700` directory. Tubular retains each item's existing identity registration/session and stores the manifest in the first session. The CLI validates the complete document before launching; the GUI validates it again before opening any media. Invalid queues fail rather than falling back to a single stream. Queue launch requires `--new-instance`, accepts `--no-config`, and cannot combine with `--url`, `--source-file` or `--progress-file`.
The supplied order is preserved (local-file batches still use natural filename order). Existing sidebar reorder/remove/play and previous/next navigation apply. Natural end advances without clearing the queue. Sources resolve only when selected, resolve afresh on revisits, and canceled/stale results cannot replace a newer selection. Labels start with descriptive page information and update from resolved titles/optional metadata.
Each report is immutably bound to its stable identity in the native decoder. Switching entries drains/closes the previous writer before beginning the next; revisits append only after verifying the original private report inode, owner, permissions and link count. An unrelated or replaced file is never reopened as an owned session, and an unregistered identity is never written to the previously selected report. Only the active item owns a running writer/open report descriptor. A reporting failure is shown separately without rerouting facts or changing acknowledged playback/completion semantics. Single-stream `--progress-file` remains unchanged.
Runnable queue checks: `make check-cli check-queue check-reporting check-resolved`. The existing `check-resolved` local HTTP/HLS/VTT harness also drives a real native `PlayerModel` through a mixed YouTube/Miruro queue with injected fixture resolution, stale-result cancellation, manual navigation, natural-end advancement, fresh revisits/resume and isolated v1/v2 reports. Run `TEREBI_CHECK_STREAM_QUEUE_ONLY=1 make check-startup` for focused actual CLI→LaunchServices→GUI queue delivery, native decode, manual previous/next, natural-end advancement and per-item report revisits using the existing bundled-extractor/local-media fixture. These checks require no browser or live streaming service. Full `make check-startup` includes this queue scenario too. These additions have not yet been executed as part of this change.
### Optional native metadata ### Optional native metadata
The native resolver requests watch-route data and safely follows bounded same-origin, same-ID, same-episode canonicalization envelopes, including HTTP-200 redirects. It accepts JSON and newline-delimited `text/sveltekit-data`, decoding only the needed response-local tables and episode promise. Bodies and traversal are bounded; unrelated source promises are not used for playback. The native resolver requests watch-route data and safely follows bounded same-origin, same-ID, same-episode canonicalization envelopes, including HTTP-200 redirects. It accepts JSON and newline-delimited `text/sveltekit-data`, decoding only the needed response-local tables and episode promise. Bodies and traversal are bounded; unrelated source promises are not used for playback.