From 9356ed8b4da79680329b1bf7334bdf88900f2b26 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sat, 10 Oct 2026 16:09:29 +0200 Subject: [PATCH] fix: keep seek target visible until acknowledged and make volume thumb opaque --- Checks/playback.mm | 17 ++++++++++++++--- README.md | 8 ++++---- Sources/App/PlayerApp.swift | 31 +++++++++++++++++++++++-------- Sources/Core/player.cpp | 5 +++++ Sources/Core/player.h | 2 ++ 5 files changed, 48 insertions(+), 15 deletions(-) diff --git a/Checks/playback.mm b/Checks/playback.mm index 9ef6443..15c888e 100644 --- a/Checks/playback.mm +++ b/Checks/playback.mm @@ -69,22 +69,33 @@ int main(int argc, char **argv) { "pause freezes authoritative position"); uint64_t revision = paused.revision; require(player_seek(player.get(), 1.5), "paused seek accepted"); + auto pending = player_get_state(player.get()); + require(pending.seek_pending || std::abs(pending.position - 1.5) < .03, + "accepted seek reports pending until the target is authoritative"); auto sought = until(player.get(), [revision](auto s) { - return s.revision != revision && s.status == PLAYER_PAUSED && std::abs(s.position - 1.5) < .03; + return !s.seek_pending && s.revision != revision && s.status == PLAYER_PAUSED && std::abs(s.position - 1.5) < .03; }); [view draw]; require(sought.status == PLAYER_PAUSED, "seek preserves paused state"); + require(player_seek(player.get(), .8) && player_seek(player.get(), 1.5), "queued seeks accepted"); + pending = player_get_state(player.get()); + require(pending.seek_pending || std::abs(pending.position - 1.5) < .03, + "an earlier queued seek cannot acknowledge the final seek"); + until(player.get(), [](auto s) { + return !s.seek_pending && s.status == PLAYER_PAUSED && std::abs(s.position - 1.5) < .03; + }); require(player_play(player.get()), "resume accepted"); until(player.get(), [](auto s) { return s.status == PLAYER_PLAYING && s.position > 1.7; }); require(player_seek(player.get(), 3.2), "playing seek accepted"); - until(player.get(), [](auto s) { return s.status == PLAYER_PLAYING && s.position >= 3.2; }); + until(player.get(), [](auto s) { return !s.seek_pending && s.status == PLAYER_PLAYING && s.position >= 3.2; }); auto ended = until(player.get(), [](auto s) { return s.status == PLAYER_ENDED; }); require(ended.position >= 3.8, "EOF drains media before completion"); require(player_play(player.get()), "replay accepted"); until(player.get(), [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1 && s.position < 1; }); require(player_close(player.get()), "close accepted while playing"); auto closed = until(player.get(), [](auto s) { return s.status == PLAYER_EMPTY; }); - require(closed.position == 0 && !closed.has_audio && !closed.has_video, "close clears media state"); + require(closed.position == 0 && !closed.has_audio && !closed.has_video && !closed.seek_pending, + "close clears media state and pending seeks"); std::printf("PASS %s: open/play/pause/seek/end/replay/close\n", fixture); } require(player_open(player.get(), (std::string(argv[1]) + "/hdr.mkv").c_str()), "HDR open accepted"); diff --git a/README.md b/README.md index 93102f6..9f66b89 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ The layout follows [IINA's video-first window](https://iina.io/img/screenshot.pn Opening video fits the window to its display aspect ratio, including non-square pixels, and constrains subsequent window resizing. Fullscreen retains the normal letterboxed renderer; audio-only playback and close release the aspect constraint. Extreme ratios that cannot fit both the screen and usable controls keep a freely resizable letterboxed window. -- **Space:** play/pause. **←/→:** seek five seconds. The timeline slider seeks on release. -- **↑/↓:** adjust volume by five percent. **M:** mute/unmute, restoring the previous audible level. +- **Space:** play/pause. **←/→:** seek five seconds. The timeline slider seeks on release and holds the requested position until the core finishes queued seeks; old playback snapshots cannot pull the thumb back. +- **↑/↓:** adjust volume by five percent. **M:** mute/unmute, restoring the previous audible level. The volume thumb is opaque light gray, independent of the frosted panel behind it. - **Double-click video**, **F**, or **⌃⌘F:** toggle native fullscreen. **Esc:** leave fullscreen. - **Single-click video:** hide controls. Mouse movement reveals them; they fade after 2.5 seconds, including while paused. Hovering the titlebar/controller, dragging, seeking, an inactive window, errors, or VoiceOver keep them visible. - Click the right-hand time label to switch between total and remaining time. The titlebar pin keeps the window on top. @@ -32,7 +32,7 @@ Only implemented actions have controls; there are no placeholder playlist, subti ## Where to change things -- `Sources/Core/player.h`: the C interface and authoritative state snapshot. Playback commands enqueue work; poll state for completion. Volume set/get is synchronous and uses a lock-free target gain. Callers own the opaque handle and must stop using it before destruction. +- `Sources/Core/player.h`: the C interface and authoritative state snapshot. Playback commands enqueue work; poll state for completion. `seek_pending` is set synchronously when a seek is accepted and clears when all queued seeks on that timeline finish. Volume set/get is synchronous and uses a lock-free target gain. Callers own the opaque handle and must stop using it before destruction. - `Sources/Core/player.cpp`: single demux/decode worker, bounded buffers, timestamps, audio-master synchronization, paused previews, decoder/resampler drain, seeking, errors, and shutdown. A seek stops output and flushes all old timeline state before resuming. - `Sources/Core/output.hpp` and `Sources/macOS/Output.mm`: native output contract, allocation-free/nonblocking Core Audio callback and bounded audio ring, latency-aware presentation clock, retained frame mailbox, and Metal YUV conversion. The view can outlive the player safely. - `Sources/App/PlayerApp.swift`: MainActor state polling, file panel/Finder/drop handling, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration. @@ -44,7 +44,7 @@ Only implemented actions have controls; there are no placeholder playlist, subti make check ``` -Generates short fixtures and exercises the real playback interface and native outputs: audio/video, video-only, audio-only, non-square-pixel display aspect, pause, paused/playing seek, completion, replay, close, HDR rejection, failed-open recovery, and destruction while playing. It initializes the actual Metal shaders and uses an offscreen native window. It requires a working audio device and graphical macOS session, and plays short sine-wave audio. It is a runtime smoke check, not a mocked clock/device test. +Generates short fixtures and exercises the real playback interface and native outputs: audio/video, video-only, audio-only, non-square-pixel display aspect, pause, paused/playing and queued seek acknowledgement, completion, replay, close, HDR rejection, failed-open recovery, and destruction while playing. It initializes the actual Metal shaders and uses an offscreen native window. It requires a working audio device and graphical macOS session, and plays short sine-wave audio. It is a runtime smoke check, not a mocked clock/device test. `make check-audio` runs just the audio-quality check. A check-only hook captures actual Core Audio callback PCM into preallocated storage and compares every sample against FFmpeg's reference output for PCM/AAC, 44.1/48 kHz, and coarse container timestamps. It checks fractional gain, silent muted startup, smooth live volume changes, and unchanged playback completion. A separate gapped fixture verifies that real timestamp gaps remain silent. Release builds contain no capture hook. diff --git a/Sources/App/PlayerApp.swift b/Sources/App/PlayerApp.swift index a479eb1..cdcfb01 100644 --- a/Sources/App/PlayerApp.swift +++ b/Sources/App/PlayerApp.swift @@ -28,6 +28,7 @@ final class PlayerModel: ObservableObject { private var windowActive = true private var lastAudibleVolume = 1.0 private var needsVideoFit = false + private var seekPending = false private init() { let player = player_create() @@ -100,6 +101,7 @@ final class PlayerModel: ObservableObject { needsVideoFit = true commandError = nil isSeeking = false + seekPending = false seekPosition = 0 window?.representedURL = url window?.title = url.lastPathComponent @@ -117,7 +119,9 @@ final class PlayerModel: ObservableObject { isSeeking = false guard let handle, canSeek else { return } let position = min(max(0, seekPosition), duration) - commandError = player_seek(handle, position) ? nil : "Could not queue the seek. Please try again." + let accepted = player_seek(handle, position) + if accepted { seekPending = true } + commandError = accepted ? nil : "Could not queue the seek. Please try again." revealControls() } @@ -133,6 +137,7 @@ final class PlayerModel: ObservableObject { window?.aspectRatio = .zero commandError = nil isSeeking = false + seekPending = false window?.representedURL = nil window?.title = "Player" revealControls() @@ -140,9 +145,13 @@ final class PlayerModel: ObservableObject { func seekRelative(_ seconds: Double) { guard canSeek, let handle else { return } - let target = min(max(0, state.position + seconds), duration) - commandError = player_seek(handle, target) ? nil : "Could not queue the seek. Please try again." - seekPosition = target + let target = min(max(0, (seekPending ? seekPosition : state.position) + seconds), duration) + let accepted = player_seek(handle, target) + if accepted { + seekPosition = target + seekPending = true + } + commandError = accepted ? nil : "Could not queue the seek. Please try again." revealControls() } @@ -234,7 +243,7 @@ final class PlayerModel: ObservableObject { private var shouldHideControls: Bool { canTogglePlayback && state.has_video && fileName != nil && - !isSeeking && !controlsHovered && !titleHovered && !controlsDragging && + !isSeeking && !seekPending && !controlsHovered && !titleHovered && !controlsDragging && windowActive && errorText == nil && !NSWorkspace.shared.isVoiceOverEnabled } @@ -265,14 +274,18 @@ final class PlayerModel: ObservableObject { private func poll() { guard let handle else { return } - let next = player_get_state(handle) + applyState(player_get_state(handle)) + } + + func applyState(_ next: PlayerState) { if let revision = pendingRevision { guard next.revision != revision, next.status != PLAYER_OPENING else { return } pendingRevision = nil } let statusChanged = state.status != next.status state = next - if !isSeeking { + if !next.seek_pending || next.status == PLAYER_ERROR { seekPending = false } + if !isSeeking && !seekPending { seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration) } fitVideoWindow() @@ -379,7 +392,9 @@ private struct PlaybackSlider: NSViewRepresentable { let width: CGFloat = volume ? 16 : 3 let height: CGFloat = volume ? 12 : 15 let knob = NSRect(x: rect.midX - width / 2, y: rect.midY - height / 2, width: width, height: height) - NSColor.white.withAlphaComponent(isEnabled ? 0.65 : 0.25).setFill() + let color = volume ? NSColor(white: isEnabled ? 0.85 : 0.45, alpha: 1) + : NSColor.white.withAlphaComponent(isEnabled ? 0.65 : 0.25) + color.setFill() NSBezierPath(roundedRect: knob, xRadius: volume ? 6 : 1, yRadius: volume ? 6 : 1).fill() } } diff --git a/Sources/Core/player.cpp b/Sources/Core/player.cpp index c9902b3..624ae46 100644 --- a/Sources/Core/player.cpp +++ b/Sources/Core/player.cpp @@ -137,6 +137,7 @@ struct Player { state.status = PLAYER_OPENING; state.revision = revision; } + if (commands.back().action == Action::Seek) state.seek_pending = true; wake.notify_one(); return true; } @@ -179,6 +180,10 @@ struct Player { state.duration = duration; state.has_audio = tracks[0].codec != nullptr; state.has_video = tracks[1].codec != nullptr; + // The worker publishes only outside seek execution; retain pending for later queued seeks. + state.seek_pending = std::any_of(commands.begin(), commands.end(), [this](const Command &command) { + return command.action == Action::Seek && command.epoch == activeEpoch; + }); state.video_aspect_ratio = 0; if (const auto *video = tracks[1].codec; video && video->width > 0 && video->height > 0) { const auto aspect = av_guess_sample_aspect_ratio(format, format->streams[tracks[1].index], nullptr); diff --git a/Sources/Core/player.h b/Sources/Core/player.h index c4a3f58..4bb6ea0 100644 --- a/Sources/Core/player.h +++ b/Sources/Core/player.h @@ -17,6 +17,8 @@ typedef struct PlayerState { double duration; bool has_audio; bool has_video; + // True from accepted seek until all queued seeks on this timeline finish. + bool seek_pending; // Display width / height, including sample aspect ratio; zero without video. double video_aspect_ratio; uint64_t revision;