fix: keep seek target visible until acknowledged and make volume thumb opaque
This commit is contained in:
+14
-3
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user