feat: configure explicit state-dependent player keybindings

This commit is contained in:
2026-10-11 15:25:24 +02:00
parent 722a225da5
commit 41e4a458cf
12 changed files with 1147 additions and 209 deletions
+275
View File
@@ -0,0 +1,275 @@
import AppKit
import SwiftUI
import TerebiConfiguration
extension KeyChord {
// Key codes identify native navigation keys; printable keys follow the active keyboard layout.
private static let nativeKeys: [UInt16: String] = [
36: "return", 48: "tab", 49: "space", 51: "backspace", 53: "escape",
76: "return", 115: "home", 116: "pageup", 117: "delete", 119: "end",
121: "pagedown", 123: "left", 124: "right", 125: "down", 126: "up"
]
private static let functionKeys: [UInt32: String] = Dictionary(uniqueKeysWithValues:
(1...35).map { (UInt32(NSF1FunctionKey) + UInt32($0 - 1), "f\($0)") })
static func from(_ event: NSEvent) -> KeyChord? {
guard event.type == .keyDown,
!event.modifierFlags.contains(.function) || nativeKeys[event.keyCode] != nil ||
event.charactersIgnoringModifiers?.unicodeScalars.first.map({
$0.value >= UInt32(NSF1FunctionKey) && $0.value <= UInt32(NSF35FunctionKey)
}) == true else { return nil }
var modifiers: KeyModifiers = []
if event.modifierFlags.contains(.command) { modifiers.insert(.command) }
if event.modifierFlags.contains(.control) { modifiers.insert(.control) }
if event.modifierFlags.contains(.option) { modifiers.insert(.option) }
if event.modifierFlags.contains(.shift) { modifiers.insert(.shift) }
if let key = nativeKeys[event.keyCode] { return KeyChord(key: key, modifiers: modifiers) }
guard let text = event.charactersIgnoringModifiers, text.count == 1,
let scalar = text.unicodeScalars.first else { return nil }
if let key = functionKeys[scalar.value] {
return KeyChord(key: key, modifiers: modifiers)
}
guard !CharacterSet.controlCharacters.contains(scalar),
!(0xF700...0xF8FF).contains(scalar.value) else { return nil }
return KeyChord(key: text.lowercased(), modifiers: modifiers)
}
var keyboardShortcut: KeyboardShortcut? {
let equivalent: KeyEquivalent
switch key {
case "space": equivalent = .space
case "escape": equivalent = .escape
case "return": equivalent = .return
case "tab": equivalent = .tab
case "backspace": equivalent = .delete
case "delete": equivalent = .deleteForward
case "left": equivalent = .leftArrow
case "right": equivalent = .rightArrow
case "up": equivalent = .upArrow
case "down": equivalent = .downArrow
case "home": equivalent = .home
case "end": equivalent = .end
case "pageup": equivalent = .pageUp
case "pagedown": equivalent = .pageDown
default:
if key.count == 1, let first = key.first { equivalent = KeyEquivalent(first) }
else if key.hasPrefix("f"), let number = Int(key.dropFirst()), (1...35).contains(number) {
equivalent = KeyEquivalent(Character(UnicodeScalar(UInt32(NSF1FunctionKey) + UInt32(number - 1))!))
} else { return nil }
}
var flags: EventModifiers = []
if modifiers.contains(.command) { flags.insert(.command) }
if modifiers.contains(.control) { flags.insert(.control) }
if modifiers.contains(.option) { flags.insert(.option) }
if modifiers.contains(.shift) { flags.insert(.shift) }
return KeyboardShortcut(equivalent, modifiers: flags)
}
}
extension PlayerModel {
private static let nativeNavigationKeys: Set<String> = [
"space", "return", "escape", "left", "right", "up", "down", "home", "end", "pageup", "pagedown"
]
// The local monitor calls this exact adapter; checks can supply real AppKit events.
@discardableResult
func handleKeyEvent(_ event: NSEvent, in window: NSWindow) -> Bool {
guard let chord = KeyChord.from(event) else { return false }
return handleKeyEvent(event, in: window, chord: chord)
}
private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool {
guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil,
!(window.firstResponder is NSTextView), !(window.firstResponder is NSTextField) else { return false }
// Preserve focus traversal, control activation/navigation and VoiceOver's own chords.
if chord.key == "tab" && chord.modifiers.subtracting(.shift).isEmpty { return false }
if window.firstResponder is NSControl,
chord.modifiers.subtracting(.shift).isEmpty,
Self.nativeNavigationKeys.contains(chord.key) {
return false
}
if NSWorkspace.shared.isVoiceOverEnabled,
chord.modifiers.contains([.control, .option]) { return false }
return handleKey(chord, isRepeat: event.isARepeat)
}
func performMenuAction(_ action: PlayerAction) {
if let event = NSApp.currentEvent, event.type == .keyDown {
if event.isARepeat && !action.allowsRepeat { return }
guard let chord = KeyChord.from(event),
keyBindings.shortcuts(for: action).contains(chord) else {
// Return/Space activating a selected menu item is native navigation, not a player shortcut.
perform(action)
return
}
if let window = event.window ?? NSApp.keyWindow,
!handleKeyEvent(event, in: window, chord: chord),
let responder = window.firstResponder as? NSView {
if !responder.performKeyEquivalent(with: event) { responder.keyDown(with: event) }
}
return
}
perform(action)
}
}
@MainActor
func PlayerActionButton(model: PlayerModel, action: PlayerAction, title: String,
selected: Bool = false) -> some View {
// Context menus use the same explicit actions as the native application menus.
Button { model.performMenuAction(action) } label: {
Text(selected ? "✓ \(title)" : title)
}
.keyboardShortcut(model.keyBindings.shortcuts(for: action).first?.keyboardShortcut)
.disabled(!model.canPerform(action))
}
@MainActor
struct PlayerQualityMenu: View {
@ObservedObject var model: PlayerModel
var body: some View {
Menu("Quality") {
PlayerActionButton(model: model, action: model.automaticQualityAction,
title: model.automaticYouTubeQuality ? "Disable Automatic Quality" : "Enable Automatic Quality (up to 1080p)")
Divider()
PlayerActionButton(model: model, action: .qualityDefault, title: "Default (up to 1080p)",
selected: !model.automaticYouTubeQuality && model.youtubeQuality == nil)
ForEach(model.youtubeHeights, id: \.self) { height in
PlayerActionButton(model: model, action: .quality(height), title: "\(height)p",
selected: !model.automaticYouTubeQuality && model.youtubeQuality == height)
}
if !model.youtubeHeights.isEmpty {
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
}
}
}
}
@MainActor
struct PlayerCommands: Commands {
@FocusedObject private var focusedModel: PlayerModel?
var body: some Commands {
// The player has one scene; app-level Open/Quit still work before it becomes focused.
let model = focusedModel ?? PlayerModel.shared
let hasFocusedScene = focusedModel != nil
let playback = model.playbackAction
let mute = model.muteAction
let fullscreen = model.fullscreenAction
let compact = model.compactAction
let onTop = model.onTopAction
let bufferInfo = model.bufferInfoAction
let snapping = model.snappingAction
let automaticQuality = model.automaticQualityAction
CommandGroup(replacing: .appTermination) {
Button("Quit terebi") { model.performMenuAction(.quit) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .quit).first?.keyboardShortcut)
.disabled(!model.canPerform(.quit))
}
CommandGroup(replacing: .newItem) {
Button("Open File…") { model.performMenuAction(.openFile) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .openFile).first?.keyboardShortcut)
.disabled(!model.canPerform(.openFile))
Button("Open YouTube URL…") { model.performMenuAction(.openYoutube) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .openYoutube).first?.keyboardShortcut)
.disabled(!model.canPerform(.openYoutube))
Button("Close File") { model.performMenuAction(.closeFile) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .closeFile).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.closeFile))
}
CommandGroup(replacing: .saveItem) {
Button("Close Window") { NSApp.keyWindow?.performClose(nil) }
.disabled(focusedModel == nil)
}
CommandMenu("Playback") {
Button(model.effectivePlaying ? "Pause" : "Play") { model.performMenuAction(playback) }
.keyboardShortcut(model.keyBindings.shortcuts(for: playback).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(playback))
Button("Seek Back 5 Seconds") { model.performMenuAction(.seekBackward5s) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .seekBackward5s).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.seekBackward5s))
Button("Seek Forward 5 Seconds") { model.performMenuAction(.seekForward5s) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .seekForward5s).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.seekForward5s))
Button("Seek Back 10 Seconds") { model.performMenuAction(.seekBackward10s) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .seekBackward10s).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.seekBackward10s))
Button("Seek Forward 10 Seconds") { model.performMenuAction(.seekForward10s) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .seekForward10s).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.seekForward10s))
Divider()
Button("Previous Frame") { model.performMenuAction(.stepFrameBackward) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .stepFrameBackward).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.stepFrameBackward))
Button("Next Frame") { model.performMenuAction(.stepFrameForward) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .stepFrameForward).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.stepFrameForward))
Divider()
Button("Volume Up") { model.performMenuAction(.volumeUp) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .volumeUp).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.volumeUp))
Button("Volume Down") { model.performMenuAction(.volumeDown) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .volumeDown).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.volumeDown))
Button(model.volume == 0 ? "Unmute" : "Mute") { model.performMenuAction(mute) }
.keyboardShortcut(model.keyBindings.shortcuts(for: mute).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(mute))
Divider()
Menu("Quality") {
Button(model.automaticYouTubeQuality ? "Disable Automatic Quality" : "Enable Automatic Quality (up to 1080p)") {
model.performMenuAction(automaticQuality)
}
.keyboardShortcut(model.keyBindings.shortcuts(for: automaticQuality).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(automaticQuality))
Divider()
Button(!model.automaticYouTubeQuality && model.youtubeQuality == nil ?
"✓ Default (up to 1080p)" : "Default (up to 1080p)") {
model.performMenuAction(.qualityDefault)
}
.keyboardShortcut(model.keyBindings.shortcuts(for: .qualityDefault).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.qualityDefault))
ForEach(model.youtubeHeights, id: \.self) { height in
Button(!model.automaticYouTubeQuality && model.youtubeQuality == height ? "✓ \(height)p" : "\(height)p") {
model.performMenuAction(.quality(height))
}
.keyboardShortcut(model.keyBindings.shortcuts(for: .quality(height)).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.quality(height)))
}
if !model.youtubeHeights.isEmpty {
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
}
}
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
model.performMenuAction(bufferInfo)
}
.keyboardShortcut(model.keyBindings.shortcuts(for: bufferInfo).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(bufferInfo))
}
CommandGroup(replacing: .sidebar) {
Button(fullscreen == .exitFullscreen ? "Exit Full Screen" : "Enter Full Screen") {
model.performMenuAction(fullscreen)
}
.keyboardShortcut(model.keyBindings.shortcuts(for: fullscreen).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(fullscreen))
}
CommandGroup(after: .windowArrangement) {
Button(compact == .exitCompact ? "Exit Compact Mode" : "Enter Compact Mode") {
model.performMenuAction(compact)
}
.keyboardShortcut(model.keyBindings.shortcuts(for: compact).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(compact))
Button(model.compactWindow.snappingEnabled ? "Disable Compact Snapping" : "Enable Compact Snapping") {
model.performMenuAction(snapping)
}
.keyboardShortcut(model.keyBindings.shortcuts(for: snapping).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(snapping))
Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top") {
model.performMenuAction(onTop)
}
.keyboardShortcut(model.keyBindings.shortcuts(for: onTop).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(onTop))
}
}
}
+1 -1
View File
@@ -68,7 +68,7 @@ final class SystemPlaybackControls {
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 .toggle: accepted = model.setPlayback(playing: !model.effectivePlaying)
case let .skip(seconds): accepted = model.seekRelative(seconds)
}
return accepted ? .success : .commandFailed
+201 -143
View File
@@ -27,6 +27,24 @@ final class PlayerModel: ObservableObject {
@Published private(set) var isFullScreen = false
@Published private(set) var isOnTop = false
let compactWindow: CompactWindowController
let keyBindings: KeyBindings
@Published private(set) var playbackIntent: Bool?
private var playbackInFlight: (playing: Bool, revision: UInt64)?
@Published private(set) var fullScreenIntent: Bool?
@Published private(set) var compactIntent: Bool?
var effectivePlaying: Bool { playbackIntent ?? (state.status == PLAYER_PLAYING) }
var playbackAction: PlayerAction { effectivePlaying ? .pause : .play }
var fullscreenAction: PlayerAction { (fullScreenIntent ?? isFullScreen) ? .exitFullscreen : .enterFullscreen }
var compactAction: PlayerAction { (compactIntent ?? isCompact) ? .exitCompact : .enterCompact }
var muteAction: PlayerAction { volume == 0 ? .unmute : .mute }
var onTopAction: PlayerAction { isOnTop ? .disableAlwaysOnTop : .enableAlwaysOnTop }
var bufferInfoAction: PlayerAction { showsBufferInfo ? .hideBufferInfo : .showBufferInfo }
var snappingAction: PlayerAction {
compactWindow.snappingEnabled ? .disableCompactSnapping : .enableCompactSnapping
}
var automaticQualityAction: PlayerAction {
automaticYouTubeQuality ? .disableAutomaticQuality : .enableAutomaticQuality
}
var isCompact: Bool { compactWindow.isCompact }
var canToggleCompact: Bool {
isCompact || compactWindow.isTransitioning ||
@@ -66,6 +84,7 @@ final class PlayerModel: ObservableObject {
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
self.qualityClock = qualityClock
keyBindings = configuration.keys
automaticYouTubeQuality = configuration.automaticQuality
showsBufferInfo = configuration.showBufferInfo
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges)
@@ -80,7 +99,14 @@ final class PlayerModel: ObservableObject {
videoView = nil
commandError = "Could not create the media player."
}
compactWindow.onChange = { [weak self] in self?.objectWillChange.send() }
compactWindow.onChange = { [weak self] in
guard let self else { return }
if !self.compactWindow.isTransitioning {
self.compactIntent = nil
self.fullScreenIntent = nil
}
self.objectWillChange.send()
}
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.poll() }
}
@@ -187,7 +213,7 @@ final class PlayerModel: ObservableObject {
}
let current = player_get_state(handle)
let position = !isLiveStream && current.can_seek ? current.position : nil
resolveYouTube(url, height: height, restoring: (position, current.status == PLAYER_PLAYING))
resolveYouTube(url, height: height, restoring: (position, effectivePlaying))
}
private func resolveYouTube(_ url: URL, height: Int?, restoring: (position: Double?, playing: Bool)? = nil,
@@ -200,6 +226,10 @@ final class PlayerModel: ObservableObject {
revealControls()
return
}
if !automatic {
playbackIntent = nil
playbackInFlight = nil
}
let request = UUID()
youtubeRequest = request
resolvingYouTube = true
@@ -296,6 +326,8 @@ final class PlayerModel: ObservableObject {
private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false,
restoring: (position: Double?, playing: Bool)? = nil, automatic: Bool = false) {
guard let handle else { return }
playbackIntent = nil
playbackInFlight = nil
if automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
pendingRevision = revision
fileName = title
@@ -347,7 +379,7 @@ final class PlayerModel: ObservableObject {
}
func togglePlayback() {
setPlayback(playing: state.status != PLAYER_PLAYING)
perform(playbackAction)
}
@discardableResult
@@ -355,7 +387,18 @@ final class PlayerModel: ObservableObject {
guard let handle, canTogglePlayback else { return false }
if !playing && resolvingAutomaticQuality { cancelYouTubeResolution() }
if !playing { automaticQuality.resetHealth() }
if playbackInFlight != nil {
playbackIntent = playing
revealControls()
return true
}
guard effectivePlaying != playing else { return true }
let revision = player_get_state(handle).revision
let accepted = playing ? player_play(handle) : player_pause(handle)
if accepted {
playbackIntent = playing
playbackInFlight = (playing, revision)
}
commandError = accepted ? nil : "Could not queue playback. Please try again."
revealControls()
return accepted
@@ -382,6 +425,9 @@ final class PlayerModel: ObservableObject {
commandError = "Could not queue closing the file. Please try again."
return
}
playbackIntent = nil
playbackInFlight = nil
compactIntent = nil
compactWindow.restore(resumeFullScreen: false)
fileName = nil
fileIcon = nil
@@ -420,12 +466,18 @@ final class PlayerModel: ObservableObject {
return accepted
}
func stepFrame(_ direction: Int32) {
guard canStepFrame, let handle else { return }
@discardableResult
func stepFrame(_ direction: Int32) -> Bool {
guard canStepFrame, let handle else { return false }
let accepted = player_step_frame(handle, direction)
if accepted { seekPending = false }
if accepted {
seekPending = false
playbackIntent = nil
playbackInFlight = nil
}
commandError = accepted ? nil : "Could not queue the frame step. Please try again."
revealControls()
return accepted
}
func setVolume(_ value: Double) {
@@ -438,8 +490,7 @@ final class PlayerModel: ObservableObject {
}
func toggleMute() {
guard canSetVolume else { return }
setVolume(volume == 0 ? lastAudibleVolume : 0)
perform(muteAction)
}
func attachWindow(_ window: NSWindow) {
@@ -455,6 +506,7 @@ final class PlayerModel: ObservableObject {
func updateWindowState() {
guard let window else { return }
isFullScreen = window.styleMask.contains(.fullScreen)
if !compactWindow.isTransitioning && fullScreenIntent == isFullScreen { fullScreenIntent = nil }
windowActive = window.isKeyWindow
if !compactWindow.isTransitioning {
window.level = isCompact || (isOnTop && !isFullScreen) ? .floating : .normal
@@ -464,25 +516,119 @@ final class PlayerModel: ObservableObject {
}
func toggleFullScreen() {
guard !compactWindow.isTransitioning else { return }
if isCompact { compactWindow.restore(resumeFullScreen: false) }
window?.toggleFullScreen(nil)
revealControls()
perform(fullscreenAction)
}
func toggleCompact() {
guard canToggleCompact else { return }
compactWindow.toggle(aspect: state.video_aspect_ratio)
controlsHovered = false
titleHovered = false
revealControls()
perform(compactAction)
}
func toggleOnTop() {
guard !isFullScreen, !isCompact, !compactWindow.isTransitioning else { return }
isOnTop.toggle()
window?.level = isOnTop ? .floating : .normal
revealControls()
perform(onTopAction)
}
func canPerform(_ action: PlayerAction) -> Bool {
switch action {
case .play: return canTogglePlayback && !effectivePlaying
case .pause: return canTogglePlayback && effectivePlaying
case .mute: return canSetVolume && volume > 0
case .unmute: return canSetVolume && volume == 0
case .volumeUp: return canSetVolume && volume < 1
case .volumeDown: return canSetVolume && volume > 0
case .seekForward5s, .seekBackward5s, .seekForward10s, .seekBackward10s: return canSeek
case .stepFrameForward, .stepFrameBackward: return canStepFrame
case .enterFullscreen, .exitFullscreen:
return window != nil && fullScreenIntent == nil && !compactWindow.isTransitioning &&
(action == .enterFullscreen ? !isFullScreen : isFullScreen)
case .enterCompact:
return window != nil && !(compactIntent ?? isCompact) && !compactWindow.isTransitioning &&
canTogglePlayback && state.has_video &&
state.video_aspect_ratio.isFinite && state.video_aspect_ratio > 0
case .exitCompact: return window != nil && (compactIntent ?? isCompact)
case .enableAlwaysOnTop, .disableAlwaysOnTop:
return window != nil && !isFullScreen && fullScreenIntent == nil &&
!isCompact && compactIntent != true && !compactWindow.isTransitioning &&
(action == .enableAlwaysOnTop ? !isOnTop : isOnTop)
case .openFile, .openYoutube: return canOpen
case .closeFile: return canClose
case .quit: return true
case .showBufferInfo: return !showsBufferInfo
case .hideBufferInfo: return showsBufferInfo
case .enableCompactSnapping: return !compactWindow.snappingEnabled
case .disableCompactSnapping: return compactWindow.snappingEnabled
case .enableAutomaticQuality: return !automaticYouTubeQuality
case .disableAutomaticQuality: return automaticYouTubeQuality
case .qualityDefault:
return canChangeYouTubeQuality && (automaticYouTubeQuality || youtubeQuality != nil)
case let .quality(height):
return canChangeYouTubeQuality && youtubeHeights.contains(height) &&
(automaticYouTubeQuality || youtubeQuality != height)
}
}
@discardableResult
func perform(_ action: PlayerAction) -> Bool {
guard canPerform(action) else { return false }
switch action {
case .play: return setPlayback(playing: true)
case .pause: return setPlayback(playing: false)
case .mute: setVolume(0)
case .unmute: setVolume(lastAudibleVolume)
case .volumeUp: setVolume(min(1, volume + 0.05))
case .volumeDown: setVolume(max(0, volume - 0.05))
case .seekForward5s: return seekRelative(5)
case .seekBackward5s: return seekRelative(-5)
case .seekForward10s: return seekRelative(10)
case .seekBackward10s: return seekRelative(-10)
case .stepFrameForward: return stepFrame(1)
case .stepFrameBackward: return stepFrame(-1)
case .enterFullscreen, .exitFullscreen:
if isCompact {
compactIntent = false
compactWindow.restore(resumeFullScreen: false)
}
fullScreenIntent = action == .enterFullscreen
window?.toggleFullScreen(nil)
revealControls()
case .enterCompact:
compactIntent = true
compactWindow.toggle(aspect: state.video_aspect_ratio)
controlsHovered = false
titleHovered = false
revealControls()
case .exitCompact:
compactIntent = false
compactWindow.restore()
revealControls()
case .enableAlwaysOnTop, .disableAlwaysOnTop:
isOnTop = action == .enableAlwaysOnTop
window?.level = isOnTop ? .floating : .normal
revealControls()
case .openFile: openPanel()
case .openYoutube: openYouTubePanel()
case .closeFile: closeFile()
case .quit: NSApp.terminate(nil)
case .showBufferInfo: setShowsBufferInfo(true)
case .hideBufferInfo: setShowsBufferInfo(false)
case .enableCompactSnapping: compactWindow.snappingEnabled = true
case .disableCompactSnapping: compactWindow.snappingEnabled = false
case .enableAutomaticQuality: setAutomaticYouTubeQuality(true)
case .disableAutomaticQuality: setAutomaticYouTubeQuality(false)
case .qualityDefault: setYouTubeQuality(nil)
case let .quality(height): setYouTubeQuality(height)
}
return true
}
@discardableResult
func handleKey(_ chord: KeyChord, isRepeat: Bool = false) -> Bool {
let actions = keyBindings.actions(for: chord)
guard !actions.isEmpty else { return false }
for action in actions where canPerform(action) {
if !isRepeat || action.allowsRepeat { perform(action) }
break
}
return true
}
func setControlsHovered(_ hovered: Bool) {
@@ -594,6 +740,17 @@ final class PlayerModel: ObservableObject {
defer { systemPlayback?.update() }
let statusChanged = state.status != next.status
state = next
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR {
playbackIntent = nil
playbackInFlight = nil
} else if let flight = playbackInFlight, next.revision != flight.revision,
next.status == PLAYER_ENDED || (next.status == PLAYER_PLAYING) == flight.playing,
next.status == PLAYER_PLAYING || next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED {
let desired = playbackIntent
playbackIntent = nil
playbackInFlight = nil
if let desired, desired != flight.playing { setPlayback(playing: desired) }
}
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR ||
(!isBusy && !next.has_video) {
compactWindow.restore(resumeFullScreen: false)
@@ -633,7 +790,7 @@ final class PlayerModel: ObservableObject {
let reserve = bufferedRange.map { max(0, $0.upperBound - state.position) }
let sample = AutomaticQuality.Sample(
enabled: automaticYouTubeQuality, network: isNetworkMedia,
playing: state.status == PLAYER_PLAYING, opening: isBusy || resolvingYouTube || resumePosition != nil,
playing: effectivePlaying, opening: isBusy || resolvingYouTube || resumePosition != nil,
seeking: isSeeking || seekPending || state.seek_pending, live: isLiveStream,
buffering: state.buffering, position: state.position, duration: state.duration,
ahead: reserve, height: youtubeHeight, heights: youtubeHeights)
@@ -906,52 +1063,22 @@ private struct PlayerContent: View {
private var actionsMenu: some View {
Menu {
Button("Open File…", action: model.openPanel).disabled(!model.canOpen)
Button("Open YouTube URL…", action: model.openYouTubePanel).disabled(!model.canOpen)
Button("Close File", action: model.closeFile).disabled(!model.canClose)
if !model.youtubeHeights.isEmpty {
Divider()
Menu("Quality") {
Toggle("Automatic Quality (up to 1080p)", isOn: Binding(
get: { model.automaticYouTubeQuality },
set: { model.setAutomaticYouTubeQuality($0) }))
Divider()
Button { model.setYouTubeQuality(nil) } label: {
HStack {
Text("Default (up to 1080p)")
if !model.automaticYouTubeQuality && model.youtubeQuality == nil {
Image(systemName: "checkmark")
}
}
}
.disabled(!model.canChangeYouTubeQuality)
ForEach(model.youtubeHeights, id: \.self) { height in
Button { model.setYouTubeQuality(height) } label: {
HStack {
Text("\(height)p")
if !model.automaticYouTubeQuality && model.youtubeQuality == height {
Image(systemName: "checkmark")
}
}
}
.disabled(!model.canChangeYouTubeQuality)
}
}
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
}
PlayerActionButton(model: model, action: .openFile, title: "Open File…")
PlayerActionButton(model: model, action: .openYoutube, title: "Open YouTube URL…")
PlayerActionButton(model: model, action: .closeFile, title: "Close File")
Divider()
Toggle("Show Buffer Info", isOn: Binding(
get: { model.showsBufferInfo },
set: { model.setShowsBufferInfo($0) }))
PlayerQualityMenu(model: model)
Divider()
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact)
.disabled(!model.canToggleCompact)
Toggle("Snap to Screen Edges", isOn: Binding(
get: { model.compactWindow.snappingEnabled },
set: { model.compactWindow.snappingEnabled = $0 }))
PlayerActionButton(model: model, action: model.bufferInfoAction,
title: model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info")
Divider()
Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
.disabled(model.isFullScreen || model.isCompact || model.compactWindow.isTransitioning)
PlayerActionButton(model: model, action: model.compactAction,
title: model.compactAction == .exitCompact ? "Exit Compact Mode" : "Enter Compact Mode")
PlayerActionButton(model: model, action: model.snappingAction,
title: model.compactWindow.snappingEnabled ? "Disable Compact Snapping" : "Enable Compact Snapping")
Divider()
PlayerActionButton(model: model, action: model.onTopAction,
title: model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top")
} label: {
Image(systemName: "gearshape").font(.system(size: 17)).frame(width: 24, height: 24)
}
@@ -965,8 +1092,8 @@ private struct PlayerContent: View {
private var compactControls: some View {
VStack(spacing: 4) {
HStack(spacing: 16) {
iconButton(model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill",
model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback)
iconButton(model.effectivePlaying ? "pause.fill" : "play.fill",
model.effectivePlaying ? "Pause" : "Play", action: model.togglePlayback)
.disabled(!model.canTogglePlayback)
iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact)
.disabled(model.compactWindow.isTransitioning)
@@ -1005,13 +1132,13 @@ private struct PlayerContent: View {
iconButton("backward.fill", "Seek Back 5 Seconds") { model.seekRelative(-5) }
.disabled(!model.canSeek)
Button(action: model.togglePlayback) {
Image(systemName: model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill")
Image(systemName: model.effectivePlaying ? "pause.fill" : "play.fill")
.font(.system(size: 24))
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
.accessibilityLabel(model.state.status == PLAYER_PLAYING ? "Pause" : "Play")
.help(model.state.status == PLAYER_PLAYING ? "Pause (K / Space)" : "Play (K / Space)")
.accessibilityLabel(model.effectivePlaying ? "Pause" : "Play")
.help(model.effectivePlaying ? "Pause" : "Play")
.disabled(!model.canTogglePlayback)
iconButton("forward.fill", "Seek Forward 5 Seconds") { model.seekRelative(5) }
.disabled(!model.canSeek)
@@ -1231,34 +1358,8 @@ private struct WindowChrome: NSViewRepresentable {
required init?(coder: NSCoder) { nil }
func handleKeyEvent(_ event: NSEvent) -> Bool {
guard event.type == .keyDown, let window = configuredWindow, event.window === window,
!(window.firstResponder is NSTextView),
event.modifierFlags.intersection([.command, .control, .option]).isEmpty else { return false }
if event.keyCode == 53 && model.isFullScreen {
if !event.isARepeat { model.toggleFullScreen() }
return true
}
let key = event.charactersIgnoringModifiers?.lowercased()
switch key {
case "j": model.seekRelative(-10)
case "l": model.seekRelative(10)
case ",": model.stepFrame(-1)
case ".": model.stepFrame(1)
case "k", " ":
if !event.isARepeat { model.togglePlayback() }
case "m":
if !event.isARepeat { model.toggleMute() }
case "f":
if !event.isARepeat { model.toggleFullScreen() }
case "p":
if !event.isARepeat { model.toggleOnTop() }
case "o":
if !event.isARepeat { model.openPanel() }
case "q":
if !event.isARepeat { NSApp.terminate(nil) }
default: return false
}
return true
guard let window = configuredWindow else { return false }
return model.handleKeyEvent(event, in: window)
}
override func viewDidMoveToWindow() {
@@ -1396,54 +1497,11 @@ struct TerebiApp: App {
var body: some Scene {
Window("\(model.fileName ?? "terebi")", id: "terebi") {
PlayerContent(model: model)
.focusedSceneObject(model)
}
.defaultSize(width: 900, height: 560)
.windowStyle(.hiddenTitleBar)
.commands {
CommandGroup(replacing: .newItem) {
Button("Open File…", action: model.openPanel).keyboardShortcut("o")
.disabled(!model.canOpen)
Button("Open YouTube URL…", action: model.openYouTubePanel)
.keyboardShortcut("o", modifiers: [.command, .shift])
.disabled(!model.canOpen)
Button("Close File", action: model.closeFile)
.keyboardShortcut("w", modifiers: [.command, .shift])
.disabled(!model.canClose)
}
CommandMenu("Playback") {
Button(model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback)
.disabled(!model.canTogglePlayback)
Button("Seek Back 5 Seconds") { model.seekRelative(-5) }
.keyboardShortcut(.leftArrow, modifiers: []).disabled(!model.canSeek)
Button("Seek Forward 5 Seconds") { model.seekRelative(5) }
.keyboardShortcut(.rightArrow, modifiers: []).disabled(!model.canSeek)
Button("Seek Back 10 Seconds") { model.seekRelative(-10) }.disabled(!model.canSeek)
Button("Seek Forward 10 Seconds") { model.seekRelative(10) }.disabled(!model.canSeek)
Divider()
Button("Previous Frame") { model.stepFrame(-1) }.disabled(!model.canStepFrame)
Button("Next Frame") { model.stepFrame(1) }.disabled(!model.canStepFrame)
Divider()
Button("Volume Up") { model.setVolume(min(1, model.volume + 0.05)) }
.keyboardShortcut(.upArrow, modifiers: []).disabled(!model.canSetVolume)
Button("Volume Down") { model.setVolume(max(0, model.volume - 0.05)) }
.keyboardShortcut(.downArrow, modifiers: []).disabled(!model.canSetVolume)
Button(model.volume == 0 ? "Unmute" : "Mute", action: model.toggleMute)
.disabled(!model.canSetVolume)
}
CommandGroup(after: .windowArrangement) {
Button(model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen)
.keyboardShortcut("f", modifiers: [.control, .command])
.disabled(model.compactWindow.isTransitioning)
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact)
.keyboardShortcut("p", modifiers: [.control, .command])
.disabled(!model.canToggleCompact)
Toggle("Snap to Screen Edges", isOn: Binding(
get: { model.compactWindow.snappingEnabled },
set: { model.compactWindow.snappingEnabled = $0 }))
Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
.disabled(model.isFullScreen || model.isCompact || model.compactWindow.isTransitioning)
}
}
.commands { PlayerCommands() }
}
}