diff --git a/Checks/audio-continuity.cpp b/Checks/audio-continuity.cpp index 80b4155..52a9e47 100644 --- a/Checks/audio-continuity.cpp +++ b/Checks/audio-continuity.cpp @@ -18,11 +18,18 @@ size_t capturedFrames = 0; void require(bool condition, const char *message) { if (!condition) { std::fprintf(stderr, "FAIL audio: %s\n", message); std::exit(1); } } -void play(const std::string &path) { +void play(const std::string &path, float volume = 1, bool changeWhilePlaying = false) { capturedFrames = 0; std::unique_ptr player(player_create(), player_destroy); - require(player && player_open(player.get(), path.c_str()), "open fixture"); - bool started = false, ended = false; + require(bool(player), "create fixture player"); + require(player_set_volume(player.get(), volume), "set fixture volume"); + for (float invalid : {NAN, INFINITY, -INFINITY, -.01f, 1.01f}) { + require(!player_set_volume(player.get(), invalid), "invalid volume rejected"); + require(player_get_volume(player.get()) == volume, "invalid volume leaves target unchanged"); + } + require(player_open(player.get(), path.c_str()), "open fixture"); + bool started = false, ended = false, sought = false, changed = false; + uint64_t seekRevision = 0; auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(12); while (std::chrono::steady_clock::now() < deadline) { auto state = player_get_state(player.get()); @@ -31,18 +38,45 @@ void play(const std::string &path) { require(false, "playback error"); } if (state.status == PLAYER_PAUSED && !started) { + require(player_get_volume(player.get()) == volume, "volume survives open and seek"); + if (volume != 1 && !sought) { + seekRevision = state.revision; + require(player_seek(player.get(), 0), "seek with gain configured"); + sought = true; + continue; + } + if (sought && state.revision == seekRevision) { + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + continue; + } require(player_play(player.get()), "play fixture"); started = true; } - if (state.status == PLAYER_ENDED) { ended = true; break; } + if (changeWhilePlaying && !changed && state.status == PLAYER_PLAYING && state.position > .3) { + require(player_set_volume(player.get(), .25f), "change volume during playback"); + changed = true; + } + if (state.status == PLAYER_ENDED) { + require(state.position >= state.duration - .01, "gain does not shorten the media timeline"); + ended = true; + break; + } std::this_thread::sleep_for(std::chrono::milliseconds(2)); } require(ended, "playback completes"); + require(!changeWhilePlaying || changed, "live volume change was exercised"); + const float finalVolume = changeWhilePlaying ? .25f : volume; + require(player_get_volume(player.get()) == finalVolume, "volume survives playback"); + require(player_close(player.get()), "close fixture"); + while (std::chrono::steady_clock::now() < deadline && player_get_state(player.get()).status != PLAYER_EMPTY) + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + require(player_get_state(player.get()).status == PLAYER_EMPTY, "close completes"); + require(player_get_volume(player.get()) == finalVolume, "volume survives close"); player.reset(); // Stop/join before inspecting callback-owned capture storage. } -size_t firstActive(const float *samples, size_t frames) { +size_t firstActive(const float *samples, size_t frames, float threshold = .002f) { size_t first = 0; - while (first < frames && std::abs(samples[first * 2]) < .002f) ++first; + while (first < frames && std::abs(samples[first * 2]) < threshold) ++first; require(first < frames, "tone is present"); return first; } @@ -57,6 +91,7 @@ void player_check_audio(const float *samples, size_t frames) { int main(int argc, char **argv) { require(argc == 2, "fixture directory required"); + require(!player_set_volume(nullptr, .5f) && player_get_volume(nullptr) == 0, "null volume handle"); std::string directory = argv[1]; for (const char *name : {"tone441.wav", "tone480.wav", "tone441.mka", "tone-aac.m4a"}) { std::string path = directory + "/" + name; @@ -79,6 +114,46 @@ int main(int argc, char **argv) { maximumError = std::max(maximumError, std::abs(capture[offset * 2 + i] - reference[i])); std::printf("%s: max sample error %.8f\n", name, maximumError); require(maximumError < .00003f, "continuous callback PCM matches reference (no inserted/dropped samples)"); + if (std::string(name) == "tone480.wav") { + play(path, .25f); + size_t quietStart = firstActive(capture.data(), capturedFrames, .002f * .25f); + require(quietStart >= referenceStart, "fractional-volume alignment"); + size_t quietOffset = quietStart - referenceStart; + require(capturedFrames >= quietOffset + frames, "fractional volume consumes all media"); + float gainError = 0; + for (size_t sample = 0; sample < reference.size(); ++sample) + gainError = std::max(gainError, std::abs(capture[quietOffset * 2 + sample] - reference[sample] * .25f)); + require(gainError < .00003f, "volume set while stopped applies from the first sample"); + play(path, 0); + require(capturedFrames >= frames, "mute consumes the entire media timeline"); + for (size_t sample = 0; sample < capturedFrames * 2; ++sample) + require(capture[sample] == 0, "muted startup never leaks audible samples"); + std::printf("volume: max quarter-gain error %.8f; mute silent from startup\n", gainError); + + play(path, 1, true); + size_t liveOffset = firstActive(capture.data(), capturedFrames) - referenceStart; + require(capturedFrames >= liveOffset + frames, "live volume change preserves all media samples"); + size_t firstChange = frames, settled = frames, previousFrame = 0; + float previousGain = 1; + for (size_t frame = 1; frame < frames; ++frame) { + float expected = reference[frame * 2]; + if (std::abs(expected) < .003f) continue; // Gain is undefined at zero crossings. + float gain = capture[(liveOffset + frame) * 2] / expected; + require(gain >= .2499f && gain <= 1.0001f, "live gain never overshoots"); + require(std::abs(gain - previousGain) / (frame - previousFrame) < .01f, + "live gain changes smoothly rather than popping"); + if (firstChange == frames && gain < .9999f) firstChange = frame; + if (firstChange != frames && settled == frames && std::abs(gain - .25f) < .0001f) + settled = frame; + previousGain = gain; + previousFrame = frame; + } + require(firstChange > 12000 && settled < frames && settled - firstChange <= 256, + "live gain reaches its target within approximately five milliseconds"); + for (size_t sample = (settled + 1) * 2; sample < reference.size(); ++sample) + require(std::abs(capture[liveOffset * 2 + sample] - reference[sample] * .25f) < .00003f, + "live gain settles without corrupting subsequent PCM"); + } } // Continuity must not erase a real 250 ms PTS gap in the source. play(directory + "/tone-gap.mka"); @@ -92,5 +167,5 @@ int main(int argc, char **argv) { energy += value * value; } require(energy / 4800 > .001, "audible tone resumes after real gap"); - std::puts("PASS callback PCM continuity at 44.1/48 kHz, coarse timestamps, and real timestamp gap"); + std::puts("PASS callback PCM continuity, 5 ms volume ramp, fractional gain, mute, and unchanged timeline"); } diff --git a/README.md b/README.md index a5ed77f..3bf326a 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,26 @@ make run The local app is `.build/Player.app`. It links the installed FFmpeg libraries; it is not a standalone distributable bundle. After upgrading FFmpeg, rebuild with `make -B`. -Open a local file with **⌘O**, play/pause with **Space**, and seek with the slider. Files open paused. **⇧⌘W** closes the file; closing the window quits and joins the worker. +Open a local file with **⌘O**, drop it onto the window, or use Finder's **Open With**. Files open paused. **⇧⌘W** closes the file; closing the window quits and joins the worker. + +## Playback UI + +The starting layout follows [IINA's video-first window](https://iina.io/img/screenshot.png): dark, edge-to-edge video, a slim titlebar with native traffic lights, and a draggable translucent floating controller. The controller starts centered near the bottom and keeps its relative position when the window changes size. + +- **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. +- **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. + +Only implemented actions have controls; there are no placeholder playlist, subtitle, picture-in-picture, or settings buttons. Layout and interaction references: IINA v1.5.0's [floating controller](https://github.com/iina/iina/blob/v1.5.0/iina/OSCFloatingView.swift), [preferences](https://github.com/iina/iina/blob/v1.5.0/iina/Preference.swift), and [titlebar](https://github.com/iina/iina/blob/v1.5.0/iina/Titlebar.swift). ## Where to change things -- `Sources/Core/player.h`: the C interface and authoritative state snapshot. Commands enqueue work; poll state for completion. 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. 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, video host, and controls. +- `Sources/App/PlayerApp.swift`: MainActor state polling, file panel/Finder/drop handling, video host, floating controls, visibility, and native window integration. - `Makefile`: compiler/linker commands and app bundle construction. No generated Xcode project or additional build dependency. ## Runnable check @@ -32,10 +44,12 @@ make check Generates short fixtures and exercises the real playback interface and native outputs: audio/video, video-only, audio-only, 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. -`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. A separate gapped fixture verifies that real timestamp gaps remain silent. Release builds contain no capture hook. +`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. Continuous audio uses resampled sample counts for its output timeline; packet timestamp quantization and rounded resampler delay must not insert silence or drop samples between blocks. +Volume persists across open, seek, and close. Live gain changes ramp over five milliseconds without callback allocation or locking; a stopped output adopts the requested gain before playback starts, so muted startup cannot leak audio. + Visual correctness, perceived A/V synchronization, and control usability still need a check in the visible app. ## Current limits diff --git a/Resources/Info.plist b/Resources/Info.plist index 9871d34..58d7fcf 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -8,6 +8,16 @@ CFBundlePackageTypeAPPL CFBundleShortVersionString0.1 LSMinimumSystemVersion15.0 + CFBundleDocumentTypes + + + CFBundleTypeNameMedia + CFBundleTypeRoleViewer + LSHandlerRankAlternate + LSItemContentTypes + public.moviepublic.audio + + NSHighResolutionCapable NSPrincipalClassNSApplication diff --git a/Sources/App/PlayerApp.swift b/Sources/App/PlayerApp.swift index 6496134..2649859 100644 --- a/Sources/App/PlayerApp.swift +++ b/Sources/App/PlayerApp.swift @@ -11,10 +11,21 @@ final class PlayerModel: ObservableObject { @Published private(set) var pendingRevision: UInt64? @Published var seekPosition = 0.0 @Published var isSeeking = false + @Published private(set) var volume = 1.0 + @Published private(set) var controlsVisible = true + @Published private(set) var isFullScreen = false + @Published private(set) var isOnTop = false let videoView: NSView? private var handle: OpaquePointer? private var timer: Timer? + private weak var window: NSWindow? + private var hideDeadline: ContinuousClock.Instant? + private var controlsHovered = false + private var titleHovered = false + private var controlsDragging = false + private var windowActive = true + private var lastAudibleVolume = 1.0 private init() { let player = player_create() @@ -22,14 +33,13 @@ final class PlayerModel: ObservableObject { if let player, let view = player_macos_make_video_view(player) { videoView = Unmanaged.fromOpaque(view).takeRetainedValue() state = player_get_state(player) + volume = Double(player_get_volume(player)) } else { videoView = nil commandError = "Could not create the media player." } timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - self?.poll() - } + MainActor.assumeIsolated { self?.poll() } } } @@ -41,19 +51,8 @@ final class PlayerModel: ObservableObject { var canSeek: Bool { canTogglePlayback && state.duration.isFinite && state.duration > 0 } var canOpen: Bool { handle != nil && !isBusy } var canClose: Bool { handle != nil && !isBusy && state.status != PLAYER_EMPTY } + var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR } var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 } - var statusText: String { - if isBusy { return "Opening…" } - switch state.status { - case PLAYER_EMPTY: return "Open a local audio or video file to begin." - case PLAYER_OPENING: return "Opening…" - case PLAYER_PAUSED: return "Paused" - case PLAYER_PLAYING: return "Playing" - case PLAYER_ENDED: return "Finished" - case PLAYER_ERROR: return "Unable to play this file" - default: return "Unavailable" - } - } var errorText: String? { if let commandError { return commandError } guard state.status == PLAYER_ERROR else { return nil } @@ -66,6 +65,7 @@ final class PlayerModel: ObservableObject { func openPanel() { guard canOpen else { return } + revealControls() let panel = NSOpenPanel() panel.canChooseFiles = true panel.canChooseDirectories = false @@ -75,8 +75,13 @@ final class PlayerModel: ObservableObject { open(url) } - private func open(_ url: URL) { - guard let handle else { return } + func open(_ url: URL) { + guard canOpen, let handle else { return } + guard url.isFileURL else { + commandError = "Only local media files are supported." + revealControls() + return + } let revision = player_get_state(handle).revision let accepted = url.withUnsafeFileSystemRepresentation { path in guard let path else { return false } @@ -92,12 +97,16 @@ final class PlayerModel: ObservableObject { commandError = nil isSeeking = false seekPosition = 0 + window?.representedURL = url + window?.title = url.lastPathComponent + revealControls() } func togglePlayback() { guard let handle, canTogglePlayback else { return } let accepted = state.status == PLAYER_PLAYING ? player_pause(handle) : player_play(handle) commandError = accepted ? nil : "Could not queue playback. Please try again." + revealControls() } func finishSeeking() { @@ -105,6 +114,7 @@ final class PlayerModel: ObservableObject { 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." + revealControls() } func closeFile() { @@ -116,6 +126,108 @@ final class PlayerModel: ObservableObject { fileName = nil commandError = nil isSeeking = false + window?.representedURL = nil + window?.title = "Player" + revealControls() + } + + 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 + revealControls() + } + + func setVolume(_ value: Double) { + guard let handle else { return } + if player_set_volume(handle, Float(value)) { + volume = Double(player_get_volume(handle)) + if volume > 0 { lastAudibleVolume = volume } + } + revealControls() + } + + func toggleMute() { + guard canSetVolume else { return } + setVolume(volume == 0 ? lastAudibleVolume : 0) + } + + func attachWindow(_ window: NSWindow) { + self.window = window + window.title = fileName ?? "Player" + updateWindowState() + } + + func updateWindowState() { + guard let window else { return } + isFullScreen = window.styleMask.contains(.fullScreen) + windowActive = window.isKeyWindow + window.level = isOnTop && !isFullScreen ? .floating : .normal + revealControls() + } + + func toggleFullScreen() { + window?.toggleFullScreen(nil) + revealControls() + } + + func toggleOnTop() { + guard !isFullScreen else { return } + isOnTop.toggle() + window?.level = isOnTop ? .floating : .normal + revealControls() + } + + func setControlsHovered(_ hovered: Bool) { + guard controlsHovered != hovered else { return } + controlsHovered = hovered + revealControls() + } + + func setTitleHovered(_ hovered: Bool) { + guard titleHovered != hovered else { return } + titleHovered = hovered + revealControls() + } + + func setControlsDragging(_ dragging: Bool) { + guard controlsDragging != dragging else { return } + controlsDragging = dragging + revealControls() + } + + func hideControls(hideCursor: Bool = true) { + guard shouldHideControls else { return } + hideDeadline = nil + withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeIn(duration: 0.25)) { + controlsVisible = false + } + NSCursor.setHiddenUntilMouseMoves(hideCursor) + } + + func beginSeeking() { + isSeeking = true + revealControls() + } + + func revealControls() { + hideDeadline = nil + NSCursor.setHiddenUntilMouseMoves(false) + if !controlsVisible { + withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeOut(duration: 0.25)) { + controlsVisible = true + } + } + guard shouldHideControls else { return } + hideDeadline = ContinuousClock.now.advanced(by: .milliseconds(2500)) + } + + private var shouldHideControls: Bool { + canTogglePlayback && state.has_video && fileName != nil && + !isSeeking && !controlsHovered && !titleHovered && !controlsDragging && + windowActive && errorText == nil && + !NSWorkspace.shared.isVoiceOverEnabled } private func poll() { @@ -125,15 +237,20 @@ final class PlayerModel: ObservableObject { guard next.revision != revision, next.status != PLAYER_OPENING else { return } pendingRevision = nil } + let statusChanged = state.status != next.status state = next if !isSeeking { seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration) } + if statusChanged { revealControls() } + if let hideDeadline, ContinuousClock.now >= hideDeadline { hideControls() } } func shutdown() { timer?.invalidate() timer = nil + hideDeadline = nil + NSCursor.setHiddenUntilMouseMoves(false) guard let player = handle else { return } handle = nil player_destroy(player) @@ -151,6 +268,12 @@ private struct VideoSurface: NSViewRepresentable { @MainActor private struct PlayerContent: View { @ObservedObject var model: PlayerModel + @State private var showsRemainingTime = false + @State private var dropTargeted = false + @State private var controlSize = CGSize(width: 460, height: 82) + // Relative offsets preserve the dragged position across resize/fullscreen. + @State private var controlOffset = CGSize.zero + @State private var dragOrigin = CGSize.zero private func time(_ seconds: Double) -> String { let value = Int(min(max(0, seconds.isFinite ? seconds : 0), Double(Int.max / 2))) @@ -160,58 +283,332 @@ private struct PlayerContent: View { : String(format: "%d:%02d", value / 60, value % 60) } + private func iconButton(_ symbol: String, _ label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: symbol).font(.system(size: 17)).frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .help(label) + .accessibilityLabel(label) + } + + private var titlebar: some View { + HStack(spacing: 4) { + Image(systemName: model.fileName == nil ? "play.rectangle.fill" : "doc.fill") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + Text(model.fileName ?? "Player") + .font(.system(size: 13, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + .help(model.fileName ?? "Player") + Spacer(minLength: 12) + iconButton(model.isFullScreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right", + model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen) + iconButton(model.isOnTop ? "pin.fill" : "pin", + model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop) + .foregroundStyle(model.isOnTop ? .white : .secondary) + .disabled(model.isFullScreen) + } + .padding(.leading, model.isFullScreen ? 16 : 86) + .padding(.trailing, 12) + .frame(height: 40) + .background(.ultraThinMaterial) + .overlay(alignment: .bottom) { Rectangle().fill(.white.opacity(0.08)).frame(height: 1) } + .background(WindowChrome(model: model)) + .onHover(perform: model.setTitleHovered) + .opacity(model.controlsVisible ? 1 : 0) + .allowsHitTesting(model.controlsVisible) + .accessibilityHidden(!model.controlsVisible) + } + + private var playbackControls: some View { + VStack(spacing: 8) { + HStack(spacing: 0) { + HStack(spacing: 4) { + iconButton(model.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill", + model.volume == 0 ? "Unmute" : "Mute", action: model.toggleMute) + Slider(value: Binding(get: { model.volume }, set: model.setVolume), in: 0...1) + .controlSize(.mini) + .frame(width: 70) + .accessibilityLabel("Volume") + .accessibilityValue("\(Int((model.volume * 100).rounded())) percent") + } + .disabled(!model.canSetVolume) + .frame(width: 106, alignment: .leading) + Spacer(minLength: 8) + HStack(spacing: 20) { + iconButton("backward.fill", "Seek Back 5 Seconds") { model.seekRelative(-5) } + .disabled(!model.canSeek) + Button(action: model.togglePlayback) { + Image(systemName: model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill") + .font(.system(size: 28)) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel(model.state.status == PLAYER_PLAYING ? "Pause" : "Play") + .help(model.state.status == PLAYER_PLAYING ? "Pause (Space)" : "Play (Space)") + .disabled(!model.canTogglePlayback) + iconButton("forward.fill", "Seek Forward 5 Seconds") { model.seekRelative(5) } + .disabled(!model.canSeek) + } + Spacer(minLength: 8) + HStack(spacing: 12) { + iconButton(model.isFullScreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right", + model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen) + Menu { + Button("Open File…", action: model.openPanel).disabled(!model.canOpen) + Button("Close File", action: model.closeFile).disabled(!model.canClose) + Divider() + Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop) + .disabled(model.isFullScreen) + } label: { + Image(systemName: "ellipsis.circle").font(.system(size: 18)).frame(width: 24, height: 24) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .accessibilityLabel("Playback Actions") + .help("Playback Actions") + } + .frame(width: 106, alignment: .trailing) + } + .padding(.horizontal, 12) + .padding(.top, 14) + + HStack(spacing: 8) { + Text(time(model.seekPosition)).frame(width: 46, alignment: .trailing) + Slider(value: $model.seekPosition, in: 0...max(model.duration, 0.001), onEditingChanged: { editing in + if editing { model.beginSeeking() } else { model.finishSeeking() } + }) + .controlSize(.mini) + .disabled(!model.canSeek) + .accessibilityLabel("Playback Position") + .accessibilityValue(time(model.seekPosition)) + Button { + showsRemainingTime.toggle() + model.revealControls() + } label: { + Text(showsRemainingTime ? "−" + time(model.duration - model.seekPosition) : time(model.duration)) + .frame(width: 46, alignment: .leading) + } + .buttonStyle(.plain) + .help(showsRemainingTime ? "Show Duration" : "Show Remaining Time") + .accessibilityLabel(showsRemainingTime ? "Remaining Time" : "Duration") + .accessibilityValue(time(showsRemainingTime ? model.duration - model.seekPosition : model.duration)) + } + .font(.system(size: 11).monospacedDigit()) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.bottom, 8) + } + .tint(.white) + .onHover(perform: model.setControlsHovered) + } + + private func controllerPosition(in size: CGSize) -> CGPoint { + let x = size.width / 2 + controlOffset.width * size.width + let y = size.height * 0.9 - controlSize.height / 2 + controlOffset.height * size.height + return CGPoint( + x: min(max(x, controlSize.width / 2 + 12), size.width - controlSize.width / 2 - 12), + y: min(max(y, 40 + controlSize.height / 2 + 12), size.height - controlSize.height / 2 - 12) + ) + } + var body: some View { - VStack(spacing: 0) { - ZStack { + GeometryReader { geometry in + ZStack(alignment: .top) { Color.black if let view = model.videoView { VideoSurface(view: view) + .accessibilityLabel("Video") + .gesture( + TapGesture(count: 2).onEnded { model.toggleFullScreen() } + .exclusively(before: TapGesture().onEnded { model.hideControls() }) + ) } - if model.fileName == nil { - VStack(spacing: 12) { - Image(systemName: "play.rectangle").font(.system(size: 48)) - Text("Open a local audio or video file") - Button("Open File…", action: model.openPanel) - .disabled(!model.canOpen) - } - .foregroundStyle(.white) - } else if !model.isBusy && !model.state.has_video && model.state.has_audio { - Image(systemName: "waveform").font(.system(size: 64)).foregroundStyle(.white) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .accessibilityLabel("Video") - - VStack(alignment: .leading, spacing: 10) { - HStack { - Button(action: model.togglePlayback) { - Image(systemName: model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill") - .frame(width: 22) - } - .keyboardShortcut(.space, modifiers: []) - .accessibilityLabel(model.state.status == PLAYER_PLAYING ? "Pause" : "Play") - .disabled(!model.canTogglePlayback) - Text(time(model.seekPosition)).monospacedDigit().frame(minWidth: 52, alignment: .trailing) - Slider(value: $model.seekPosition, in: 0...max(model.duration, 0.001), onEditingChanged: { editing in - if editing { model.isSeeking = true } else { model.finishSeeking() } - }) - .disabled(!model.canSeek) - .accessibilityLabel("Playback position") - Text(time(model.duration)).monospacedDigit().frame(minWidth: 52, alignment: .leading) - Button("Open…", action: model.openPanel) - .accessibilityLabel("Open media file") - .disabled(!model.canOpen) - } - Text(model.fileName ?? "Player").font(.headline).lineLimit(1).help(model.fileName ?? "Player") - Text(model.statusText).font(.caption).foregroundStyle(.secondary) if let error = model.errorText { - Text(error).font(.callout).foregroundStyle(.red).textSelection(.enabled) + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle").font(.system(size: 32)).foregroundStyle(.yellow) + Text("Unable to Play").font(.headline) + Text(error).font(.callout).multilineTextAlignment(.center).textSelection(.enabled) + Button("Open Another File…", action: model.openPanel).disabled(!model.canOpen) + } + .padding(24) + .frame(maxWidth: 420) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if model.isBusy { + VStack(spacing: 12) { + ProgressView().controlSize(.large) + Text("Opening…").font(.callout).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if model.fileName == nil { + VStack(spacing: 14) { + Image(systemName: "play.rectangle").font(.system(size: 56, weight: .ultraLight)) + .foregroundStyle(.white.opacity(0.65)) + Text("Player").font(.system(size: 22, weight: .medium)) + Text("Drop a media file here").font(.callout).foregroundStyle(.secondary) + Button("Open File…", action: model.openPanel) + .buttonStyle(.bordered).controlSize(.large).disabled(!model.canOpen) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if !model.state.has_video && model.state.has_audio { + VStack(spacing: 16) { + Image(systemName: "waveform").font(.system(size: 64, weight: .ultraLight)) + Text(model.fileName ?? "").font(.headline).lineLimit(1) + } + .foregroundStyle(.secondary) + .padding(40) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + titlebar + + if model.canTogglePlayback { + playbackControls + .frame(width: min(460, geometry.size.width - 32)) + .background { + RoundedRectangle(cornerRadius: 12) + .fill(.ultraThinMaterial) + .gesture(DragGesture(minimumDistance: 4).onChanged { value in + model.setControlsDragging(true) + controlOffset = CGSize(width: dragOrigin.width + value.translation.width / geometry.size.width, + height: dragOrigin.height + value.translation.height / geometry.size.height) + }.onEnded { _ in + let position = controllerPosition(in: geometry.size) + controlOffset = CGSize(width: (position.x - geometry.size.width / 2) / geometry.size.width, + height: (position.y - (geometry.size.height * 0.9 - controlSize.height / 2)) / geometry.size.height) + dragOrigin = controlOffset + model.setControlsDragging(false) + }) + } + .overlay { RoundedRectangle(cornerRadius: 12).strokeBorder(.white.opacity(0.13), lineWidth: 1).allowsHitTesting(false) } + .shadow(color: .black.opacity(0.3), radius: 16, y: 6) + .onGeometryChange(for: CGSize.self) { $0.size } action: { controlSize = $0 } + .position(controllerPosition(in: geometry.size)) + .opacity(model.controlsVisible ? 1 : 0) + .allowsHitTesting(model.controlsVisible) + .accessibilityHidden(!model.controlsVisible) + } + if dropTargeted { + RoundedRectangle(cornerRadius: 10) + .strokeBorder(.white.opacity(0.75), lineWidth: 2) + .padding(8) + .allowsHitTesting(false) + } + } + .dropDestination(for: URL.self) { urls, _ in + guard model.canOpen, let url = urls.first, url.isFileURL else { return false } + model.open(url) + return true + } isTargeted: { + dropTargeted = $0 + model.revealControls() + } + .onContinuousHover { phase in + switch phase { + case .active: break + case .ended: model.hideControls(hideCursor: false) } } - .padding() } + .ignoresSafeArea() .frame(minWidth: 560, minHeight: 360) - .navigationTitle(model.fileName ?? "Player") + .preferredColorScheme(.dark) + } +} + +@MainActor +private struct WindowChrome: NSViewRepresentable { + @ObservedObject var model: PlayerModel + + func makeNSView(context: Context) -> ChromeView { ChromeView(model: model) } + func updateNSView(_ view: ChromeView, context: Context) { view.updateButtons() } + static func dismantleNSView(_ view: ChromeView, coordinator: ()) { view.detach() } + + final class ChromeView: NSView { + private let model: PlayerModel + private weak var configuredWindow: NSWindow? + private var monitor: Any? + private var observers: [NSObjectProtocol] = [] + + init(model: PlayerModel) { + self.model = model + super.init(frame: .zero) + } + required init?(coder: NSCoder) { nil } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard let window else { detach(); return } + guard window !== configuredWindow else { return } + detach() + configuredWindow = window + window.styleMask.insert(.fullSizeContentView) + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.backgroundColor = .black + window.acceptsMouseMovedEvents = true + window.collectionBehavior.insert(.fullScreenPrimary) + window.appearance = NSAppearance(named: .darkAqua) + for name in [NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification, + NSWindow.didEnterFullScreenNotification, NSWindow.didExitFullScreenNotification] { + observers.append(NotificationCenter.default.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + self?.model.updateWindowState() + self?.updateButtons() + } + }) + } + monitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDown, .keyDown]) { [weak self] event in + let consumed = MainActor.assumeIsolated { + guard let self, event.window === self.configuredWindow else { return false } + if event.type == .keyDown && event.keyCode == 48 { self.model.revealControls() } + if event.type == .mouseMoved || event.type == .leftMouseDown { + self.model.revealControls() + } else if event.type == .keyDown && !(event.window?.firstResponder is NSSlider) && + !(event.window?.firstResponder is NSTextView) { + if event.keyCode == 53 && self.model.isFullScreen { + self.model.toggleFullScreen() + return true + } + if event.modifierFlags.intersection([.command, .control, .option]).isEmpty && + event.charactersIgnoringModifiers?.lowercased() == "f" { + self.model.toggleFullScreen() + return true + } + } + return false + } + return consumed ? nil : event + } + Task { @MainActor [weak self, weak window] in + guard let self, let window else { return } + self.model.attachWindow(window) + self.updateButtons() + } + } + + func updateButtons() { + let hidden = !model.controlsVisible || model.isFullScreen + for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { + if let view = configuredWindow?.standardWindowButton(button), view.isHidden != hidden { + view.isHidden = hidden + } + } + } + + override func mouseDown(with event: NSEvent) { window?.performDrag(with: event) } + + func detach() { + if let monitor { NSEvent.removeMonitor(monitor) } + monitor = nil + observers.forEach(NotificationCenter.default.removeObserver) + observers.removeAll() + configuredWindow = nil + } } } @@ -222,6 +619,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSApp.activate(ignoringOtherApps: true) } + func application(_ application: NSApplication, open urls: [URL]) { + if let url = urls.first { PlayerModel.shared.open(url) } + } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } func applicationWillTerminate(_ notification: Notification) { @@ -236,10 +637,11 @@ struct PlayerApp: App { @StateObject private var model = PlayerModel.shared var body: some Scene { - Window("Player", id: "player") { + Window("\(model.fileName ?? "Player")", id: "player") { PlayerContent(model: model) } - .defaultSize(width: 900, height: 600) + .defaultSize(width: 900, height: 560) + .windowStyle(.hiddenTitleBar) .commands { CommandGroup(replacing: .newItem) { Button("Open File…", action: model.openPanel).keyboardShortcut("o") @@ -248,6 +650,27 @@ struct PlayerApp: App { .keyboardShortcut("w", modifiers: [.command, .shift]) .disabled(!model.canClose) } + CommandMenu("Playback") { + Button(model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback) + .keyboardShortcut(.space, modifiers: []).disabled(!model.canTogglePlayback) + Button("Seek Back 5 Seconds") { model.seekRelative(-5) } + .keyboardShortcut(.leftArrow, modifiers: []).disabled(!model.canSeek) + Button("Seek Forward 5 Seconds") { model.seekRelative(5) } + .keyboardShortcut(.rightArrow, modifiers: []).disabled(!model.canSeek) + Divider() + Button("Volume Up") { model.setVolume(min(1, model.volume + 0.05)) } + .keyboardShortcut(.upArrow, modifiers: []).disabled(!model.canSetVolume) + Button("Volume Down") { model.setVolume(max(0, model.volume - 0.05)) } + .keyboardShortcut(.downArrow, modifiers: []).disabled(!model.canSetVolume) + Button(model.volume == 0 ? "Unmute" : "Mute", action: model.toggleMute) + .keyboardShortcut("m", modifiers: []).disabled(!model.canSetVolume) + } + CommandGroup(after: .windowArrangement) { + Button(model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen) + .keyboardShortcut("f", modifiers: [.control, .command]) + Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop) + .disabled(model.isFullScreen) + } } } } diff --git a/Sources/Core/output.hpp b/Sources/Core/output.hpp index aaf47ad..68d3ebe 100644 --- a/Sources/Core/output.hpp +++ b/Sources/Core/output.hpp @@ -18,6 +18,9 @@ public: bool configure_audio(std::string &error); bool start(std::string &error); void pause(); + // Atomic target gain; callback slews to it within 240 frames (5 ms). + void set_volume(float normalized0to1) noexcept; + float volume() const noexcept; // Called with audio stopped. Clears audio and video from the old timeline. void reset(); std::size_t write(const float *samples, std::size_t frames, double pts); diff --git a/Sources/Core/player.cpp b/Sources/Core/player.cpp index c4d9b46..4f56aa6 100644 --- a/Sources/Core/player.cpp +++ b/Sources/Core/player.cpp @@ -748,6 +748,16 @@ bool player_seek(Player *player, double seconds) { bool player_close(Player *player) { return submit(player, [=] { return player->enqueue({Action::Close, {}}); }); } +bool player_set_volume(Player *player, float normalized0to1) { + if (!std::isfinite(normalized0to1) || normalized0to1 < 0 || normalized0to1 > 1) return false; + return submit(player, [=] { + player->output.set_volume(normalized0to1); + return true; + }); +} +float player_get_volume(Player *player) { + return player ? player->output.volume() : 0; +} PlayerState player_get_state(Player *player) { try { if (player) { diff --git a/Sources/Core/player.h b/Sources/Core/player.h index 81387e2..e39b00a 100644 --- a/Sources/Core/player.h +++ b/Sources/Core/player.h @@ -21,7 +21,7 @@ typedef struct PlayerState { char error[512]; } PlayerState; -// Commands are asynchronous. False means invalid input or a full command queue. +// Playback commands are asynchronous. False means invalid input or a full command queue. // Paths are copied before returning. All other calls require a live handle. Player *player_create(void); bool player_open(Player *player, const char *path); @@ -29,6 +29,11 @@ bool player_play(Player *player); bool player_pause(Player *player); bool player_seek(Player *player, double seconds); bool player_close(Player *player); +// Synchronous, thread-safe gain; survives open, seek, and close. Zero mutes. +// Rejects null handles and non-finite/out-of-range values without changing gain. +bool player_set_volume(Player *player, float normalized0to1); +// Returns the target gain immediately; zero for a null handle. +float player_get_volume(Player *player); PlayerState player_get_state(Player *player); // Joins the worker and stops audio; never invoke from an audio callback. void player_destroy(Player *player); diff --git a/Sources/macOS/Output.mm b/Sources/macOS/Output.mm index 10b6a70..04074d8 100644 --- a/Sources/macOS/Output.mm +++ b/Sources/macOS/Output.mm @@ -244,6 +244,12 @@ struct Output::Impl { AudioUnit unit = nullptr; std::array samples{}; std::atomic read{0}, written{0}; + static_assert(std::atomic::is_always_lock_free); + std::atomic targetGain{1}; + // Callback-owned while running; start initializes gain before callbacks begin. + double currentGain = 1, gainStep = 0; + float rampTarget = 1; + size_t rampRemaining = 0; double origin = 0; double latency = 0; std::atomic clockVersion{0}; @@ -260,9 +266,31 @@ struct Output::Impl { uint64_t w = self.written.load(std::memory_order_acquire); size_t frames = std::min(count, w - r); float *destination = static_cast(buffers->mBuffers[0].mData); - size_t first = std::min(frames, capacity - size_t(r % capacity)); - std::memcpy(destination, self.samples.data() + (r % capacity) * 2, first * 2 * sizeof(float)); - std::memcpy(destination + first * 2, self.samples.data(), (frames - first) * 2 * sizeof(float)); + float target = self.targetGain.load(std::memory_order_relaxed); + if (target != self.rampTarget) { + self.rampTarget = target; + self.rampRemaining = 240; + self.gainStep = (target - self.currentGain) / 240; + } + // Stable mute consumes the ring normally, leaving the zeroed output alone. + if (self.currentGain != 0 || target != 0) { + size_t first = std::min(frames, capacity - size_t(r % capacity)); + std::memcpy(destination, self.samples.data() + (r % capacity) * 2, first * 2 * sizeof(float)); + std::memcpy(destination + first * 2, self.samples.data(), (frames - first) * 2 * sizeof(float)); + size_t frame = 0; + while (frame < frames && self.rampRemaining) { + self.currentGain += self.gainStep; + if (--self.rampRemaining == 0) self.currentGain = target; + float gain = static_cast(self.currentGain); + destination[frame * 2] *= gain; + destination[frame * 2 + 1] *= gain; + ++frame; + } + // Unity is bit-identical to the decoded PCM; do not scale underrun silence. + if (self.currentGain != 1) + for (size_t sample = frame * 2; sample < frames * 2; ++sample) + destination[sample] *= static_cast(self.currentGain); + } #ifdef PLAYER_AUDIO_CHECK player_check_audio(destination, count); #endif @@ -345,6 +373,10 @@ bool Output::configure_audio(std::string &error) { bool Output::start(std::string &error) { if (!impl->unit) return true; // Video-only playback does not open an audio device. if (impl->running) return true; + impl->rampTarget = impl->targetGain.load(std::memory_order_relaxed); + impl->currentGain = impl->rampTarget; + impl->rampRemaining = 0; + impl->gainStep = 0; OSStatus status = AudioOutputUnitStart(impl->unit); if (status != noErr) { error = "Could not start Core Audio (" + std::to_string(status) + ")"; return false; } impl->running = true; @@ -357,6 +389,12 @@ void Output::pause() { impl->running = false; impl->frozen.store(position, std::memory_order_release); } +void Output::set_volume(float normalized0to1) noexcept { + impl->targetGain.store(normalized0to1, std::memory_order_relaxed); +} +float Output::volume() const noexcept { + return impl->targetGain.load(std::memory_order_relaxed); +} void Output::reset() { impl->read.store(0); impl->written.store(0); impl->origin = 0;