feat: configure explicit state-dependent player keybindings
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user