feat: configure explicit state-dependent player keybindings
This commit is contained in:
+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