commit 8a89d88254c4cba9a3aea9fb5248eec65b8e5d6b Author: hl-valdemar Date: Sat Oct 10 01:07:49 2026 +0200 feat: add native local-file playback diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e94f7ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.build/ +.DS_Store diff --git a/Checks/playback.mm b/Checks/playback.mm new file mode 100644 index 0000000..60fc380 --- /dev/null +++ b/Checks/playback.mm @@ -0,0 +1,102 @@ +#include "../Sources/Core/player_macos.h" +#import +#import +#include +#include +#include +#include +#include +#include +#include + +static void require(bool condition, const char *message) { + if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); } +} +// Runtime smoke: the actual audio device and monotonic playback clock are exercised. +// No calendar dates, sleeps standing in for command completion, or mocked outputs. +static PlayerState until(Player *player, const std::function &ready, bool expectError = false) { + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10); + while (std::chrono::steady_clock::now() < deadline) { + auto state = player_get_state(player); + if (state.status == PLAYER_ERROR && !expectError) { + std::fprintf(stderr, "Playback error: %s\n", state.error); + std::exit(1); + } + if (ready(state)) return state; + CFRunLoopRunInMode(kCFRunLoopDefaultMode, .005, false); + } + auto state = player_get_state(player); + std::fprintf(stderr, "Timeout: status=%d position=%.4f duration=%.4f\n", + state.status, state.position, state.duration); + std::exit(1); +} + +int main(int argc, char **argv) { + @autoreleasepool { + require(argc == 2, "fixture directory argument required"); + [NSApplication sharedApplication]; + std::unique_ptr player(player_create(), player_destroy); + require(bool(player), "player creation"); + void *native = player_macos_make_video_view(player.get()); + require(native != nullptr, "Metal view and shaders initialize"); + MTKView *view = (__bridge_transfer MTKView *)native; + // Attach the real renderer to an offscreen native window, not a browser. + NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(-2000, -2000, 320, 180) + styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO]; + window.contentView = view; + [window orderBack:nil]; + + for (const char *fixture : {"av.mp4", "video.mp4", "audio.wav"}) { + std::string path = std::string(argv[1]) + "/" + fixture; + require(player_open(player.get(), path.c_str()), "open accepted"); + auto opened = until(player.get(), [](auto s) { return s.status == PLAYER_PAUSED; }); + require(opened.duration > 3.9 && opened.duration < 4.2, "discovered fixture duration"); + require(opened.has_video == (std::string(fixture) != "audio.wav"), "video track selection"); + require(opened.has_audio == (std::string(fixture) != "video.mp4"), "audio track selection"); + require(!player_seek(player.get(), NAN), "non-finite seek rejected"); + require(player_play(player.get()), "play accepted"); + until(player.get(), [](auto s) { return s.status == PLAYER_PLAYING && s.position > .35; }); + [view draw]; + require(player_pause(player.get()), "pause accepted"); + auto paused = until(player.get(), [](auto s) { return s.status == PLAYER_PAUSED; }); + // Observe the device through several run-loop turns while paused. + for (int i = 0; i < 20; ++i) + CFRunLoopRunInMode(kCFRunLoopDefaultMode, .005, false); + require(std::abs(player_get_state(player.get()).position - paused.position) < .002, + "pause freezes authoritative position"); + uint64_t revision = paused.revision; + require(player_seek(player.get(), 1.5), "paused seek accepted"); + auto sought = until(player.get(), [revision](auto s) { + return s.revision != revision && s.status == PLAYER_PAUSED && std::abs(s.position - 1.5) < .03; + }); + [view draw]; + require(sought.status == PLAYER_PAUSED, "seek preserves paused state"); + require(player_play(player.get()), "resume accepted"); + until(player.get(), [](auto s) { return s.status == PLAYER_PLAYING && s.position > 1.7; }); + require(player_seek(player.get(), 3.2), "playing seek accepted"); + until(player.get(), [](auto s) { return s.status == PLAYER_PLAYING && s.position >= 3.2; }); + auto ended = until(player.get(), [](auto s) { return s.status == PLAYER_ENDED; }); + require(ended.position >= 3.8, "EOF drains media before completion"); + require(player_play(player.get()), "replay accepted"); + until(player.get(), [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1 && s.position < 1; }); + require(player_close(player.get()), "close accepted while playing"); + auto closed = until(player.get(), [](auto s) { return s.status == PLAYER_EMPTY; }); + require(closed.position == 0 && !closed.has_audio && !closed.has_video, "close clears media state"); + std::printf("PASS %s: open/play/pause/seek/end/replay/close\n", fixture); + } + require(player_open(player.get(), (std::string(argv[1]) + "/hdr.mkv").c_str()), "HDR open accepted"); + auto hdr = until(player.get(), [](auto s) { return s.status == PLAYER_ERROR; }, true); + require(std::string(hdr.error).find("HDR") != std::string::npos, "HDR reports unsupported output"); + require(player_open(player.get(), "/nonexistent/personal-player-smoke.mp4"), "error open accepted"); + auto failed = until(player.get(), [](auto s) { return s.status == PLAYER_ERROR; }, true); + require(failed.error[0], "failed open reports an error"); + require(player_open(player.get(), (std::string(argv[1]) + "/av.mp4").c_str()), "recover after error"); + until(player.get(), [](auto s) { return s.status == PLAYER_PAUSED; }); + require(player_play(player.get()), "play before destruction"); + until(player.get(), [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1; }); + player.reset(); + [view draw]; // Native view may outlive the player without a dangling engine pointer. + [window orderOut:nil]; + std::puts("PASS HDR rejection/errors/recovery/destroy while playing/native view lifetime"); + } +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5f8ed59 --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +CXX := xcrun clang++ +SWIFT := xcrun swiftc +FFMPEG := libavformat libavcodec libavutil libswresample libswscale +FF_CFLAGS := $(shell pkg-config --cflags $(FFMPEG)) +FF_LIBS := $(shell pkg-config --libs $(FFMPEG)) +ARCH := $(shell uname -m) +CXXFLAGS := -std=c++20 -mmacosx-version-min=15.0 -O2 -g -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS) +FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore +OBJECTS := .build/player.o .build/Output.o +APP := .build/Player.app + +.PHONY: all run check +all: $(APP)/Contents/MacOS/Player $(APP)/Contents/Info.plist + +.build: + mkdir -p $@ + +.build/player.o: Sources/Core/player.cpp Sources/Core/player.h Sources/Core/player_macos.h Sources/Core/output.hpp Makefile | .build + $(CXX) $(CXXFLAGS) -c $< -o $@ + +.build/Output.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | .build + $(CXX) $(CXXFLAGS) -fobjc-arc -c $< -o $@ + +$(APP)/Contents/MacOS/Player: Sources/App/PlayerApp.swift $(OBJECTS) Makefile + mkdir -p $(APP)/Contents/MacOS + $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O -g -import-objc-header Sources/Core/player_macos.h $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@ + +$(APP)/Contents/Info.plist: Resources/Info.plist + mkdir -p $(APP)/Contents + cp $< $@ + +run: all + open $(APP) + +.build/playback-check: Checks/playback.mm $(OBJECTS) Makefile + $(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@ + +check: .build/playback-check + mkdir -p .build/fixtures + ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 4 -c:v mpeg4 -bf 2 -c:a aac .build/fixtures/av.mp4 + ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -an -c:v copy .build/fixtures/video.mp4 + ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -vn -c:a pcm_s16le .build/fixtures/audio.wav + ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=160x90:rate=12 -t 1 -vf 'format=yuv420p10le,setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc' -c:v ffv1 .build/fixtures/hdr.mkv + .build/playback-check .build/fixtures diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e0b088 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Player + +A personal native macOS player whose playback code is small enough to own and modify. FFmpeg reads and decodes media; our C++ worker owns playback. SwiftUI owns the controls, Core Audio owns audio output, and Metal presents video. + +## Build and run + +Requires macOS 15+, Xcode's command-line tools with Swift 6, and Homebrew FFmpeg/pkg-config: + +```sh +brew install ffmpeg pkg-config +make +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. + +## 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.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. +- `Makefile`: compiler/linker commands and app bundle construction. No generated Xcode project or additional build dependency. + +## Runnable check + +```sh +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. + +Visual correctness, perceived A/V synchronization, and control usability still need a check in the visible app. + +## Current limits + +Local regular files, automatically selected best audio/video tracks, software decoding, and SDR output. PQ/HLG HDR is rejected rather than rendered incorrectly. No subtitles, selectable tracks, VideoToolbox decoding, rotation/deinterlacing, or network playback yet. Mid-track audio format changes report an error. + +Audio buffering is bounded to one second plus one decoded block (up to four seconds); video is bounded to 64 frames/128 MiB. Extremely interleaved files can drop far-future video frames to avoid starving audio; the ceiling and upgrade path are marked at `queueVideo()`. Device/stream latency is accounted for, but device changes during playback are not handled yet. diff --git a/Resources/Info.plist b/Resources/Info.plist new file mode 100644 index 0000000..9871d34 --- /dev/null +++ b/Resources/Info.plist @@ -0,0 +1,14 @@ + + + + + CFBundleExecutablePlayer + CFBundleIdentifierlocal.personal.player + CFBundleNamePlayer + CFBundlePackageTypeAPPL + CFBundleShortVersionString0.1 + LSMinimumSystemVersion15.0 + NSHighResolutionCapable + NSPrincipalClassNSApplication + + diff --git a/Sources/App/PlayerApp.swift b/Sources/App/PlayerApp.swift new file mode 100644 index 0000000..6496134 --- /dev/null +++ b/Sources/App/PlayerApp.swift @@ -0,0 +1,253 @@ +import AppKit +import SwiftUI + +@MainActor +final class PlayerModel: ObservableObject { + static let shared = PlayerModel() + + @Published private(set) var state = PlayerState() + @Published private(set) var fileName: String? + @Published private(set) var commandError: String? + @Published private(set) var pendingRevision: UInt64? + @Published var seekPosition = 0.0 + @Published var isSeeking = false + + let videoView: NSView? + private var handle: OpaquePointer? + private var timer: Timer? + + private init() { + let player = player_create() + handle = player + if let player, let view = player_macos_make_video_view(player) { + videoView = Unmanaged.fromOpaque(view).takeRetainedValue() + state = player_get_state(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() + } + } + } + + var isBusy: Bool { pendingRevision != nil || state.status == PLAYER_OPENING } + var canTogglePlayback: Bool { + handle != nil && !isBusy && + (state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED) + } + 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 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 } + var error = state.error + return withUnsafeBytes(of: &error) { bytes in + let message = String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self) + return message.isEmpty ? "An unknown playback error occurred." : message + } + } + + func openPanel() { + guard canOpen else { return } + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.prompt = "Open" + guard panel.runModal() == .OK, let url = panel.url else { return } + open(url) + } + + private func open(_ url: URL) { + guard let handle else { return } + let revision = player_get_state(handle).revision + let accepted = url.withUnsafeFileSystemRepresentation { path in + guard let path else { return false } + return player_open(handle, path) + } + guard accepted else { + commandError = "Could not queue the file to open. Please try again." + return + } + // Open is always paused; never queue play against the previous file. + pendingRevision = revision + fileName = url.lastPathComponent + commandError = nil + isSeeking = false + seekPosition = 0 + } + + 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." + } + + func finishSeeking() { + isSeeking = false + guard let handle, canSeek else { return } + let position = min(max(0, seekPosition), duration) + commandError = player_seek(handle, position) ? nil : "Could not queue the seek. Please try again." + } + + func closeFile() { + guard let handle, canClose else { return } + guard player_close(handle) else { + commandError = "Could not queue closing the file. Please try again." + return + } + fileName = nil + commandError = nil + isSeeking = false + } + + private func poll() { + guard let handle else { return } + let next = player_get_state(handle) + if let revision = pendingRevision { + guard next.revision != revision, next.status != PLAYER_OPENING else { return } + pendingRevision = nil + } + state = next + if !isSeeking { + seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration) + } + } + + func shutdown() { + timer?.invalidate() + timer = nil + guard let player = handle else { return } + handle = nil + player_destroy(player) + } +} + +@MainActor +private struct VideoSurface: NSViewRepresentable { + let view: NSView + + func makeNSView(context: Context) -> NSView { view } + func updateNSView(_ nsView: NSView, context: Context) {} +} + +@MainActor +private struct PlayerContent: View { + @ObservedObject var model: PlayerModel + + private func time(_ seconds: Double) -> String { + let value = Int(min(max(0, seconds.isFinite ? seconds : 0), Double(Int.max / 2))) + let hours = value / 3600 + return hours > 0 + ? String(format: "%d:%02d:%02d", hours, value / 60 % 60, value % 60) + : String(format: "%d:%02d", value / 60, value % 60) + } + + var body: some View { + VStack(spacing: 0) { + ZStack { + Color.black + if let view = model.videoView { + VideoSurface(view: view) + } + 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) + } + } + .padding() + } + .frame(minWidth: 560, minHeight: 360) + .navigationTitle(model.fileName ?? "Player") + } +} + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } + + func applicationWillTerminate(_ notification: Notification) { + PlayerModel.shared.shutdown() + } +} + +@main +@MainActor +struct PlayerApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate + @StateObject private var model = PlayerModel.shared + + var body: some Scene { + Window("Player", id: "player") { + PlayerContent(model: model) + } + .defaultSize(width: 900, height: 600) + .commands { + CommandGroup(replacing: .newItem) { + Button("Open File…", action: model.openPanel).keyboardShortcut("o") + .disabled(!model.canOpen) + Button("Close File", action: model.closeFile) + .keyboardShortcut("w", modifiers: [.command, .shift]) + .disabled(!model.canClose) + } + } + } +} diff --git a/Sources/Core/output.hpp b/Sources/Core/output.hpp new file mode 100644 index 0000000..aaf47ad --- /dev/null +++ b/Sources/Core/output.hpp @@ -0,0 +1,35 @@ +#pragma once +#include +#include +#include +extern "C" { +#include +} + +namespace playback { +// One decoding worker produces output. The native audio callback is the consumer. +// Audio is interleaved stereo float at 48 kHz; write() never blocks. +class Output { +public: + Output(); + ~Output(); + Output(const Output &) = delete; + Output &operator=(const Output &) = delete; + bool configure_audio(std::string &error); + bool start(std::string &error); + void pause(); + // 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); + std::size_t queued() const; + // Latency-aware presented position; NaN until an audio callback has run. + double clock() const; + // Retains the frame's buffers; decoder may reuse its AVFrame after return. + void present(const AVFrame *frame); + // Returns a retained NSView. The view remains safe after Output is destroyed. + void *make_video_view(); +private: + struct Impl; + std::unique_ptr impl; +}; +} diff --git a/Sources/Core/player.cpp b/Sources/Core/player.cpp new file mode 100644 index 0000000..0fd9152 --- /dev/null +++ b/Sources/Core/player.cpp @@ -0,0 +1,781 @@ +#include "player.h" +#include "player_macos.h" +#include "output.hpp" + +extern "C" { +#include +#include +#include +#include +#include +#include +} + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr int sampleRate = 48000; +constexpr std::size_t commandLimit = 32; +constexpr std::size_t videoFrameLimit = 64; +constexpr std::size_t videoByteLimit = 128 * 1024 * 1024; +constexpr int audioFrameLimit = sampleRate * 4; +constexpr double sampleTime = 1.0 / sampleRate; +using Clock = std::chrono::steady_clock; + +struct Cancelled {}; +struct FrameDeleter { + void operator()(AVFrame *frame) const { av_frame_free(&frame); } +}; +using Frame = std::unique_ptr; +struct VideoFrame { + Frame frame; + double pts; + std::size_t bytes; +}; +struct Track { + AVCodecContext *codec = nullptr; + int index = -1; + bool drainSent = false; + bool drained = false; + double nextPts = 0; +}; +enum class Action { Open, Play, Pause, Seek, Close }; +struct Command { + Action action; + std::string path; + double seconds = 0; + uint64_t epoch = 0; +}; + +void copyError(char *destination, const char *message) noexcept { + std::snprintf(destination, 512, "%s", message ? message : "Unknown playback error"); +} +} + +struct Player { + playback::Output output; + std::mutex mutex; + std::condition_variable wake; + std::deque commands; + PlayerState state{}; + std::array requestedError{}; + bool errorRequested = false; + uint64_t errorEpoch = 0; + std::atomic stopping{false}; + std::atomic requestedEpoch{0}; + uint64_t activeEpoch = 0; + + // Everything below, including FFmpeg and Output audio operations, is worker-owned. + AVFormatContext *format = nullptr; + std::array tracks; + AVPacket *packet = nullptr; + AVFrame *decoded = nullptr; + SwrContext *resampler = nullptr; + AVChannelLayout inputLayout{}; + int inputRate = 0; + AVSampleFormat inputFormat = AV_SAMPLE_FMT_NONE; + bool resamplerDrained = false; + bool packetPending = false; + bool demuxEnded = false; + int receiving = -1; + std::deque video; + std::size_t videoBytes = 0; + std::vector pcm; + std::size_t pcmFrames = 0; + std::size_t pcmOffset = 0; + double pcmPts = 0; + double resampleNextPts = 0; + double origin = 0; + double duration = 0; + double position = 0; + double seekTarget = 0; + double audioWriteEnd = 0; + double videoEnd = 0; + double videoFrameTime = 1.0 / 25; + double steadyPosition = 0; + Clock::time_point steadyAnchor = Clock::now(); + PlayerStatus status = PLAYER_EMPTY; + bool previewShown = false; + std::array error{}; + std::thread worker; + + Player() : worker([this] { run(); }) {} + ~Player() { + stopping.store(true, std::memory_order_relaxed); + requestedEpoch.fetch_add(1, std::memory_order_relaxed); + wake.notify_one(); + if (worker.joinable()) worker.join(); + } + + bool enqueue(Command command) { + std::lock_guard lock(mutex); + if (stopping.load(std::memory_order_relaxed) || commands.size() >= commandLimit) + return false; + const bool newTimeline = command.action == Action::Open || command.action == Action::Close; + command.epoch = requestedEpoch.load(std::memory_order_relaxed) + (newTimeline ? 1 : 0); + commands.push_back(std::move(command)); + if (newTimeline) requestedEpoch.store(commands.back().epoch, std::memory_order_relaxed); + if (commands.back().action == Action::Open) { + const auto revision = state.revision + 1; + state = {}; + state.status = PLAYER_OPENING; + state.revision = revision; + } + wake.notify_one(); + return true; + } + + void apiError(const char *message) noexcept { + try { + std::lock_guard lock(mutex); + copyError(requestedError.data(), message); + errorRequested = true; + errorEpoch = requestedEpoch.fetch_add(1, std::memory_order_relaxed) + 1; + state.status = PLAYER_ERROR; + copyError(state.error, message); + ++state.revision; + wake.notify_one(); + } catch (...) { + // No exception may cross the C boundary, including a failed mutex operation. + } + } + + bool interrupted() const { + return stopping.load(std::memory_order_relaxed) || + requestedEpoch.load(std::memory_order_relaxed) != activeEpoch; + } + static int interrupt(void *opaque) { + return static_cast(opaque)->interrupted(); + } + void check(int result, const char *operation) { + if (result >= 0) return; + if (interrupted()) throw Cancelled{}; + char details[AV_ERROR_MAX_STRING_SIZE]; + av_strerror(result, details, sizeof(details)); + throw std::runtime_error(std::string(operation) + ": " + details); + } + + void publish() { + std::lock_guard lock(mutex); + if (activeEpoch != requestedEpoch.load(std::memory_order_relaxed)) return; + state.status = status; + state.position = position; + state.duration = duration; + state.has_audio = tracks[0].codec != nullptr; + state.has_video = tracks[1].codec != nullptr; + std::memcpy(state.error, error.data(), error.size()); + ++state.revision; + } + + void release() { + output.pause(); + output.reset(); + video.clear(); + videoBytes = 0; + swr_free(&resampler); + av_channel_layout_uninit(&inputLayout); + for (auto &track : tracks) { + avcodec_free_context(&track.codec); + track = {}; + } + av_packet_free(&packet); + av_frame_free(&decoded); + avformat_close_input(&format); + inputRate = 0; + inputFormat = AV_SAMPLE_FMT_NONE; + pcmFrames = pcmOffset = 0; + packetPending = demuxEnded = resamplerDrained = false; + receiving = -1; + origin = duration = position = seekTarget = audioWriteEnd = videoEnd = resampleNextPts = 0; + previewShown = false; + error.fill(0); + status = PLAYER_EMPTY; + } + + void fail(const char *message) { + // Preserve messages that point into one of this object's own state buffers. + std::array saved{}; + copyError(saved.data(), message); + release(); + error = saved; + status = PLAYER_ERROR; + publish(); + } + + void openTrack(int slot, AVMediaType type, int related) { + const AVCodec *decoder = nullptr; + int index = av_find_best_stream(format, type, -1, related, &decoder, 0); + if (index == AVERROR_STREAM_NOT_FOUND) return; + check(index, type == AVMEDIA_TYPE_AUDIO ? "Finding audio decoder" : "Finding video decoder"); + auto &track = tracks[slot]; + track.index = index; + track.codec = avcodec_alloc_context3(decoder); + if (!track.codec) throw std::bad_alloc(); + check(avcodec_parameters_to_context(track.codec, format->streams[index]->codecpar), + "Reading codec parameters"); + track.codec->pkt_timebase = format->streams[index]->time_base; + // A bounded software decoder pool; FFmpeg owns its internal frame reorder buffer. + track.codec->thread_count = 2; + check(avcodec_open2(track.codec, decoder, nullptr), "Opening decoder"); + } + + void resetTimeline(double target) { + output.pause(); + output.reset(); + for (auto &track : tracks) { + if (!track.codec) continue; + avcodec_flush_buffers(track.codec); + track.drainSent = track.drained = false; + const AVStream *stream = format->streams[track.index]; + track.nextPts = stream->start_time == AV_NOPTS_VALUE ? target : + std::max(target, stream->start_time * av_q2d(stream->time_base) - origin); + } + swr_free(&resampler); + av_channel_layout_uninit(&inputLayout); + inputRate = 0; + inputFormat = AV_SAMPLE_FMT_NONE; + av_packet_unref(packet); + av_frame_unref(decoded); + video.clear(); + videoBytes = 0; + pcmFrames = pcmOffset = 0; + packetPending = demuxEnded = resamplerDrained = false; + receiving = -1; + position = seekTarget = audioWriteEnd = videoEnd = resampleNextPts = target; + previewShown = false; + steadyPosition = target; + steadyAnchor = Clock::now(); + } + + void open(const std::string &path) { + release(); + status = PLAYER_OPENING; + publish(); + std::error_code filesystemError; + if (!std::filesystem::is_regular_file(path, filesystemError)) + throw std::runtime_error(filesystemError ? filesystemError.message() : + "Open requires a local regular media file"); + const std::string localPath = std::filesystem::absolute(path).string(); + format = avformat_alloc_context(); + if (!format) throw std::bad_alloc(); + format->interrupt_callback = {interrupt, this}; + AVDictionary *options = nullptr; + const int optionResult = av_dict_set(&options, "protocol_whitelist", "file", 0); + if (optionResult < 0) { + av_dict_free(&options); + check(optionResult, "Restricting input to local files"); + } + const int result = avformat_open_input(&format, localPath.c_str(), nullptr, &options); + av_dict_free(&options); + check(result, "Opening media"); + check(avformat_find_stream_info(format, nullptr), "Reading media information"); + openTrack(1, AVMEDIA_TYPE_VIDEO, -1); + openTrack(0, AVMEDIA_TYPE_AUDIO, tracks[1].index); + if (!tracks[0].codec && !tracks[1].codec) + throw std::runtime_error("The file contains no playable audio or video track"); + for (unsigned i = 0; i < format->nb_streams; ++i) { + if (static_cast(i) != tracks[0].index && static_cast(i) != tracks[1].index) + format->streams[i]->discard = AVDISCARD_ALL; + } + if (format->start_time != AV_NOPTS_VALUE) { + origin = static_cast(format->start_time) / AV_TIME_BASE; + } else { + double first = std::numeric_limits::infinity(); + for (const auto &track : tracks) { + if (!track.codec) continue; + const AVStream *stream = format->streams[track.index]; + if (stream->start_time != AV_NOPTS_VALUE) + first = std::min(first, stream->start_time * av_q2d(stream->time_base)); + } + origin = std::isfinite(first) ? first : 0; + } + if (format->duration != AV_NOPTS_VALUE && format->duration > 0) + duration = static_cast(format->duration) / AV_TIME_BASE; + if (duration == 0) { + for (const auto &track : tracks) { + if (!track.codec) continue; + const AVStream *stream = format->streams[track.index]; + if (stream->duration == AV_NOPTS_VALUE || stream->duration <= 0) continue; + const double start = stream->start_time == AV_NOPTS_VALUE ? origin : + stream->start_time * av_q2d(stream->time_base); + duration = std::max(duration, start - origin + stream->duration * av_q2d(stream->time_base)); + } + } + if (tracks[1].codec) { + const AVRational rate = av_guess_frame_rate(format, format->streams[tracks[1].index], nullptr); + videoFrameTime = rate.num > 0 && rate.den > 0 ? av_q2d(av_inv_q(rate)) : 1.0 / 25; + } + if (tracks[0].codec) { + std::string deviceError; + if (!output.configure_audio(deviceError)) + throw std::runtime_error(deviceError.empty() ? "Cannot configure audio output" : deviceError); + } + packet = av_packet_alloc(); + decoded = av_frame_alloc(); + if (!packet || !decoded) throw std::bad_alloc(); + resetTimeline(0); + status = PLAYER_PAUSED; + publish(); + } + + double clockPosition() const { + if (status != PLAYER_PLAYING) return position; + if (tracks[0].codec) { + const double audioClock = output.clock(); + return std::isfinite(audioClock) ? std::max(seekTarget, audioClock) : position; + } + return steadyPosition + std::chrono::duration(Clock::now() - steadyAnchor).count(); + } + + void start() { + if (!format || status == PLAYER_ERROR || status == PLAYER_EMPTY) return; + if (status == PLAYER_ENDED) seek(0, false); + if (status == PLAYER_PLAYING) return; + if (tracks[0].codec) { + std::string deviceError; + if (!output.start(deviceError)) + throw std::runtime_error(deviceError.empty() ? "Cannot start audio output" : deviceError); + } + steadyPosition = position; + steadyAnchor = Clock::now(); + status = PLAYER_PLAYING; + } + + void pause() { + if (status != PLAYER_PLAYING) return; + position = clockPosition(); + output.pause(); + if (tracks[0].codec) { + const double frozen = output.clock(); + if (std::isfinite(frozen)) position = std::max(seekTarget, frozen); + } + status = PLAYER_PAUSED; + } + + void seek(double seconds, bool resume) { + if (!format) return; + const double target = duration > 0 ? std::clamp(seconds, 0.0, duration) : std::max(0.0, seconds); + const long double absolute = std::round((static_cast(target) + origin) * AV_TIME_BASE); + if (absolute < -0x1p63L || absolute >= 0x1p63L) + throw std::runtime_error("Seek target is outside the supported timestamp range"); + output.pause(); + const int64_t timestamp = static_cast(absolute); + check(avformat_seek_file(format, -1, std::numeric_limits::min(), + timestamp, timestamp, 0), "Seeking media"); + resetTimeline(target); + status = PLAYER_PAUSED; + if (resume) start(); + } + + void execute(const Command &command) { + if (command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return; + activeEpoch = command.epoch; + switch (command.action) { + case Action::Open: open(command.path); break; + case Action::Play: start(); break; + case Action::Pause: pause(); break; + case Action::Seek: seek(command.seconds, status == PLAYER_PLAYING); break; + case Action::Close: release(); break; + } + publish(); + } + + double framePts(Track &track, const AVFrame *frame) { + const int64_t timestamp = frame->best_effort_timestamp != AV_NOPTS_VALUE ? + frame->best_effort_timestamp : frame->pts; + if (timestamp == AV_NOPTS_VALUE) return track.nextPts; + return timestamp * av_q2d(format->streams[track.index]->time_base) - origin; + } + + void convertAudio() { + auto &track = tracks[0]; + const int rate = decoded->sample_rate > 0 ? decoded->sample_rate : track.codec->sample_rate; + const AVChannelLayout *layout = decoded->ch_layout.nb_channels > 0 ? + &decoded->ch_layout : &track.codec->ch_layout; + const auto sampleFormat = static_cast(decoded->format); + if (rate <= 0 || layout->nb_channels <= 0 || decoded->nb_samples < 0) + throw std::runtime_error("Invalid decoded audio format"); + if (!resampler) { + AVChannelLayout stereo = AV_CHANNEL_LAYOUT_STEREO; + check(swr_alloc_set_opts2(&resampler, &stereo, AV_SAMPLE_FMT_FLT, sampleRate, + layout, sampleFormat, rate, 0, nullptr), "Allocating audio resampler"); + check(swr_init(resampler), "Initializing audio resampler"); + check(av_channel_layout_copy(&inputLayout, layout), "Saving audio channel layout"); + inputRate = rate; + inputFormat = sampleFormat; + } else if (inputRate != rate || inputFormat != sampleFormat || + av_channel_layout_compare(&inputLayout, layout) != 0) { + throw std::runtime_error("Audio format changes within a track are not supported"); + } + const double timestamp = framePts(track, decoded); + track.nextPts = timestamp + static_cast(decoded->nb_samples) / rate; + pcmPts = timestamp - static_cast(swr_get_delay(resampler, rate)) / rate; + const int capacity = swr_get_out_samples(resampler, decoded->nb_samples); + check(capacity, "Sizing resampled audio"); + if (capacity > audioFrameLimit) + throw std::runtime_error("Decoded audio block exceeds the four-second buffer limit"); + const auto samples = static_cast(capacity) * 2; + if (pcm.size() < samples) pcm.resize(samples); + uint8_t *destination = reinterpret_cast(pcm.data()); + const int count = swr_convert(resampler, &destination, capacity, + const_cast(decoded->extended_data), decoded->nb_samples); + check(count, "Resampling audio"); + resampleNextPts = pcmPts + static_cast(count) / sampleRate; + pcmFrames = static_cast(count); + pcmOffset = 0; + trimAudio(); + } + + void trimAudio() { + if (pcmPts < seekTarget) { + const double skip = std::ceil((seekTarget - pcmPts) * sampleRate - 1e-6); + pcmOffset = static_cast(std::min(skip, static_cast(pcmFrames))); + } + } + + void drainResampler() { + if (!resampler) { + resamplerDrained = true; + return; + } + const int capacity = swr_get_out_samples(resampler, 0); + check(capacity, "Sizing final audio"); + if (capacity > audioFrameLimit) + throw std::runtime_error("Resampler drain exceeds the audio buffer limit"); + // A nonzero destination also lets a resampler with no delayed samples signal completion. + const auto samples = static_cast(std::max(capacity, 32)) * 2; + if (pcm.size() < samples) pcm.resize(samples); + uint8_t *destination = reinterpret_cast(pcm.data()); + const int count = swr_convert(resampler, &destination, std::max(capacity, 32), nullptr, 0); + check(count, "Draining audio resampler"); + pcmPts = resampleNextPts; + resampleNextPts += static_cast(count) / sampleRate; + pcmFrames = static_cast(count); + pcmOffset = 0; + resamplerDrained = count == 0; + trimAudio(); + } + + bool writeSilence(double end) { + if (end <= audioWriteEnd + sampleTime * 0.5) return false; + static constexpr std::array silence{}; + const double remaining = std::round((end - audioWriteEnd) * sampleRate); + const std::size_t frames = static_cast(std::min(remaining, 2048.0)); + if (frames == 0) return false; + const std::size_t accepted = output.write(silence.data(), frames, audioWriteEnd); + audioWriteEnd += static_cast(accepted) / sampleRate; + return accepted != 0; + } + + bool pumpAudio() { + if (pcmOffset >= pcmFrames) return false; + const double start = pcmPts + static_cast(pcmOffset) / sampleRate; + if (start > audioWriteEnd + sampleTime * 0.5) return writeSilence(start); + if (start < audioWriteEnd - sampleTime * 0.5) { + const double overlap = std::round((audioWriteEnd - start) * sampleRate); + const auto skip = static_cast(std::min(overlap, static_cast(pcmFrames - pcmOffset))); + pcmOffset += skip; + if (pcmOffset >= pcmFrames) return true; + } + const std::size_t accepted = output.write(pcm.data() + pcmOffset * 2, + pcmFrames - pcmOffset, audioWriteEnd); + pcmOffset += accepted; + audioWriteEnd += static_cast(accepted) / sampleRate; + return accepted != 0; + } + + bool videoFull() const { + return video.size() >= videoFrameLimit || + (!video.empty() && videoBytes > videoByteLimit - video.back().bytes); + } + + void queueVideo() { + auto &track = tracks[1]; + const auto transfer = decoded->color_trc == AVCOL_TRC_UNSPECIFIED ? + track.codec->color_trc : decoded->color_trc; + if (transfer == AVCOL_TRC_SMPTE2084 || transfer == AVCOL_TRC_ARIB_STD_B67) + throw std::runtime_error("HDR PQ/HLG video is unsupported: the native view currently supports SDR only"); + const double pts = framePts(track, decoded); + const double frameDuration = decoded->duration > 0 ? + decoded->duration * av_q2d(format->streams[track.index]->time_base) : videoFrameTime; + track.nextPts = pts + frameDuration; + if (pts + sampleTime * 0.5 < seekTarget || (decoded->flags & AV_FRAME_FLAG_DISCARD)) return; + const int size = av_image_get_buffer_size(static_cast(decoded->format), + decoded->width, decoded->height, 1); + check(size, "Sizing decoded video"); + const auto bytes = static_cast(size); + if (bytes > videoByteLimit) + throw std::runtime_error("A decoded video frame exceeds the 128 MiB buffer limit"); + videoEnd = std::max(videoEnd, pts + frameDuration); + // ponytail: extreme interleaving can starve audio behind a full video queue. + // Keep nearest frames plus the newest; packet spooling is the upgrade if that matters. + while (!video.empty() && (video.size() >= videoFrameLimit || videoBytes + bytes > videoByteLimit)) { + videoBytes -= video.back().bytes; + video.pop_back(); + } + Frame frame(av_frame_clone(decoded)); + if (!frame) throw std::bad_alloc(); + VideoFrame item{std::move(frame), pts, bytes}; + auto insertion = std::upper_bound(video.begin(), video.end(), pts, + [](double value, const VideoFrame &entry) { return value < entry.pts; }); + video.insert(insertion, std::move(item)); + videoBytes += bytes; + } + + bool decodeOne() { + if (pcmOffset < pcmFrames) return false; + if (receiving >= 0) { + auto &track = tracks[receiving]; + if (receiving == 1 && videoFull() && + (!tracks[0].codec || output.queued() >= 1024 || status != PLAYER_PLAYING)) + return false; + const int result = avcodec_receive_frame(track.codec, decoded); + if (result == AVERROR(EAGAIN)) { + receiving = -1; + return true; + } + if (result == AVERROR_EOF) { + track.drained = true; + receiving = -1; + return true; + } + check(result, "Decoding media"); + if (receiving == 0) convertAudio(); else queueVideo(); + av_frame_unref(decoded); + return true; + } + if (packetPending) { + const int slot = packet->stream_index == tracks[0].index ? 0 : 1; + const int result = avcodec_send_packet(tracks[slot].codec, packet); + if (result == AVERROR(EAGAIN)) { + receiving = slot; + return true; + } + check(result, "Sending media packet to decoder"); + av_packet_unref(packet); + packetPending = false; + receiving = slot; + return true; + } + if (demuxEnded) { + for (int slot = 0; slot < 2; ++slot) { + auto &track = tracks[slot]; + if (!track.codec || track.drainSent) continue; + const int result = avcodec_send_packet(track.codec, nullptr); + if (result == AVERROR(EAGAIN)) { + receiving = slot; + return true; + } + if (result != AVERROR_EOF) check(result, "Draining decoder"); + track.drainSent = true; + receiving = slot; + return true; + } + if ((!tracks[0].codec || tracks[0].drained) && !resamplerDrained) { + drainResampler(); + return true; + } + return false; + } + const int result = av_read_frame(format, packet); + if (result == AVERROR_EOF) { + demuxEnded = true; + return true; + } + if (result == AVERROR(EAGAIN)) return false; + check(result, "Reading media packet"); + if (packet->stream_index != tracks[0].index && packet->stream_index != tracks[1].index) { + av_packet_unref(packet); + } else { + packetPending = true; + } + return true; + } + + void renderVideo(double now) { + Frame latest; + while (!video.empty() && video.front().pts <= now + 0.002) { + videoBytes -= video.front().bytes; + latest = std::move(video.front().frame); + video.pop_front(); + } + if (latest) { + output.present(latest.get()); + previewShown = true; + } + } + + bool tick() { + if (!format || interrupted() || status == PLAYER_ENDED || status == PLAYER_ERROR) return false; + bool progressed = pumpAudio(); + if (status == PLAYER_PAUSED) { + if (!previewShown && !video.empty()) { + output.present(video.front().frame.get()); + videoBytes -= video.front().bytes; + video.pop_front(); + previewShown = true; + progressed = true; + } + const bool needPreview = tracks[1].codec && !previewShown; + const bool needAudio = tracks[0].codec && output.queued() < 4800; + if (needPreview || needAudio) progressed = decodeOne() || progressed; + return progressed; + } + position = clockPosition(); + renderVideo(position); + progressed = decodeOne() || progressed; + if (demuxEnded && (!tracks[0].codec || tracks[0].drained) && + resamplerDrained && pcmOffset >= pcmFrames && tracks[0].codec) + progressed = writeSilence(videoEnd) || progressed; + const bool drained = demuxEnded && resamplerDrained && pcmOffset >= pcmFrames && + (!tracks[0].codec || tracks[0].drained) && (!tracks[1].codec || tracks[1].drained); + if (drained && video.empty()) { + const double end = std::max(audioWriteEnd, videoEnd); + if (position + sampleTime >= end && (!tracks[0].codec || output.queued() == 0)) { + output.pause(); + position = end; + if (duration <= 0) duration = end; + status = PLAYER_ENDED; + } + } + publish(); + return progressed; + } + + void run() noexcept { + while (!stopping.load(std::memory_order_relaxed)) { + try { + Command command{Action::Pause, {}}; + bool haveCommand = false; + std::array apiFailure{}; + bool haveFailure = false; + uint64_t failureEpoch = 0; + { + std::lock_guard lock(mutex); + if (errorRequested) { + apiFailure = requestedError; + failureEpoch = errorEpoch; + errorRequested = false; + haveFailure = true; + } else if (!commands.empty()) { + command = std::move(commands.front()); + commands.pop_front(); + haveCommand = true; + } + } + if (haveFailure) { + if (failureEpoch == requestedEpoch.load(std::memory_order_relaxed)) { + activeEpoch = failureEpoch; + fail(apiFailure.data()); + } + continue; + } + if (haveCommand) { + execute(command); + continue; + } + if (tick()) continue; + std::unique_lock lock(mutex); + wake.wait_for(lock, std::chrono::milliseconds(status == PLAYER_PLAYING ? 5 : 20), + [this] { return stopping.load(std::memory_order_relaxed) || errorRequested || !commands.empty(); }); + } catch (const Cancelled &) { + // An accepted open/close or destruction superseded the current I/O. + } catch (const std::exception &exception) { + try { fail(exception.what()); } catch (...) {} + } catch (...) { + try { fail("Unexpected playback failure"); } catch (...) {} + } + } + try { release(); } catch (...) {} + } +}; + +namespace { +template +bool submit(Player *player, Function function) noexcept { + if (!player) return false; + try { + return function(); + } catch (const std::exception &exception) { + player->apiError(exception.what()); + } catch (...) { + player->apiError("Unexpected command failure"); + } + return false; +} +} + +extern "C" { +Player *player_create(void) { + try { return new Player; } catch (...) { return nullptr; } +} +bool player_open(Player *player, const char *path) { + if (!path || !*path) return false; + return submit(player, [=] { return player->enqueue({Action::Open, path}); }); +} +bool player_play(Player *player) { + return submit(player, [=] { return player->enqueue({Action::Play, {}}); }); +} +bool player_pause(Player *player) { + return submit(player, [=] { return player->enqueue({Action::Pause, {}}); }); +} +bool player_seek(Player *player, double seconds) { + if (!std::isfinite(seconds)) return false; + return submit(player, [=] { return player->enqueue({Action::Seek, {}, seconds}); }); +} +bool player_close(Player *player) { + return submit(player, [=] { return player->enqueue({Action::Close, {}}); }); +} +PlayerState player_get_state(Player *player) { + try { + if (player) { + std::lock_guard lock(player->mutex); + return player->state; + } + } catch (const std::exception &exception) { + PlayerState result{}; + result.status = PLAYER_ERROR; + copyError(result.error, exception.what()); + return result; + } catch (...) { + PlayerState result{}; + result.status = PLAYER_ERROR; + copyError(result.error, "Cannot read playback state"); + return result; + } + PlayerState result{}; + result.status = PLAYER_ERROR; + copyError(result.error, "Invalid player handle"); + return result; +} +void *player_macos_make_video_view(Player *player) { + if (!player) return nullptr; + try { + return player->output.make_video_view(); + } catch (const std::exception &exception) { + player->apiError(exception.what()); + } catch (...) { + player->apiError("Cannot create the native video view"); + } + return nullptr; +} +void player_destroy(Player *player) { + try { delete player; } catch (...) {} +} +} diff --git a/Sources/Core/player.h b/Sources/Core/player.h new file mode 100644 index 0000000..81387e2 --- /dev/null +++ b/Sources/Core/player.h @@ -0,0 +1,39 @@ +#ifndef PERSONAL_PLAYER_H +#define PERSONAL_PLAYER_H +#include +#include +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct Player Player; +typedef enum PlayerStatus { + PLAYER_EMPTY, PLAYER_OPENING, PLAYER_PAUSED, PLAYER_PLAYING, + PLAYER_ENDED, PLAYER_ERROR +} PlayerStatus; +typedef struct PlayerState { + PlayerStatus status; + double position; + double duration; + bool has_audio; + bool has_video; + uint64_t revision; + char error[512]; +} PlayerState; + +// 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); +bool player_play(Player *player); +bool player_pause(Player *player); +bool player_seek(Player *player, double seconds); +bool player_close(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); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/Sources/Core/player_macos.h b/Sources/Core/player_macos.h new file mode 100644 index 0000000..bc42de0 --- /dev/null +++ b/Sources/Core/player_macos.h @@ -0,0 +1,12 @@ +#ifndef PERSONAL_PLAYER_MACOS_H +#define PERSONAL_PLAYER_MACOS_H +#include "player.h" +#ifdef __cplusplus +extern "C" { +#endif +// Returns an owned NSView (+1). Swift must consume it with takeRetainedValue(). +void *player_macos_make_video_view(Player *player); +#ifdef __cplusplus +} +#endif +#endif diff --git a/Sources/macOS/Output.mm b/Sources/macOS/Output.mm new file mode 100644 index 0000000..9647507 --- /dev/null +++ b/Sources/macOS/Output.mm @@ -0,0 +1,399 @@ +#include "../Core/output.hpp" +#import +#import +#import +#import +#import +#import +extern "C" { +#include +} +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr size_t capacity = 48000; +double host_seconds(uint64_t ticks) { + static const double factor = [] { + mach_timebase_info_data_t info; + mach_timebase_info(&info); + return double(info.numer) / double(info.denom) / 1e9; + }(); + return ticks * factor; +} +struct VideoMailbox { + std::mutex mutex; + AVFrame *frame = av_frame_alloc(); + uint64_t version = 0; + ~VideoMailbox() { av_frame_free(&frame); } +}; +struct Uniforms { simd_float4 red, green, blue, parameters; }; +NSString *const shader = @R"metal( +#include +using namespace metal; +struct U { float4 red, green, blue, parameters; }; +struct V { float4 position [[position]]; float2 uv; }; +vertex V vertex_main(uint index [[vertex_id]], constant U &u [[buffer(0)]]) { + const float2 p[] = { {-1,-1}, {1,-1}, {-1,1}, {1,1} }; + V v; + v.position = float4(p[index] * u.parameters.xy, 0, 1); + v.uv = float2((p[index].x + 1) / 2, (1 - p[index].y) / 2); + return v; +} +fragment float4 fragment_main(V v [[stage_in]], constant U &u [[buffer(0)]], + texture2d y [[texture(0)]], texture2d cb [[texture(1)]], + texture2d cr [[texture(2)]]) { + constexpr sampler s(address::clamp_to_edge, filter::linear); + if (u.parameters.w == 2) return float4(y.sample(s, v.uv).rgb, 1); + float luma = y.sample(s, v.uv).r * u.parameters.z; + float2 chroma = u.parameters.w == 1 ? cb.sample(s, v.uv).rg : + float2(cb.sample(s, v.uv).r, cr.sample(s, v.uv).r); + float4 values = float4(luma, chroma * u.parameters.z, 1); + return float4(dot(u.red, values), dot(u.green, values), dot(u.blue, values), 1); +} +)metal"; +} + +@interface PlaybackVideoView : MTKView { + std::shared_ptr mailbox; + uint64_t seen; + id commands; + id pipeline; + id planes[3]; + Uniforms uniforms; + int imageWidth, imageHeight; + double imageAspect; + SwsContext *conversion; + AVFrame *converted; + std::shared_ptr inFlight; + CFStringRef colorName; +} +- (instancetype)initWithMailbox:(std::shared_ptr)source; +@end + +@implementation PlaybackVideoView +- (instancetype)initWithMailbox:(std::shared_ptr)source { + id device = MTLCreateSystemDefaultDevice(); + if (!device) throw std::runtime_error("Metal is unavailable on this Mac"); + self = [super initWithFrame:NSMakeRect(0, 0, 960, 540) device:device]; + if (!self) throw std::runtime_error("Could not create the video view"); + mailbox = std::move(source); + seen = UINT64_MAX; + inFlight = std::make_shared(false); + self.colorPixelFormat = MTLPixelFormatBGRA8Unorm; + self.clearColor = MTLClearColorMake(0, 0, 0, 1); + self.preferredFramesPerSecond = 60; + self.delegate = self; + self.layer.contentsScale = NSScreen.mainScreen.backingScaleFactor; + commands = [device newCommandQueue]; + NSError *error = nil; + id library = [device newLibraryWithSource:shader options:nil error:&error]; + if (!library) throw std::runtime_error(error.localizedDescription.UTF8String); + MTLRenderPipelineDescriptor *description = [MTLRenderPipelineDescriptor new]; + description.vertexFunction = [library newFunctionWithName:@"vertex_main"]; + description.fragmentFunction = [library newFunctionWithName:@"fragment_main"]; + description.colorAttachments[0].pixelFormat = self.colorPixelFormat; + pipeline = [device newRenderPipelineStateWithDescriptor:description error:&error]; + if (!pipeline || !commands) throw std::runtime_error("Could not initialize Metal rendering"); + converted = av_frame_alloc(); + if (!converted) throw std::bad_alloc(); + return self; +} +- (void)dealloc { + self.delegate = nil; + sws_freeContext(conversion); + av_frame_free(&converted); +} +- (void)mtkView:(MTKView *)view drawableSizeWillChange:(CGSize)size {} +- (void)upload:(const AVFrame *)source { + const AVFrame *frame = source; + AVPixelFormat format = static_cast(frame->format); + bool planar = format == AV_PIX_FMT_YUV420P || format == AV_PIX_FMT_YUVJ420P || + format == AV_PIX_FMT_YUV420P10LE; + bool nv12 = format == AV_PIX_FMT_NV12; + bool rgba = format == AV_PIX_FMT_RGBA || format == AV_PIX_FMT_BGRA; + bool negativeStride = frame->linesize[0] < 0 || (!rgba && frame->linesize[1] < 0); + if ((!planar && !nv12 && !rgba) || negativeStride) { + conversion = sws_getCachedContext(conversion, frame->width, frame->height, format, + frame->width, frame->height, AV_PIX_FMT_BGRA, SWS_BILINEAR, nullptr, nullptr, nullptr); + if (!conversion) throw std::runtime_error("Pixel conversion is unavailable"); + int space = frame->colorspace == AVCOL_SPC_BT709 ? SWS_CS_ITU709 : + frame->colorspace == AVCOL_SPC_BT2020_NCL ? SWS_CS_BT2020 : SWS_CS_ITU601; + const int *coefficients = sws_getCoefficients(space); + sws_setColorspaceDetails(conversion, coefficients, frame->color_range == AVCOL_RANGE_JPEG, + coefficients, 1, 0, 1 << 16, 1 << 16); + if (!converted->buf[0] || converted->width != frame->width || converted->height != frame->height) { + av_frame_unref(converted); + converted->format = AV_PIX_FMT_BGRA; + converted->width = frame->width; + converted->height = frame->height; + if (av_frame_get_buffer(converted, 32) < 0) throw std::bad_alloc(); + } + if (av_frame_make_writable(converted) < 0) throw std::bad_alloc(); + sws_scale(conversion, frame->data, frame->linesize, 0, frame->height, + converted->data, converted->linesize); + frame = converted; + format = AV_PIX_FMT_BGRA; + rgba = true; planar = nv12 = false; + } + bool ten = format == AV_PIX_FMT_YUV420P10LE; + int count = rgba ? 1 : nv12 ? 2 : 3; + for (int i = 0; i < count; ++i) { + NSUInteger width = i == 0 ? frame->width : (frame->width + 1) / 2; + NSUInteger height = i == 0 ? frame->height : (frame->height + 1) / 2; + MTLPixelFormat pixel = rgba ? (format == AV_PIX_FMT_BGRA ? MTLPixelFormatBGRA8Unorm : MTLPixelFormatRGBA8Unorm) : + ten ? MTLPixelFormatR16Unorm : nv12 && i == 1 ? MTLPixelFormatRG8Unorm : MTLPixelFormatR8Unorm; + if (!planes[i] || planes[i].width != width || planes[i].height != height || planes[i].pixelFormat != pixel) { + MTLTextureDescriptor *description = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:pixel + width:width height:height mipmapped:NO]; + description.usage = MTLTextureUsageShaderRead; + description.storageMode = MTLStorageModeShared; + planes[i] = [self.device newTextureWithDescriptor:description]; + if (!planes[i]) throw std::runtime_error("Could not allocate video texture"); + } + [planes[i] replaceRegion:MTLRegionMake2D(0, 0, width, height) mipmapLevel:0 + withBytes:frame->data[i] bytesPerRow:frame->linesize[i]]; + } + // Unused slots still need valid textures for the shared fragment function. + if (count < 3) planes[2] = planes[count - 1]; + if (count == 1) planes[1] = planes[0]; + imageWidth = frame->width; imageHeight = frame->height; + AVRational aspect = source->sample_aspect_ratio; + imageAspect = double(imageWidth) / imageHeight * (aspect.num > 0 && aspect.den > 0 ? double(aspect.num) / aspect.den : 1); + CFStringRef targetColor = source->color_primaries == AVCOL_PRI_BT2020 ? kCGColorSpaceITUR_2020 : + source->color_trc == AVCOL_TRC_IEC61966_2_1 ? kCGColorSpaceSRGB : kCGColorSpaceITUR_709; + if (colorName != targetColor) { + CGColorSpaceRef space = CGColorSpaceCreateWithName(targetColor); + self.colorspace = space; + CGColorSpaceRelease(space); + colorName = targetColor; + } + double kr = .299, kb = .114; + if (source->colorspace == AVCOL_SPC_BT709 || + (source->colorspace == AVCOL_SPC_UNSPECIFIED && source->height >= 720)) { kr = .2126; kb = .0722; } + if (source->colorspace == AVCOL_SPC_BT2020_NCL) { kr = .2627; kb = .0593; } + bool full = source->color_range == AVCOL_RANGE_JPEG || format == AV_PIX_FMT_YUVJ420P; + double ys = full ? 1 : ten ? 1023. / 876 : 255. / 219; + double cs = full ? 1 : ten ? 1023. / 896 : 255. / 224; + double offset = full ? 0 : ten ? 64. / 1023 : 16. / 255; + double center = ten ? 512. / 1023 : 128. / 255; + double r = 2 * (1 - kr) * cs, b = 2 * (1 - kb) * cs; + double gc = -2 * kb * (1 - kb) / (1 - kr - kb) * cs; + double gr = -2 * kr * (1 - kr) / (1 - kr - kb) * cs; + uniforms.red = {float(ys), 0, float(r), float(-ys * offset - r * center)}; + uniforms.green = {float(ys), float(gc), float(gr), float(-ys * offset - (gc + gr) * center)}; + uniforms.blue = {float(ys), float(b), 0, float(-ys * offset - b * center)}; + uniforms.parameters = {1, 1, ten ? float(65535. / 1023) : 1.f, rgba ? 2.f : nv12 ? 1.f : 0.f}; +} +- (void)drawInMTKView:(MTKView *)view { + // Do not overwrite shared textures while the GPU is still sampling them. + if (inFlight->exchange(true, std::memory_order_acquire)) return; + // Keep the mailbox lock only for retaining a frame, never during GPU work. + AVFrame *frame = nullptr; + { + std::lock_guard lock(mailbox->mutex); + if (seen != mailbox->version) { + seen = mailbox->version; + if (mailbox->frame->buf[0]) frame = av_frame_clone(mailbox->frame); + else { planes[0] = planes[1] = planes[2] = nil; imageWidth = imageHeight = 0; } + } + } + if (frame) { + try { [self upload:frame]; } + catch (const std::exception &error) { NSLog(@"Video rendering failed: %s", error.what()); } + av_frame_free(&frame); + } + MTLRenderPassDescriptor *pass = self.currentRenderPassDescriptor; + id drawable = self.currentDrawable; + if (!pass || !drawable) { inFlight->store(false, std::memory_order_release); return; } + id command = [commands commandBuffer]; + id encoder = [command renderCommandEncoderWithDescriptor:pass]; + if (planes[0] && imageWidth > 0) { + double viewAspect = self.drawableSize.width / std::max(1., self.drawableSize.height); + uniforms.parameters.x = imageAspect < viewAspect ? imageAspect / viewAspect : 1; + uniforms.parameters.y = imageAspect > viewAspect ? viewAspect / imageAspect : 1; + [encoder setRenderPipelineState:pipeline]; + [encoder setVertexBytes:&uniforms length:sizeof(uniforms) atIndex:0]; + [encoder setFragmentBytes:&uniforms length:sizeof(uniforms) atIndex:0]; + for (int i = 0; i < 3; ++i) [encoder setFragmentTexture:planes[i] atIndex:i]; + [encoder drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:4]; + } + [encoder endEncoding]; + auto flight = inFlight; + [command addCompletedHandler:^(id) { + flight->store(false, std::memory_order_release); + }]; + [command presentDrawable:drawable]; + [command commit]; +} +@end + +namespace playback { +struct Output::Impl { + AudioUnit unit = nullptr; + std::array samples{}; + std::atomic read{0}, written{0}; + double origin = 0; + double latency = 0; + std::atomic clockVersion{0}; + std::atomic anchorPTS{NAN}, anchorHost{0}, endPTS{0}, frozen{NAN}; + bool running = false; + std::shared_ptr video = std::make_shared(); + + static OSStatus render(void *context, AudioUnitRenderActionFlags *, const AudioTimeStamp *time, + UInt32, UInt32 count, AudioBufferList *buffers) { + auto &self = *static_cast(context); + for (UInt32 i = 0; i < buffers->mNumberBuffers; ++i) + std::memset(buffers->mBuffers[i].mData, 0, buffers->mBuffers[i].mDataByteSize); + uint64_t r = self.read.load(std::memory_order_relaxed); + uint64_t w = self.written.load(std::memory_order_acquire); + size_t frames = std::min(count, w - r); + float *destination = static_cast(buffers->mBuffers[0].mData); + size_t first = std::min(frames, capacity - size_t(r % capacity)); + std::memcpy(destination, self.samples.data() + (r % capacity) * 2, first * 2 * sizeof(float)); + std::memcpy(destination + first * 2, self.samples.data(), (frames - first) * 2 * sizeof(float)); + if (frames) { + self.clockVersion.fetch_add(1, std::memory_order_acq_rel); + self.anchorPTS.store(self.origin + double(r) / 48000, std::memory_order_relaxed); + uint64_t host = time->mFlags & kAudioTimeStampHostTimeValid ? time->mHostTime : mach_absolute_time(); + self.anchorHost.store(host_seconds(host) + self.latency, std::memory_order_relaxed); + self.endPTS.store(self.origin + double(r + frames) / 48000, std::memory_order_relaxed); + self.clockVersion.fetch_add(1, std::memory_order_release); + } + self.read.store(r + frames, std::memory_order_release); + return noErr; + } + ~Impl() { + if (unit) { + AudioOutputUnitStop(unit); + AudioUnitUninitialize(unit); + AudioComponentInstanceDispose(unit); + } + } +}; +Output::Output() : impl(std::make_unique()) { host_seconds(mach_absolute_time()); } +Output::~Output() = default; +bool Output::configure_audio(std::string &error) { + if (impl->unit) return true; + AudioComponentDescription description{ kAudioUnitType_Output, kAudioUnitSubType_DefaultOutput, + kAudioUnitManufacturer_Apple, 0, 0 }; + AudioComponent component = AudioComponentFindNext(nullptr, &description); + OSStatus result = component ? AudioComponentInstanceNew(component, &impl->unit) : -1; + AudioStreamBasicDescription format{}; + format.mSampleRate = 48000; + format.mFormatID = kAudioFormatLinearPCM; + format.mFormatFlags = kAudioFormatFlagsNativeFloatPacked; + format.mBytesPerPacket = format.mBytesPerFrame = 2 * sizeof(float); + format.mFramesPerPacket = 1; format.mChannelsPerFrame = 2; format.mBitsPerChannel = 32; + AURenderCallbackStruct callback{Impl::render, impl.get()}; + if (result == noErr) result = AudioUnitSetProperty(impl->unit, kAudioUnitProperty_StreamFormat, + kAudioUnitScope_Input, 0, &format, sizeof(format)); + if (result == noErr) result = AudioUnitSetProperty(impl->unit, kAudioUnitProperty_SetRenderCallback, + kAudioUnitScope_Input, 0, &callback, sizeof(callback)); + if (result == noErr) result = AudioUnitInitialize(impl->unit); + if (result != noErr) { + error = "Core Audio initialization failed (" + std::to_string(result) + ")"; + if (impl->unit) AudioComponentInstanceDispose(impl->unit); + impl->unit = nullptr; + return false; + } + AudioDeviceID device = 0; + UInt32 size = sizeof(device); + if (AudioUnitGetProperty(impl->unit, kAudioOutputUnitProperty_CurrentDevice, + kAudioUnitScope_Global, 0, &device, &size) == noErr) { + AudioObjectPropertyAddress address{ kAudioDevicePropertyLatency, kAudioObjectPropertyScopeOutput, + kAudioObjectPropertyElementMain }; + UInt32 latency = 0; size = sizeof(latency); + Float64 rate = 48000; + AudioObjectGetPropertyData(device, &address, 0, nullptr, &size, &latency); + address.mSelector = kAudioDevicePropertyNominalSampleRate; address.mScope = kAudioObjectPropertyScopeGlobal; + size = sizeof(rate); + AudioObjectGetPropertyData(device, &address, 0, nullptr, &size, &rate); + address = {kAudioDevicePropertyStreams, kAudioObjectPropertyScopeOutput, kAudioObjectPropertyElementMain}; + if (AudioObjectGetPropertyDataSize(device, &address, 0, nullptr, &size) == noErr) { + std::vector streams(size / sizeof(AudioStreamID)); + if (AudioObjectGetPropertyData(device, &address, 0, nullptr, &size, streams.data()) == noErr) { + UInt32 streamLatency = 0; + for (AudioStreamID stream : streams) { + AudioObjectPropertyAddress property{kAudioStreamPropertyLatency, + kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain}; + UInt32 value = 0, bytes = sizeof(value); + if (AudioObjectGetPropertyData(stream, &property, 0, nullptr, &bytes, &value) == noErr) + streamLatency = std::max(streamLatency, value); + } + latency += streamLatency; + } + } + impl->latency = rate > 0 ? latency / rate : 0; + } + return true; +} +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; + OSStatus status = AudioOutputUnitStart(impl->unit); + if (status != noErr) { error = "Could not start Core Audio (" + std::to_string(status) + ")"; return false; } + impl->running = true; + return true; +} +void Output::pause() { + if (!impl->running) return; + double position = clock(); + AudioOutputUnitStop(impl->unit); + impl->running = false; + impl->frozen.store(position, std::memory_order_release); +} +void Output::reset() { + impl->read.store(0); impl->written.store(0); + impl->origin = 0; + impl->anchorPTS.store(NAN); impl->frozen.store(NAN); impl->endPTS.store(0); + std::lock_guard lock(impl->video->mutex); + av_frame_unref(impl->video->frame); + ++impl->video->version; +} +size_t Output::write(const float *samples, size_t frames, double pts) { + uint64_t w = impl->written.load(std::memory_order_relaxed); + uint64_t r = impl->read.load(std::memory_order_acquire); + frames = std::min(frames, capacity - size_t(w - r)); + if (!frames) return 0; + if (w == 0) impl->origin = pts; + size_t first = std::min(frames, capacity - size_t(w % capacity)); + std::memcpy(impl->samples.data() + (w % capacity) * 2, samples, first * 2 * sizeof(float)); + std::memcpy(impl->samples.data(), samples + first * 2, (frames - first) * 2 * sizeof(float)); + impl->written.store(w + frames, std::memory_order_release); + return frames; +} +size_t Output::queued() const { + return impl->written.load(std::memory_order_acquire) - impl->read.load(std::memory_order_acquire); +} +double Output::clock() const { + if (!impl->running) return impl->frozen.load(std::memory_order_acquire); + for (int attempt = 0; attempt < 3; ++attempt) { + uint64_t version = impl->clockVersion.load(std::memory_order_acquire); + if (version & 1) continue; + double pts = impl->anchorPTS.load(std::memory_order_relaxed); + double host = impl->anchorHost.load(std::memory_order_relaxed); + double end = impl->endPTS.load(std::memory_order_relaxed); + if (version != impl->clockVersion.load(std::memory_order_acquire)) continue; + if (!std::isfinite(pts)) return NAN; + return std::clamp(pts + host_seconds(mach_absolute_time()) - host, pts, end); + } + return NAN; +} +void Output::present(const AVFrame *frame) { + std::lock_guard lock(impl->video->mutex); + av_frame_unref(impl->video->frame); + if (av_frame_ref(impl->video->frame, frame) < 0) throw std::bad_alloc(); + ++impl->video->version; +} +void *Output::make_video_view() { + PlaybackVideoView *view = [[PlaybackVideoView alloc] initWithMailbox:impl->video]; + return (__bridge_retained void *)view; +} +}