feat: adopt IINA-style playback window
This commit is contained in:
@@ -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, decltype(&player_destroy)> 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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key><string>0.1</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>NSPrincipalClass</key><string>NSApplication</string>
|
||||
</dict>
|
||||
|
||||
+484
-61
@@ -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<NSView>.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
+41
-3
@@ -244,6 +244,12 @@ struct Output::Impl {
|
||||
AudioUnit unit = nullptr;
|
||||
std::array<float, capacity * 2> samples{};
|
||||
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 latency = 0;
|
||||
std::atomic<uint64_t> clockVersion{0};
|
||||
@@ -260,9 +266,31 @@ struct Output::Impl {
|
||||
uint64_t w = self.written.load(std::memory_order_acquire);
|
||||
size_t frames = std::min<uint64_t>(count, w - r);
|
||||
float *destination = static_cast<float *>(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<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
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user