From 880b596765a2e60994a90bc1fa655792fe8d6f89 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sat, 17 Oct 2026 00:03:02 +0200 Subject: [PATCH] perf: coalesce superseded queued playback seeks --- Checks/cast-playback.swift | 3 +- Checks/cast-session.swift | 38 ++++++++++++++-- Checks/seek-queue.cpp | 81 +++++++++++++++++++++++++++++++++++ Checks/system-playback.swift | 2 +- Makefile | 10 ++++- README.md | 11 +++++ Sources/App/CastSession.swift | 39 +++++++++++++---- Sources/Core/player.cpp | 9 +++- TODO.md | 2 +- 9 files changed, 176 insertions(+), 19 deletions(-) create mode 100644 Checks/seek-queue.cpp diff --git a/Checks/cast-playback.swift b/Checks/cast-playback.swift index 95ca393..7e6184b 100644 --- a/Checks/cast-playback.swift +++ b/Checks/cast-playback.swift @@ -200,7 +200,8 @@ struct CastPlaybackCheck { require(!model.canStepFrame && model.bufferedRange == nil, "local decoder-only controls are not exposed while casting") require(model.setPlayback(playing: true), "remote play accepted") try await until(model, "receiver play acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil } - require(model.seekRelative(5) && model.seekRelative(10), "rapid remote skips accumulate before acknowledgement") + for _ in 0..<64 { require(model.seekRelative(0.125), "rapid remote skips accumulate beyond queue capacity") } + require(model.seekRelative(7), "final remote skip retains accumulated target") try await until(model, "receiver seek acknowledged") { !model.state.seek_pending && abs(model.state.position - 24.25) < 0.15 } require(model.setPlayback(playing: false), "remote pause accepted") try await until(model, "receiver pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil } diff --git a/Checks/cast-session.swift b/Checks/cast-session.swift index 7ecd593..0336801 100644 --- a/Checks/cast-session.swift +++ b/Checks/cast-session.swift @@ -399,6 +399,39 @@ struct CastSessionCheck { language: "en", label: "English")]) } + static func seekCoalescing(identity: SecIdentity) async throws { + let peer = try Receiver(identity: identity) + try await peer.start() + defer { peer.stop() } + let clock = CastCheckClock() + let session = CastSession(sleep: { try await clock.sleep($0) }) + var status: CastStatus? + var errors: [String] = [] + session.onStatus = { status = $0 } + session.onError = { errors.append($0) } + try await session.connect(to: peer.device) + try await session.load(media(), title: "Seek burst", position: 0, playing: false, subtitle: nil) + let before = peer.commands.count + for target in 1...64 { require(session.seek(Double(target)), "Seek burst exceeds ordinary queue capacity") } + require(session.setPlaying(true), "PLAY separates seek runs") + for target in 65...96 { require(session.seek(Double(target)), "Second seek run accepted") } + require(session.setPlaying(false), "PAUSE separates seek runs") + for target in (48...63).reversed() { require(session.seek(Double(target)), "Reverse seek run accepted") } + require(status?.seekPending == true, "Latest queued seek remains unacknowledged") + try await until("Final seek and playback acknowledged") { + status?.position == 48 && status?.seekPending == false && + status?.playing == false && status?.playbackPending == false + } + let controls = peer.commands.dropFirst(before).filter { ["SEEK", "PLAY", "PAUSE"].contains($0.0) } + let targets = controls.filter { $0.0 == "SEEK" }.compactMap { $0.1["currentTime"] as? Double } + require(targets == [64, 96, 48] || targets == [1, 64, 96, 48], + "Only unsent adjacent seeks are replaced") + require(Array(controls.suffix(5)).map(\.0) == ["SEEK", "PLAY", "SEEK", "PAUSE", "SEEK"], + "Playback controls remain seek ordering barriers") + require(errors.isEmpty && peer.failure == nil, "Superseded requests do not fail or leak queue capacity") + session.disconnect() + } + static func behavior(identity: SecIdentity) async throws { let peer = try Receiver(identity: identity) peer.muted = true @@ -756,10 +789,6 @@ struct CastSessionCheck { let load = peer.commands.first { $0.0 == "LOAD" }!.1 require(load["currentTime"] as? Double == 9.25 && load["autoplay"] as? Bool == false, "Acknowledged paused handoff") require(peer.relayVerified, "Receiver fetches real relay HLS and media bytes") - let controls = peer.commands.filter { ["PLAY", "SEEK", "PAUSE", "STOP"].contains($0.0) } - require(controls.map(\.0) == ["PLAY", "SEEK", "SEEK", "PAUSE", "STOP"], "Integrated controls ordered") - require(controls[1].1["currentTime"] as? Double == 14.25 && - controls[2].1["currentTime"] as? Double == 24.25, "Rapid skips accumulate from the pending target") } static func interruptedPlayback(identity: SecIdentity, executable: String, file: String) async throws { @@ -886,6 +915,7 @@ struct CastSessionCheck { print("PASS: interrupted Cast connect/LOAD, rejection, replacement/close, latest intent and acknowledged-position recovery") } else { try await behavior(identity: identity) + try await seekCoalescing(identity: identity) try await launchRegressions(identity: identity) try await volumeRegressions(identity: identity) try await failures(identity: identity) diff --git a/Checks/seek-queue.cpp b/Checks/seek-queue.cpp new file mode 100644 index 0000000..4fc1c59 --- /dev/null +++ b/Checks/seek-queue.cpp @@ -0,0 +1,81 @@ +// Include the implementation to inspect pending commands without adding a public test API. +#include "../Sources/Core/player.cpp" +#include + +static void require(bool condition, const char *message) { + if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); } +} + +int main(int argc, char **argv) { + Player player; + // Join before submitting anything: the queue exercise has no executing worker, + // media I/O, device clock or scheduling-dependent queue depth. + player.shutdown(); + player.stopping.store(false); + auto reset = [&] { + player.commands.clear(); + player.state = {}; + player.state.status = PLAYER_PAUSED; + player.state.can_seek = true; + player.state.has_video = true; + player.publishedEpoch = player.requestedEpoch.load(); + }; + reset(); + player.state.position = 7; + player.state.buffered_start = 6; + player.state.buffered_end = 9; + constexpr unsigned requests = 100000; + unsigned accepted = 0; + for (unsigned i = 0; i < requests; ++i) + accepted += player_seek(&player, double(i)); + std::printf("Native pending seek burst: requested=%u accepted=%u queued=%zu latest=%.0f\n", + requests, accepted, player.commands.size(), player.commands.back().seconds); + if (argc == 2 && std::strcmp(argv[1], "--measure") == 0) return 0; + require(argc == 1, "only --measure is supported"); + require(accepted == requests && player.commands.size() == 1 && + player.commands.back().seconds == requests - 1, + "every superseding seek is accepted and only the latest target remains"); + require(player.state.seek_pending && player.state.revision == requests && + player.state.buffered_start == 0 && player.state.buffered_end == 0 && + player.state.position == 7 && player.state.status == PLAYER_PAUSED, + "each accepted seek publishes pending and invalidates buffering, not acknowledged position"); + + reset(); + for (std::size_t i = 0; i < commandLimit - 1; ++i) + require(player_pause(&player), "fill queue with command barriers"); + require(player_seek(&player, 1) && player_seek(&player, 2), + "latest queued seek can be replaced even at capacity"); + require(player.commands.size() == commandLimit && player.commands.back().seconds == 2 && + !player_play(&player), "coalescing does not relax capacity for other commands"); + + for (Action barrier : {Action::Play, Action::Pause, Action::Step, + Action::Open, Action::Close, Action::Remote}) { + reset(); + require(player_seek(&player, 1), "seek before barrier"); + Command command{barrier, {}}; + command.direction = 1; + require(player.enqueue(std::move(command)) && player_seek(&player, 2), "seek after barrier"); + require(player.commands.size() == 3 && player.commands[0].action == Action::Seek && + player.commands[0].seconds == 1 && player.commands[1].action == barrier && + player.commands[2].action == Action::Seek && player.commands[2].seconds == 2, + "no seek replacement crosses another action"); + if (barrier == Action::Open || barrier == Action::Close) + require(player.commands[0].epoch != player.commands[2].epoch, + "timeline replacement separates seek epochs"); + } + reset(); + require(player_seek(&player, 1), "seek before API error boundary"); + player.apiError("queue check boundary"); + require(player_seek(&player, 2) && player.commands.size() == 2 && + player.commands[0].epoch != player.commands[1].epoch, + "adjacent seeks from different epochs remain distinct"); + + reset(); + require(player_seek(&player, 1), "first seek queued"); + auto executing = std::move(player.commands.front()); + player.commands.pop_front(); // The worker removes a seek before executing it. + require(player_seek(&player, 2) && player_seek(&player, 3) && + executing.seconds == 1 && player.commands.size() == 1 && player.commands.back().seconds == 3, + "replacement never changes a seek already removed for execution"); + std::puts("PASS native seek burst/capacity/publication/action barriers/epochs/executing seek"); +} diff --git a/Checks/system-playback.swift b/Checks/system-playback.swift index 2af1568..6252283 100644 --- a/Checks/system-playback.swift +++ b/Checks/system-playback.swift @@ -170,7 +170,7 @@ struct SystemPlaybackChecks { require(commands.nextTrackCommand.isEnabled, "seekability changes re-enable commands even when playback metadata is unchanged") - require(model.seekRelative(1), "relative seek accepted") + for _ in 0..<64 { require(model.seekRelative(1.0 / 64), "rapid native skips retain accumulated target") } until("paused seek acknowledged") { model.state.status == PLAYER_PAUSED && !model.state.seek_pending && abs(model.state.position - settledPosition - 1) < 0.05 diff --git a/Makefile b/Makefile index 2addf62..e9eabf2 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ CLI := $(APP)/Contents/MacOS/terebi-cli BUNDLE_STAMP := $(BUILD_DIR)/bundle.stamp SIGNING_IDENTITY ?= - -.PHONY: all release install check-dependencies run check check-config check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast +.PHONY: all release install check-dependencies run check check-seek-queue check-config check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast all: $(BUILD_DIR)/terebi $(NATIVE_STAMP): Scripts/dependencies.py Dependencies.lock.json @@ -105,7 +105,13 @@ run: all $(BUILD_DIR)/playback-check: Checks/playback.mm $(OBJECTS) Makefile | check-dependencies $(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@ -check: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast +$(BUILD_DIR)/seek-queue-check: Checks/seek-queue.cpp Sources/Core/player.cpp Sources/Core/player.h Sources/Core/player_macos.h Sources/Core/output.hpp Sources/Core/network_input.hpp Sources/Core/network_policy.hpp Sources/Core/progress_reporter.hpp $(filter-out $(BUILD_DIR)/player.o,$(OBJECTS)) Makefile | check-dependencies + $(CXX) $(CXXFLAGS) $< $(filter-out $(BUILD_DIR)/player.o,$(OBJECTS)) $(FF_LIBS) $(FRAMEWORKS) -o $@ + +check-seek-queue: $(BUILD_DIR)/seek-queue-check + $(BUILD_DIR)/seek-queue-check + +check: check-dependencies $(BUILD_DIR)/playback-check check-seek-queue check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast $(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures check-fixtures: | check-dependencies diff --git a/README.md b/README.md index 69493e3..be2d28a 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,17 @@ In normal mode, the right-hand playback control group is captions, then compact Accepted relative seeks, including keyboard shortcuts, preserve control visibility instead of revealing hidden chrome. Seek failures still reveal the error; dragging the timeline continues to show the controls. +Adjacent, not-yet-started seeks keep only the latest absolute target in the native worker queue and Cast socket-write queue. Relative skips still accumulate before submission. Executing/sent seeks are untouched; play/pause, frame steps, media changes and other queued commands remain ordering barriers. Superseded Cast requests release their deadlines and pending-request slots without reporting an error. + +Measured with local, deterministic bursts (not provider/CDN latency): + +| Scenario | Before | After | +| --- | --- | --- | +| 24 accepted Cast seeks in one actor turn, real TLS loopback receiver | 24 receiver SEEK commands | 2 receiver SEEK commands | +| 100,000 native seek submissions with the worker held stopped | 32 accepted; 32 queued; last accepted target 31 | 100,000 accepted; 1 queued; final target 99,999 | + +The Cast burst acknowledged the same final position, 24 seconds, without errors in both runs. `make check-seek-queue` covers native queue capacity, command/timeline barriers and acknowledged-state preservation; Cast protocol and app-model checks cover burst coalescing, play/pause barriers and accumulated relative skips. These measurements establish reduced queued work, not cold-start, cached/uncached seek latency or sustained-playback gains; those benchmarks remain pending. + Normal and compact titlebars have no full-width glass strip or separator: content extends beneath AppKit’s transparent titlebar using `fullSizeContentView`, `titlebarAppearsTransparent`, and `titlebarSeparatorStyle = .none`. Titles retain their rounded HUD-glass backdrop. In normal mode, pin, Cast, and settings share a separate container matching the title's 24-point height, horizontal padding, vertical inset, tint, outline and corner radius. Cast sits between pin and settings; all three retain 24-point click targets. That button container is not shown in compact mode; only the compact title and adjacent progress occupy the top row with 8-point outer insets. Native traffic lights and window dragging are preserved. Compact titles default to ` · <position> / <duration> <unit>`, using acknowledged playback position rather than slider previews. Total duration selects the format: below one minute, `12 / 45 s`; from one minute to below one hour, `05:12 / 42:08 m`; from one hour onward, `01:23 / 02:10 h`. Values truncate to the displayed precision. Set `[ui].compact_title_progress = "percentage"` to append only a whole percentage number (`<title> · 59`, without a percent sign), or `"none"` for title-only display. Percentages truncate and stay within 0–100. Loading, failed, live, unknown-duration and closed media have no progress suffix. Normal-mode titles remain unchanged. diff --git a/Sources/App/CastSession.swift b/Sources/App/CastSession.swift index ef7f39b..8741b8d 100644 --- a/Sources/App/CastSession.swift +++ b/Sources/App/CastSession.swift @@ -255,6 +255,11 @@ final class CastSession { let deadline: Task<Void, Never> } + private struct Write { + let data: Data + let seek: Int? + } + private let makeConnection: (CastDevice) -> NWConnection private let requestTimeout: Duration private let loadTimeout: Duration @@ -270,7 +275,7 @@ final class CastSession { private var awaitingPong = false private var nextRequest = 0 private var pending: [Int: Pending] = [:] - private var writes: [Data] = [] + private var writes: [Write] = [] private var writing = false private var launchRequest: Int? private var appTransport: String? @@ -525,7 +530,17 @@ final class CastSession { operation: String, generation: UUID? = nil, timeout: Duration? = nil, id suppliedID: Int? = nil, completion: @escaping ([String: Any]?, CastError?) -> Void) throws -> Int { - guard pending.count < 32 else { throw CastError.queueFull } + // Only the tail is replaceable: it has not reached the socket, and every + // intervening control or protocol frame remains an ordering barrier. + let superseded: Int? + if command["type"] as? String == "SEEK", namespace == CastWire.media, + let id = writes.last?.seek, let previous = pending[id], + previous.generation == generation, previous.destination == destination { + superseded = id + } else { + superseded = nil + } + guard pending.count < 32 || superseded != nil else { throw CastError.queueFull } let id = suppliedID ?? allocateRequest() var command = command command["requestId"] = id @@ -537,8 +552,11 @@ final class CastSession { } pending[id] = Pending(namespace: namespace, destination: destination, operation: operation, generation: generation, completion: completion, deadline: deadline) - do { try send(command, namespace: namespace, destination: destination) } + do { + try send(command, namespace: namespace, destination: destination, replacing: superseded) + } catch { pending.removeValue(forKey: id)?.deadline.cancel(); throw error } + if let superseded { complete(superseded, json: nil, error: .cancelled) } return id } @@ -553,12 +571,17 @@ final class CastSession { for id in ids { complete(id, json: nil, error: .cancelled) } } - private func send(_ json: [String: Any], namespace: String, destination: String) throws { + private func send(_ json: [String: Any], namespace: String, destination: String, + replacing: Int? = nil) throws { guard connection != nil else { throw CastError.disconnected } - guard writes.count < 32 else { throw CastError.queueFull } + guard writes.count < 32 || replacing != nil else { throw CastError.queueFull } let payload = try JSONSerialization.data(withJSONObject: json) - writes.append(try CastWire.frame(.init(source: source, destination: destination, - namespace: namespace, payload: payload))) + let write = Write(data: try CastWire.frame(.init(source: source, destination: destination, + namespace: namespace, payload: payload)), + seek: namespace == CastWire.media && json["type"] as? String == "SEEK" ? + json["requestId"] as? Int : nil) + if replacing != nil { writes[writes.count - 1] = write } + else { writes.append(write) } flush() } @@ -567,7 +590,7 @@ final class CastSession { writing = true let data = writes.removeFirst() let id = connectionID - socket.send(content: data, completion: .contentProcessed { [weak self] error in + socket.send(content: data.data, completion: .contentProcessed { [weak self] error in Task { @MainActor in guard let self, self.connectionID == id else { return } self.writing = false diff --git a/Sources/Core/player.cpp b/Sources/Core/player.cpp index f3c3337..c1ef6ca 100644 --- a/Sources/Core/player.cpp +++ b/Sources/Core/player.cpp @@ -234,8 +234,13 @@ struct Player { return true; } } - if (commands.size() >= commandLimit) return false; - commands.push_back(std::move(command)); + if (command.action == Action::Seek && !commands.empty() && + commands.back().action == Action::Seek && commands.back().epoch == command.epoch) { + commands.back().seconds = command.seconds; + } else { + if (commands.size() >= commandLimit) return false; + commands.push_back(std::move(command)); + } if (newTimeline) requestedEpoch.store(commands.back().epoch, std::memory_order_relaxed); if (commands.back().action == Action::Open) { const auto revision = state.revision + 1; diff --git a/TODO.md b/TODO.md index 952cbe9..a0fc22f 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,5 @@ - [ ] evaluate playback optimization opportunities for YouTube and Miruro — measure benefits before changing behavior - - [ ] coalesce superseded, not-yet-started seeks in the player and Cast controls; preserve accumulated relative skips and play/pause ordering + - [x] coalesce superseded, not-yet-started seeks in the player and Cast controls; preserve accumulated relative skips and play/pause ordering - [ ] expand Cast H.264/AAC remux coverage with keyframe-aligned segments; preserve seek accuracy, timestamps and receiver compatibility - [ ] reuse decoder/encoder state during sequential Cast playback; reset on random seeks and preserve independent keyframes and AAC continuity - [ ] evaluate VideoToolbox hardware video decoding for native and Cast playback; retain software fallback for unsupported formats