fix: respond to macOS system playback controls
This commit is contained in:
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,9 @@ BUILD_DIR ?= .build
|
|||||||
DEBUG_FLAGS ?= -g
|
DEBUG_FLAGS ?= -g
|
||||||
PREFIX ?= $(HOME)/.local
|
PREFIX ?= $(HOME)/.local
|
||||||
CXXFLAGS = -std=c++20 -mmacosx-version-min=15.0 -O2 $(DEBUG_FLAGS) -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS)
|
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
|
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
|
RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift
|
||||||
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift $(RUNTIME_SOURCE)
|
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift $(RUNTIME_SOURCE)
|
||||||
APP := $(BUILD_DIR)/terebi.app
|
APP := $(BUILD_DIR)/terebi.app
|
||||||
@@ -18,7 +18,7 @@ PLAYER := $(APP)/Contents/MacOS/terebi-player
|
|||||||
BOOTSTRAP := $(APP)/Contents/MacOS/terebi
|
BOOTSTRAP := $(APP)/Contents/MacOS/terebi
|
||||||
MANIFEST := $(APP)/Contents/Resources/ffmpeg-libraries.txt
|
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
|
all: check-dependencies $(BUILD_DIR)/terebi $(PLAYER) $(BOOTSTRAP) $(MANIFEST) $(APP)/Contents/Info.plist
|
||||||
|
|
||||||
check-dependencies:
|
check-dependencies:
|
||||||
@@ -77,7 +77,7 @@ run: all
|
|||||||
$(BUILD_DIR)/playback-check: Checks/playback.mm $(OBJECTS) Makefile | check-dependencies
|
$(BUILD_DIR)/playback-check: Checks/playback.mm $(OBJECTS) Makefile | check-dependencies
|
||||||
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
|
$(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
|
$(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
|
||||||
|
|
||||||
check-fixtures: | check-dependencies
|
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)
|
check-runtime: $(BUILD_DIR)/ffmpeg-runtime-check $(MANIFEST)
|
||||||
$(BUILD_DIR)/ffmpeg-runtime-check "$(APP)"
|
$(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
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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/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/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/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/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/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.
|
- `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-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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
private var progressURL: URL?
|
private var progressURL: URL?
|
||||||
private var resumePosition: Double?
|
private var resumePosition: Double?
|
||||||
private var lastSavedPosition: Double?
|
private var lastSavedPosition: Double?
|
||||||
|
private var systemPlayback: SystemPlaybackControls?
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard) {
|
init(defaults: UserDefaults = .standard) {
|
||||||
playbackProgress = PlaybackProgress(defaults: defaults)
|
playbackProgress = PlaybackProgress(defaults: defaults)
|
||||||
@@ -65,6 +66,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||||
MainActor.assumeIsolated { self?.poll() }
|
MainActor.assumeIsolated { self?.poll() }
|
||||||
}
|
}
|
||||||
|
systemPlayback = SystemPlaybackControls(model: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
var isBusy: Bool { resolvingYouTube || pendingRevision != nil || state.status == PLAYER_OPENING }
|
var isBusy: Bool { resolvingYouTube || pendingRevision != nil || state.status == PLAYER_OPENING }
|
||||||
@@ -138,6 +140,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
let request = UUID()
|
let request = UUID()
|
||||||
youtubeRequest = request
|
youtubeRequest = request
|
||||||
resolvingYouTube = true
|
resolvingYouTube = true
|
||||||
|
systemPlayback?.update()
|
||||||
commandError = nil
|
commandError = nil
|
||||||
revealControls()
|
revealControls()
|
||||||
youtubeTask = Task { [weak self] in
|
youtubeTask = Task { [weak self] in
|
||||||
@@ -212,6 +215,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
window?.representedURL = url
|
window?.representedURL = url
|
||||||
window?.title = title
|
window?.title = title
|
||||||
revealControls()
|
revealControls()
|
||||||
|
systemPlayback?.update()
|
||||||
}
|
}
|
||||||
|
|
||||||
func open(_ url: URL) {
|
func open(_ url: URL) {
|
||||||
@@ -236,10 +240,16 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func togglePlayback() {
|
func togglePlayback() {
|
||||||
guard let handle, canTogglePlayback else { return }
|
setPlayback(playing: state.status != PLAYER_PLAYING)
|
||||||
let accepted = state.status == PLAYER_PLAYING ? player_pause(handle) : player_play(handle)
|
}
|
||||||
|
|
||||||
|
@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."
|
commandError = accepted ? nil : "Could not queue playback. Please try again."
|
||||||
revealControls()
|
revealControls()
|
||||||
|
return accepted
|
||||||
}
|
}
|
||||||
|
|
||||||
func finishSeeking() {
|
func finishSeeking() {
|
||||||
@@ -275,10 +285,12 @@ final class PlayerModel: ObservableObject {
|
|||||||
window?.representedURL = nil
|
window?.representedURL = nil
|
||||||
window?.title = "terebi"
|
window?.title = "terebi"
|
||||||
revealControls()
|
revealControls()
|
||||||
|
systemPlayback?.update()
|
||||||
}
|
}
|
||||||
|
|
||||||
func seekRelative(_ seconds: Double) {
|
@discardableResult
|
||||||
guard canSeek, let handle else { return }
|
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 target = min(max(0, (seekPending ? seekPosition : state.position) + seconds), duration)
|
||||||
let accepted = player_seek(handle, target)
|
let accepted = player_seek(handle, target)
|
||||||
if accepted {
|
if accepted {
|
||||||
@@ -287,6 +299,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
commandError = accepted ? nil : "Could not queue the seek. Please try again."
|
commandError = accepted ? nil : "Could not queue the seek. Please try again."
|
||||||
revealControls()
|
revealControls()
|
||||||
|
return accepted
|
||||||
}
|
}
|
||||||
|
|
||||||
func stepFrame(_ direction: Int32) {
|
func stepFrame(_ direction: Int32) {
|
||||||
@@ -458,6 +471,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
guard next.revision != revision, next.status != PLAYER_OPENING else { return }
|
guard next.revision != revision, next.status != PLAYER_OPENING else { return }
|
||||||
pendingRevision = nil
|
pendingRevision = nil
|
||||||
}
|
}
|
||||||
|
defer { systemPlayback?.update() }
|
||||||
let statusChanged = state.status != next.status
|
let statusChanged = state.status != next.status
|
||||||
state = next
|
state = next
|
||||||
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR ||
|
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR ||
|
||||||
@@ -496,6 +510,8 @@ final class PlayerModel: ObservableObject {
|
|||||||
func shutdown() {
|
func shutdown() {
|
||||||
saveProgress()
|
saveProgress()
|
||||||
cancelYouTubeResolution()
|
cancelYouTubeResolution()
|
||||||
|
systemPlayback?.shutdown()
|
||||||
|
systemPlayback = nil
|
||||||
compactWindow.restore(resumeFullScreen: false)
|
compactWindow.restore(resumeFullScreen: false)
|
||||||
timer?.invalidate()
|
timer?.invalidate()
|
||||||
timer = nil
|
timer = nil
|
||||||
@@ -1151,7 +1167,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if !PLAYER_CHECK
|
||||||
@main
|
@main
|
||||||
|
#endif
|
||||||
@MainActor
|
@MainActor
|
||||||
struct TerebiApp: App {
|
struct TerebiApp: App {
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||||
|
|||||||
Reference in New Issue
Block a user