From cf24a0fc4f4aa7c07e2cc2cbd28b367a4c5d94bf Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 01:38:37 +0200 Subject: [PATCH] fix: respond to macOS system playback controls --- Checks/system-playback.swift | 84 +++++++++++++++ Makefile | 14 ++- README.md | 5 + Sources/App/SystemPlaybackControls.swift | 130 +++++++++++++++++++++++ Sources/App/TerebiApp.swift | 26 ++++- 5 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 Checks/system-playback.swift create mode 100644 Sources/App/SystemPlaybackControls.swift diff --git a/Checks/system-playback.swift b/Checks/system-playback.swift new file mode 100644 index 0000000..77c0783 --- /dev/null +++ b/Checks/system-playback.swift @@ -0,0 +1,84 @@ +import AppKit +import MediaPlayer + +@main +@MainActor +struct SystemPlaybackChecks { + static func require(_ condition: @autoclosure () -> Bool, _ message: String) { + if !condition() { fatalError("FAIL: " + message) } + } + + static func until(_ message: String, _ condition: () -> Bool) { + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + while !condition(), ContinuousClock.now < deadline { + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01)) + } + require(condition(), message) + } + + static func main() { + _ = NSApplication.shared + NSApp.setActivationPolicy(.prohibited) + let suite = "terebi.system-playback-check.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let model = PlayerModel(defaults: defaults) + defer { model.shutdown() } + model.setVolume(0) + let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) + let center = MPNowPlayingInfoCenter.default() + let commands = MPRemoteCommandCenter.shared() + + model.open(directory.appendingPathComponent("av.mp4")) + until("open starts authoritative playback") { model.canTogglePlayback && model.state.status == PLAYER_PLAYING } + require(center.playbackState == .playing, "playing publishes macOS playback ownership") + require(model.setPlayback(playing: false), "explicit pause accepted") + until("pause acknowledged") { model.state.status == PLAYER_PAUSED } + let pausedPosition = model.state.position + require(model.setPlayback(playing: false), "repeated pause accepted") + until("idempotent pause does not restart playback") { + model.state.status == PLAYER_PAUSED && center.playbackState == .paused + } + require(model.state.position == pausedPosition, "repeated pause preserves position") + require(center.nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] as? Double == 0, + "paused system timeline does not extrapolate") + + let seekableState = model.state + var nonseekableState = seekableState + nonseekableState.can_seek = false + model.applyState(nonseekableState) + require(commands.playCommand.isEnabled && !commands.nextTrackCommand.isEnabled && + !commands.skipBackwardCommand.isEnabled && !model.seekRelative(1), + "nonseekable input retains playback but rejects system seeking") + model.applyState(seekableState) + require(commands.nextTrackCommand.isEnabled, + "seekability changes re-enable commands even when playback metadata is unchanged") + + require(model.seekRelative(1), "relative seek accepted") + until("paused seek acknowledged") { + model.state.status == PLAYER_PAUSED && !model.state.seek_pending && + abs(model.state.position - pausedPosition - 1) < 0.05 + } + require(model.seekRelative(-100), "backward boundary seek accepted") + until("backward seek clamps at start") { !model.state.seek_pending && model.state.position == 0 } + require(!model.seekRelative(.nan), "nonfinite external seek rejected") + require(model.setPlayback(playing: true), "explicit play accepted") + until("play resumes advancing timeline") { model.state.status == PLAYER_PLAYING && model.state.position > 0.1 } + require(model.setPlayback(playing: true), "repeated play accepted") + until("repeated play keeps playing") { model.state.status == PLAYER_PLAYING && model.state.position > 0.2 } + + model.closeFile() + require(center.nowPlayingInfo == nil && center.playbackState == .stopped, + "accepted close releases system playback immediately") + require(!commands.togglePlayPauseCommand.isEnabled && !commands.nextTrackCommand.isEnabled, + "closed source cannot receive system playback or seek commands") + model.open(directory.appendingPathComponent("hdr.mkv")) + until("decode error acknowledged") { model.state.status == PLAYER_ERROR && !model.isBusy } + require(center.nowPlayingInfo == nil && !commands.playCommand.isEnabled, + "failed source does not claim playback keys") + model.shutdown() + require(center.nowPlayingInfo == nil && !commands.pauseCommand.isEnabled, + "shutdown releases system playback") + print("System playback checks passed") + } +} diff --git a/Makefile b/Makefile index eead04c..bb4abfa 100644 --- a/Makefile +++ b/Makefile @@ -8,9 +8,9 @@ BUILD_DIR ?= .build DEBUG_FLAGS ?= -g PREFIX ?= $(HOME)/.local CXXFLAGS = -std=c++20 -mmacosx-version-min=15.0 -O2 $(DEBUG_FLAGS) -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS) -FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore +FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore -framework MediaPlayer OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/Output.o -SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift +SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/SystemPlaybackControls.swift RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift $(RUNTIME_SOURCE) APP := $(BUILD_DIR)/terebi.app @@ -18,7 +18,7 @@ PLAYER := $(APP)/Contents/MacOS/terebi-player BOOTSTRAP := $(APP)/Contents/MacOS/terebi MANIFEST := $(APP)/Contents/Resources/ffmpeg-libraries.txt -.PHONY: all release install check-dependencies run check check-fixtures check-audio check-window check-youtube check-network check-progress check-cli check-runtime +.PHONY: all release install check-dependencies run check check-fixtures check-audio check-window check-youtube check-network check-progress check-cli check-runtime check-media all: check-dependencies $(BUILD_DIR)/terebi $(PLAYER) $(BOOTSTRAP) $(MANIFEST) $(APP)/Contents/Info.plist check-dependencies: @@ -77,7 +77,7 @@ run: all $(BUILD_DIR)/playback-check: Checks/playback.mm $(OBJECTS) Makefile | check-dependencies $(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@ -check: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-youtube check-network check-progress check-cli check-runtime +check: check-dependencies $(BUILD_DIR)/playback-check check-fixtures check-audio check-window check-youtube check-network check-progress check-cli check-runtime check-media $(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures check-fixtures: | check-dependencies @@ -149,3 +149,9 @@ $(BUILD_DIR)/ffmpeg-runtime-check: Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) check-runtime: $(BUILD_DIR)/ffmpeg-runtime-check $(MANIFEST) $(BUILD_DIR)/ffmpeg-runtime-check "$(APP)" + +$(BUILD_DIR)/system-playback-check: Checks/system-playback.swift $(SWIFT_SOURCES) $(OBJECTS) Makefile | check-dependencies + $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/system-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@ + +check-media: $(BUILD_DIR)/system-playback-check check-fixtures + $(BUILD_DIR)/system-playback-check $(BUILD_DIR)/fixtures diff --git a/README.md b/README.md index c8d0579..d8e76c6 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ Drag the compact video to move the window; on release, each axis snaps independe Single-key shortcuts apply only to the playback window and do not intercept text editing, file-picker input, or Command/Control/Option combinations. Held toggle keys do not repeatedly toggle; held `J`/`L` continue seeking and held `,`/`.` continue stepping. `C` captions remains unbound. +macOS media keys and Now Playing controls use native MediaPlayer commands, including while the window is unfocused: play/pause toggles playback, and previous/next seeks backward/forward five seconds instead of switching tracks. System skip controls prefer five-second intervals. Live/non-seekable inputs disable seeking while retaining play/pause. Now Playing reports the current title, duration, elapsed time, and playback state; opening/replacing, closing, failed playback, and quitting release unavailable controls. No Accessibility or Input Monitoring permission is required. macOS chooses which active media app receives the keys. + Initial window setup and returning from the file/YouTube dialogs leave keyboard focus on playback rather than automatically highlighting the titlebar pin. Tab navigation and native focus indicators remain available when choosing controls explicitly. Frame stepping uses decoded presentation timestamps, not an average-FPS time offset, including variable-frame-rate video and reordered B-frames. Steps start from the displayed frame, stop at the first/last frame, and keep the timeline paused. Consecutive same-direction repeats share one counted queue entry without skipping frames or reordering seeks/direction changes. The last presented image remains visible during decoder preroll and queued steps; the progress thumb follows each authoritative stepped position. Opening/closing media still clears the old image. Audio-only files disable stepping; normal playback resumes with audio synchronized to the selected frame. @@ -112,6 +114,7 @@ Only implemented actions have controls; there are no placeholder playlist, subti - `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/TerebiApp.swift`: MainActor state polling, file/YouTube panels and Finder/drop handling, cancellable URL resolution, autoplay, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration. Both accepted open paths enqueue play on the new source's command timeline; the C interface still opens paused for callers that need explicit control. - `Sources/App/PlaybackProgress.swift`: local per-video position persistence and canonical file identities, using injectable UserDefaults. The player model checkpoints authoritative positions and defers resumed autoplay until the saved seek is queued against the ready source. +- `Sources/App/SystemPlaybackControls.swift`: native remote-command registration, main-thread dispatch, Now Playing publication, seekability gating, and target cleanup. - `Sources/App/CompactWindow.swift`: same-window compact geometry, saved normal window state, asynchronous fullscreen transitions and failure recovery, all-Spaces/floating behavior, magnetic edge snapping, and its persisted preference. - `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, external yt-dlp/runtime discovery, SDR stream selection and headers, bounded nonblocking process output, sanitized extraction errors, and request-local cancellation. - `Sources/CLI/PlayerCommand.swift` and `Sources/CLI/TerebiCLI.swift`: command-line validation/help and native application discovery/launching. Launch the app without media first to establish its singleton window, then deliver the selected URL; the app owns Apple events to prevent SwiftUI's default external-document routing from replacing that window. @@ -140,6 +143,8 @@ Generates short fixtures and exercises the real playback interface and native ou `make check-runtime` exercises missing/invalid dependency metadata, unavailable ABI libraries, existing but unloadable dylibs, omitted library families, and successful native library loading using isolated app metadata. It is included in `make check`; no installed FFmpeg files are modified. +`make check-media` exercises actual playback through the app model, idempotent play/pause, paused seeking and start-boundary clamping, non-seekable state transitions, and Now Playing cleanup on close/error/shutdown. It is included in `make check` and uses isolated playback history with muted audio. Native MediaPlayer command dispatch was also smoke-checked; physical media-key routing and macOS's choice among competing media apps still need a keyboard check in the running app. + 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. diff --git a/Sources/App/SystemPlaybackControls.swift b/Sources/App/SystemPlaybackControls.swift new file mode 100644 index 0000000..df1dee6 --- /dev/null +++ b/Sources/App/SystemPlaybackControls.swift @@ -0,0 +1,130 @@ +import AppKit +import MediaPlayer + +@MainActor +final class SystemPlaybackControls { + private enum Action: Sendable { + case play, pause, toggle, skip(Double) + } + + private struct Snapshot: Equatable { + let title: String + let duration: Double + let second: Double + let rate: Double + let playbackState: MPNowPlayingPlaybackState + let video: Bool + let live: Bool + let seeking: Bool + } + + private weak var model: PlayerModel? + private let center = MPRemoteCommandCenter.shared() + private let infoCenter = MPNowPlayingInfoCenter.default() + private var targets: [(MPRemoteCommand, Any)] = [] + private var snapshot: Snapshot? + + init(model: PlayerModel) { + self.model = model + register(center.playCommand, action: .play) + register(center.pauseCommand, action: .pause) + register(center.togglePlayPauseCommand, action: .toggle) + register(center.previousTrackCommand, action: .skip(-5)) + register(center.nextTrackCommand, action: .skip(5)) + center.skipBackwardCommand.preferredIntervals = [5] + center.skipForwardCommand.preferredIntervals = [5] + register(center.skipBackwardCommand, action: .skip(-5)) + register(center.skipForwardCommand, action: .skip(5)) + update() + } + + private func register(_ command: MPRemoteCommand, action: Action) { + let handler: @Sendable (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus = { [weak self] event in + var action = action + if case let .skip(defaultInterval) = action, + let event = event as? MPSkipIntervalCommandEvent { + guard event.interval.isFinite, event.interval > 0 else { return .commandFailed } + action = .skip(defaultInterval < 0 ? -event.interval : event.interval) + } + return self?.dispatch(action) ?? .noActionableNowPlayingItem + } + let target = command.addTarget(handler: handler) + targets.append((command, target)) + } + + // MediaPlayer callbacks are not guaranteed to run on the main thread. + private nonisolated func dispatch(_ action: Action) -> MPRemoteCommandHandlerStatus { + if Thread.isMainThread { + return MainActor.assumeIsolated { perform(action) } + } + return DispatchQueue.main.sync { perform(action) } + } + + private func perform(_ action: Action) -> MPRemoteCommandHandlerStatus { + guard let model, model.fileName != nil, model.canTogglePlayback else { + return .noActionableNowPlayingItem + } + let accepted: Bool + switch action { + case .play: accepted = model.setPlayback(playing: true) + case .pause: accepted = model.setPlayback(playing: false) + case .toggle: accepted = model.setPlayback(playing: model.state.status != PLAYER_PLAYING) + case let .skip(seconds): accepted = model.seekRelative(seconds) + } + return accepted ? .success : .commandFailed + } + + func update() { + let ready = model?.fileName != nil && model?.canTogglePlayback == true + let seekable = ready && model?.canSeek == true + for (command, _) in targets { + let playback = command === center.playCommand || command === center.pauseCommand || + command === center.togglePlayPauseCommand + let enabled = playback ? ready : seekable + if command.isEnabled != enabled { command.isEnabled = enabled } + } + guard ready, let model, let title = model.fileName else { + if snapshot != nil { + infoCenter.nowPlayingInfo = nil + infoCenter.playbackState = .stopped + snapshot = nil + } + return + } + let state = model.state + let position = state.position.isFinite ? max(0, state.position) : 0 + let playing = state.status == PLAYER_PLAYING + let playbackState: MPNowPlayingPlaybackState = playing ? .playing : + (state.status == PLAYER_ENDED ? .stopped : .paused) + let rate = playing && !state.buffering && !state.seek_pending ? 1.0 : 0.0 + let next = Snapshot(title: title, duration: model.duration, second: floor(position), + rate: rate, playbackState: playbackState, video: state.has_video, + live: model.isLiveStream, seeking: state.seek_pending) + // Publish at most once per media second unless playback/seek/source state changes. + guard next != snapshot else { return } + var info: [String: Any] = [ + MPMediaItemPropertyTitle: title, + MPNowPlayingInfoPropertyElapsedPlaybackTime: position, + MPNowPlayingInfoPropertyPlaybackRate: rate, + MPNowPlayingInfoPropertyMediaType: + (state.has_video ? MPNowPlayingInfoMediaType.video : .audio).rawValue, + MPNowPlayingInfoPropertyIsLiveStream: model.isLiveStream, + ] + if next.duration > 0 { info[MPMediaItemPropertyPlaybackDuration] = next.duration } + infoCenter.nowPlayingInfo = info + if infoCenter.playbackState != playbackState { infoCenter.playbackState = playbackState } + snapshot = next + } + + func shutdown() { + for (command, target) in targets { + command.removeTarget(target) + command.isEnabled = false + } + targets.removeAll() + infoCenter.nowPlayingInfo = nil + infoCenter.playbackState = .stopped + snapshot = nil + model = nil + } +} diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index b955336..a302c0b 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -48,6 +48,7 @@ final class PlayerModel: ObservableObject { private var progressURL: URL? private var resumePosition: Double? private var lastSavedPosition: Double? + private var systemPlayback: SystemPlaybackControls? init(defaults: UserDefaults = .standard) { playbackProgress = PlaybackProgress(defaults: defaults) @@ -65,6 +66,7 @@ final class PlayerModel: ObservableObject { timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in MainActor.assumeIsolated { self?.poll() } } + systemPlayback = SystemPlaybackControls(model: self) } var isBusy: Bool { resolvingYouTube || pendingRevision != nil || state.status == PLAYER_OPENING } @@ -138,6 +140,7 @@ final class PlayerModel: ObservableObject { let request = UUID() youtubeRequest = request resolvingYouTube = true + systemPlayback?.update() commandError = nil revealControls() youtubeTask = Task { [weak self] in @@ -212,6 +215,7 @@ final class PlayerModel: ObservableObject { window?.representedURL = url window?.title = title revealControls() + systemPlayback?.update() } func open(_ url: URL) { @@ -236,10 +240,16 @@ final class PlayerModel: ObservableObject { } func togglePlayback() { - guard let handle, canTogglePlayback else { return } - let accepted = state.status == PLAYER_PLAYING ? player_pause(handle) : player_play(handle) + setPlayback(playing: state.status != PLAYER_PLAYING) + } + + @discardableResult + func setPlayback(playing: Bool) -> Bool { + guard let handle, canTogglePlayback else { return false } + let accepted = playing ? player_play(handle) : player_pause(handle) commandError = accepted ? nil : "Could not queue playback. Please try again." revealControls() + return accepted } func finishSeeking() { @@ -275,10 +285,12 @@ final class PlayerModel: ObservableObject { window?.representedURL = nil window?.title = "terebi" revealControls() + systemPlayback?.update() } - func seekRelative(_ seconds: Double) { - guard canSeek, let handle else { return } + @discardableResult + func seekRelative(_ seconds: Double) -> Bool { + guard canSeek, let handle, seconds.isFinite else { return false } let target = min(max(0, (seekPending ? seekPosition : state.position) + seconds), duration) let accepted = player_seek(handle, target) if accepted { @@ -287,6 +299,7 @@ final class PlayerModel: ObservableObject { } commandError = accepted ? nil : "Could not queue the seek. Please try again." revealControls() + return accepted } func stepFrame(_ direction: Int32) { @@ -458,6 +471,7 @@ final class PlayerModel: ObservableObject { guard next.revision != revision, next.status != PLAYER_OPENING else { return } pendingRevision = nil } + defer { systemPlayback?.update() } let statusChanged = state.status != next.status state = next if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR || @@ -496,6 +510,8 @@ final class PlayerModel: ObservableObject { func shutdown() { saveProgress() cancelYouTubeResolution() + systemPlayback?.shutdown() + systemPlayback = nil compactWindow.restore(resumeFullScreen: false) timer?.invalidate() timer = nil @@ -1151,7 +1167,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } +#if !PLAYER_CHECK @main +#endif @MainActor struct TerebiApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate