fix: respond to macOS system playback controls

This commit is contained in:
2026-10-11 01:38:37 +02:00
parent c6d2958744
commit cf24a0fc4f
5 changed files with 251 additions and 8 deletions
+84
View File
@@ -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")
}
}
+10 -4
View File
@@ -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
+5
View File
@@ -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.
+130
View File
@@ -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
}
}
+22 -4
View File
@@ -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