feat: adopt IINA-style playback window

This commit is contained in:
2026-10-10 01:35:49 +02:00
parent df2456f559
commit 462cb52eb2
8 changed files with 654 additions and 76 deletions
+82 -7
View File
@@ -18,11 +18,18 @@ size_t capturedFrames = 0;
void require(bool condition, const char *message) { void require(bool condition, const char *message) {
if (!condition) { std::fprintf(stderr, "FAIL audio: %s\n", message); std::exit(1); } 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; capturedFrames = 0;
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy); std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
require(player && player_open(player.get(), path.c_str()), "open fixture"); require(bool(player), "create fixture player");
bool started = false, ended = false; 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); auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(12);
while (std::chrono::steady_clock::now() < deadline) { while (std::chrono::steady_clock::now() < deadline) {
auto state = player_get_state(player.get()); auto state = player_get_state(player.get());
@@ -31,18 +38,45 @@ void play(const std::string &path) {
require(false, "playback error"); require(false, "playback error");
} }
if (state.status == PLAYER_PAUSED && !started) { 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"); require(player_play(player.get()), "play fixture");
started = true; 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)); std::this_thread::sleep_for(std::chrono::milliseconds(2));
} }
require(ended, "playback completes"); 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. 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; 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"); require(first < frames, "tone is present");
return first; return first;
} }
@@ -57,6 +91,7 @@ void player_check_audio(const float *samples, size_t frames) {
int main(int argc, char **argv) { int main(int argc, char **argv) {
require(argc == 2, "fixture directory required"); 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]; std::string directory = argv[1];
for (const char *name : {"tone441.wav", "tone480.wav", "tone441.mka", "tone-aac.m4a"}) { for (const char *name : {"tone441.wav", "tone480.wav", "tone441.mka", "tone-aac.m4a"}) {
std::string path = directory + "/" + name; 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])); maximumError = std::max(maximumError, std::abs(capture[offset * 2 + i] - reference[i]));
std::printf("%s: max sample error %.8f\n", name, maximumError); std::printf("%s: max sample error %.8f\n", name, maximumError);
require(maximumError < .00003f, "continuous callback PCM matches reference (no inserted/dropped samples)"); 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. // Continuity must not erase a real 250 ms PTS gap in the source.
play(directory + "/tone-gap.mka"); play(directory + "/tone-gap.mka");
@@ -92,5 +167,5 @@ int main(int argc, char **argv) {
energy += value * value; energy += value * value;
} }
require(energy / 4800 > .001, "audible tone resumes after real gap"); 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");
} }
+18 -4
View File
@@ -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`. 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 ## 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/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/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. - `Makefile`: compiler/linker commands and app bundle construction. No generated Xcode project or additional build dependency.
## Runnable check ## 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. 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. 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. Visual correctness, perceived A/V synchronization, and control usability still need a check in the visible app.
## Current limits ## Current limits
+10
View File
@@ -8,6 +8,16 @@
<key>CFBundlePackageType</key><string>APPL</string> <key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.1</string> <key>CFBundleShortVersionString</key><string>0.1</string>
<key>LSMinimumSystemVersion</key><string>15.0</string> <key>LSMinimumSystemVersion</key><string>15.0</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key><string>Media</string>
<key>CFBundleTypeRole</key><string>Viewer</string>
<key>LSHandlerRank</key><string>Alternate</string>
<key>LSItemContentTypes</key>
<array><string>public.movie</string><string>public.audio</string></array>
</dict>
</array>
<key>NSHighResolutionCapable</key><true/> <key>NSHighResolutionCapable</key><true/>
<key>NSPrincipalClass</key><string>NSApplication</string> <key>NSPrincipalClass</key><string>NSApplication</string>
</dict> </dict>
+478 -55
View File
@@ -11,10 +11,21 @@ final class PlayerModel: ObservableObject {
@Published private(set) var pendingRevision: UInt64? @Published private(set) var pendingRevision: UInt64?
@Published var seekPosition = 0.0 @Published var seekPosition = 0.0
@Published var isSeeking = false @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? let videoView: NSView?
private var handle: OpaquePointer? private var handle: OpaquePointer?
private var timer: Timer? 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() { private init() {
let player = player_create() let player = player_create()
@@ -22,14 +33,13 @@ final class PlayerModel: ObservableObject {
if let player, let view = player_macos_make_video_view(player) { if let player, let view = player_macos_make_video_view(player) {
videoView = Unmanaged<NSView>.fromOpaque(view).takeRetainedValue() videoView = Unmanaged<NSView>.fromOpaque(view).takeRetainedValue()
state = player_get_state(player) state = player_get_state(player)
volume = Double(player_get_volume(player))
} else { } else {
videoView = nil videoView = nil
commandError = "Could not create the media player." commandError = "Could not create the media player."
} }
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in MainActor.assumeIsolated { self?.poll() }
self?.poll()
}
} }
} }
@@ -41,19 +51,8 @@ final class PlayerModel: ObservableObject {
var canSeek: Bool { canTogglePlayback && state.duration.isFinite && state.duration > 0 } var canSeek: Bool { canTogglePlayback && state.duration.isFinite && state.duration > 0 }
var canOpen: Bool { handle != nil && !isBusy } var canOpen: Bool { handle != nil && !isBusy }
var canClose: Bool { handle != nil && !isBusy && state.status != PLAYER_EMPTY } 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 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? { var errorText: String? {
if let commandError { return commandError } if let commandError { return commandError }
guard state.status == PLAYER_ERROR else { return nil } guard state.status == PLAYER_ERROR else { return nil }
@@ -66,6 +65,7 @@ final class PlayerModel: ObservableObject {
func openPanel() { func openPanel() {
guard canOpen else { return } guard canOpen else { return }
revealControls()
let panel = NSOpenPanel() let panel = NSOpenPanel()
panel.canChooseFiles = true panel.canChooseFiles = true
panel.canChooseDirectories = false panel.canChooseDirectories = false
@@ -75,8 +75,13 @@ final class PlayerModel: ObservableObject {
open(url) open(url)
} }
private func open(_ url: URL) { func open(_ url: URL) {
guard let handle else { return } 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 revision = player_get_state(handle).revision
let accepted = url.withUnsafeFileSystemRepresentation { path in let accepted = url.withUnsafeFileSystemRepresentation { path in
guard let path else { return false } guard let path else { return false }
@@ -92,12 +97,16 @@ final class PlayerModel: ObservableObject {
commandError = nil commandError = nil
isSeeking = false isSeeking = false
seekPosition = 0 seekPosition = 0
window?.representedURL = url
window?.title = url.lastPathComponent
revealControls()
} }
func togglePlayback() { func togglePlayback() {
guard let handle, canTogglePlayback else { return } guard let handle, canTogglePlayback else { return }
let accepted = state.status == PLAYER_PLAYING ? player_pause(handle) : player_play(handle) let accepted = state.status == PLAYER_PLAYING ? player_pause(handle) : player_play(handle)
commandError = accepted ? nil : "Could not queue playback. Please try again." commandError = accepted ? nil : "Could not queue playback. Please try again."
revealControls()
} }
func finishSeeking() { func finishSeeking() {
@@ -105,6 +114,7 @@ final class PlayerModel: ObservableObject {
guard let handle, canSeek else { return } guard let handle, canSeek else { return }
let position = min(max(0, seekPosition), duration) let position = min(max(0, seekPosition), duration)
commandError = player_seek(handle, position) ? nil : "Could not queue the seek. Please try again." commandError = player_seek(handle, position) ? nil : "Could not queue the seek. Please try again."
revealControls()
} }
func closeFile() { func closeFile() {
@@ -116,6 +126,108 @@ final class PlayerModel: ObservableObject {
fileName = nil fileName = nil
commandError = nil commandError = nil
isSeeking = false 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() { private func poll() {
@@ -125,15 +237,20 @@ final class PlayerModel: ObservableObject {
guard next.revision != revision, next.status != PLAYER_OPENING else { return } guard next.revision != revision, next.status != PLAYER_OPENING else { return }
pendingRevision = nil pendingRevision = nil
} }
let statusChanged = state.status != next.status
state = next state = next
if !isSeeking { if !isSeeking {
seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration) seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration)
} }
if statusChanged { revealControls() }
if let hideDeadline, ContinuousClock.now >= hideDeadline { hideControls() }
} }
func shutdown() { func shutdown() {
timer?.invalidate() timer?.invalidate()
timer = nil timer = nil
hideDeadline = nil
NSCursor.setHiddenUntilMouseMoves(false)
guard let player = handle else { return } guard let player = handle else { return }
handle = nil handle = nil
player_destroy(player) player_destroy(player)
@@ -151,6 +268,12 @@ private struct VideoSurface: NSViewRepresentable {
@MainActor @MainActor
private struct PlayerContent: View { private struct PlayerContent: View {
@ObservedObject var model: PlayerModel @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 { private func time(_ seconds: Double) -> String {
let value = Int(min(max(0, seconds.isFinite ? seconds : 0), Double(Int.max / 2))) 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) : 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 { var body: some View {
VStack(spacing: 0) { GeometryReader { geometry in
ZStack { ZStack(alignment: .top) {
Color.black Color.black
if let view = model.videoView { if let view = model.videoView {
VideoSurface(view: view) VideoSurface(view: view)
.accessibilityLabel("Video")
.gesture(
TapGesture(count: 2).onEnded { model.toggleFullScreen() }
.exclusively(before: TapGesture().onEnded { model.hideControls() })
)
} }
if model.fileName == nil { if let error = model.errorText {
VStack(spacing: 12) { VStack(spacing: 12) {
Image(systemName: "play.rectangle").font(.system(size: 48)) Image(systemName: "exclamationmark.triangle").font(.system(size: 32)).foregroundStyle(.yellow)
Text("Open a local audio or video file") Text("Unable to Play").font(.headline)
Button("Open File…", action: model.openPanel) Text(error).font(.callout).multilineTextAlignment(.center).textSelection(.enabled)
.disabled(!model.canOpen) Button("Open Another 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)
} }
.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) .frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityLabel("Video") } 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)
}
VStack(alignment: .leading, spacing: 10) { titlebar
HStack {
Button(action: model.togglePlayback) { if model.canTogglePlayback {
Image(systemName: model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill") playbackControls
.frame(width: 22) .frame(width: min(460, geometry.size.width - 32))
} .background {
.keyboardShortcut(.space, modifiers: []) RoundedRectangle(cornerRadius: 12)
.accessibilityLabel(model.state.status == PLAYER_PLAYING ? "Pause" : "Play") .fill(.ultraThinMaterial)
.disabled(!model.canTogglePlayback) .gesture(DragGesture(minimumDistance: 4).onChanged { value in
Text(time(model.seekPosition)).monospacedDigit().frame(minWidth: 52, alignment: .trailing) model.setControlsDragging(true)
Slider(value: $model.seekPosition, in: 0...max(model.duration, 0.001), onEditingChanged: { editing in controlOffset = CGSize(width: dragOrigin.width + value.translation.width / geometry.size.width,
if editing { model.isSeeking = true } else { model.finishSeeking() } 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)
}) })
.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") .overlay { RoundedRectangle(cornerRadius: 12).strokeBorder(.white.opacity(0.13), lineWidth: 1).allowsHitTesting(false) }
Text(model.statusText).font(.caption).foregroundStyle(.secondary) .shadow(color: .black.opacity(0.3), radius: 16, y: 6)
if let error = model.errorText { .onGeometryChange(for: CGSize.self) { $0.size } action: { controlSize = $0 }
Text(error).font(.callout).foregroundStyle(.red).textSelection(.enabled) .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)
} }
} }
.padding() .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)
}
}
}
.ignoresSafeArea()
.frame(minWidth: 560, minHeight: 360) .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) 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 applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
@@ -236,10 +637,11 @@ struct PlayerApp: App {
@StateObject private var model = PlayerModel.shared @StateObject private var model = PlayerModel.shared
var body: some Scene { var body: some Scene {
Window("Player", id: "player") { Window("\(model.fileName ?? "Player")", id: "player") {
PlayerContent(model: model) PlayerContent(model: model)
} }
.defaultSize(width: 900, height: 600) .defaultSize(width: 900, height: 560)
.windowStyle(.hiddenTitleBar)
.commands { .commands {
CommandGroup(replacing: .newItem) { CommandGroup(replacing: .newItem) {
Button("Open File…", action: model.openPanel).keyboardShortcut("o") Button("Open File…", action: model.openPanel).keyboardShortcut("o")
@@ -248,6 +650,27 @@ struct PlayerApp: App {
.keyboardShortcut("w", modifiers: [.command, .shift]) .keyboardShortcut("w", modifiers: [.command, .shift])
.disabled(!model.canClose) .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)
}
} }
} }
} }
+3
View File
@@ -18,6 +18,9 @@ public:
bool configure_audio(std::string &error); bool configure_audio(std::string &error);
bool start(std::string &error); bool start(std::string &error);
void pause(); 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. // Called with audio stopped. Clears audio and video from the old timeline.
void reset(); void reset();
std::size_t write(const float *samples, std::size_t frames, double pts); std::size_t write(const float *samples, std::size_t frames, double pts);
+10
View File
@@ -748,6 +748,16 @@ bool player_seek(Player *player, double seconds) {
bool player_close(Player *player) { bool player_close(Player *player) {
return submit(player, [=] { return player->enqueue({Action::Close, {}}); }); 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) { PlayerState player_get_state(Player *player) {
try { try {
if (player) { if (player) {
+6 -1
View File
@@ -21,7 +21,7 @@ typedef struct PlayerState {
char error[512]; char error[512];
} PlayerState; } 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. // Paths are copied before returning. All other calls require a live handle.
Player *player_create(void); Player *player_create(void);
bool player_open(Player *player, const char *path); bool player_open(Player *player, const char *path);
@@ -29,6 +29,11 @@ bool player_play(Player *player);
bool player_pause(Player *player); bool player_pause(Player *player);
bool player_seek(Player *player, double seconds); bool player_seek(Player *player, double seconds);
bool player_close(Player *player); 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); PlayerState player_get_state(Player *player);
// Joins the worker and stops audio; never invoke from an audio callback. // Joins the worker and stops audio; never invoke from an audio callback.
void player_destroy(Player *player); void player_destroy(Player *player);
+38
View File
@@ -244,6 +244,12 @@ struct Output::Impl {
AudioUnit unit = nullptr; AudioUnit unit = nullptr;
std::array<float, capacity * 2> samples{}; std::array<float, capacity * 2> samples{};
std::atomic<uint64_t> read{0}, written{0}; std::atomic<uint64_t> read{0}, written{0};
static_assert(std::atomic<float>::is_always_lock_free);
std::atomic<float> 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 origin = 0;
double latency = 0; double latency = 0;
std::atomic<uint64_t> clockVersion{0}; std::atomic<uint64_t> clockVersion{0};
@@ -260,9 +266,31 @@ struct Output::Impl {
uint64_t w = self.written.load(std::memory_order_acquire); uint64_t w = self.written.load(std::memory_order_acquire);
size_t frames = std::min<uint64_t>(count, w - r); size_t frames = std::min<uint64_t>(count, w - r);
float *destination = static_cast<float *>(buffers->mBuffers[0].mData); float *destination = static_cast<float *>(buffers->mBuffers[0].mData);
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)); 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, self.samples.data() + (r % capacity) * 2, first * 2 * sizeof(float));
std::memcpy(destination + first * 2, self.samples.data(), (frames - 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<float>(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<float>(self.currentGain);
}
#ifdef PLAYER_AUDIO_CHECK #ifdef PLAYER_AUDIO_CHECK
player_check_audio(destination, count); player_check_audio(destination, count);
#endif #endif
@@ -345,6 +373,10 @@ bool Output::configure_audio(std::string &error) {
bool Output::start(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->unit) return true; // Video-only playback does not open an audio device.
if (impl->running) return true; 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); OSStatus status = AudioOutputUnitStart(impl->unit);
if (status != noErr) { error = "Could not start Core Audio (" + std::to_string(status) + ")"; return false; } if (status != noErr) { error = "Could not start Core Audio (" + std::to_string(status) + ")"; return false; }
impl->running = true; impl->running = true;
@@ -357,6 +389,12 @@ void Output::pause() {
impl->running = false; impl->running = false;
impl->frozen.store(position, std::memory_order_release); 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() { void Output::reset() {
impl->read.store(0); impl->written.store(0); impl->read.store(0); impl->written.store(0);
impl->origin = 0; impl->origin = 0;