Files
yattee/Yattee/Core/PlaybackCommands.swift
Arkadiusz Fal 582aa56644 Add keyboard shortcuts to toggle corner layout panels
Playback menu items toggle the info panel below the video (⌥⌘I), the
side column (⌥⌘S) and both at once (⌥⌘P) while the corner layout is
active. Showing one region from the hidden state brings back only that
region; hiding the last visible one hides the whole panel.
2026-10-21 22:06:27 +02:00

385 lines
11 KiB
Swift

//
// PlaybackCommands.swift
// Yattee
//
// Menu bar commands for playback control.
//
import SwiftUI
#if !os(tvOS)
/// Playback-related menu bar commands.
/// Works on both macOS and iPadOS 26+.
struct PlaybackCommands: Commands {
let appEnvironment: AppEnvironment
private var playerService: PlayerService {
appEnvironment.playerService
}
private var navigationCoordinator: NavigationCoordinator {
appEnvironment.navigationCoordinator
}
private var state: PlayerState {
playerService.state
}
private var settingsManager: SettingsManager {
appEnvironment.settingsManager
}
private var hasActiveVideo: Bool {
state.currentVideo != nil
}
private var isPlayerExpanded: Bool {
navigationCoordinator.isPlayerExpanded
}
var body: some Commands {
CommandMenu(String(localized: "menu.playback")) {
// Player visibility (existing)
playerToggleButton
Divider()
// Core playback
playPauseButton
Divider()
// Seeking
seekBackwardButton
seekForwardButton
secondarySeekBackwardButton
secondarySeekForwardButton
Divider()
// Navigation
previousVideoButton
nextVideoButton
Divider()
// Speed
slowerButton
fasterButton
resetSpeedButton
Divider()
// Volume
volumeUpButton
volumeDownButton
muteButton
Divider()
// Display modes
pipButton
Divider()
// Corner layout panels
toggleInfoPanelButton
toggleSidebarButton
toggleAllPanelsButton
Divider()
closeVideoButton
}
}
// MARK: - Player Visibility
private var playerToggleButton: some View {
Button {
togglePlayerExpanded()
} label: {
Text(isPlayerExpanded
? String(localized: "menu.playback.hidePlayer")
: String(localized: "menu.playback.showPlayer"))
}
.keyboardShortcut("p", modifiers: [.command, .shift])
.disabled(!hasActiveVideo)
}
private func togglePlayerExpanded() {
if isPlayerExpanded {
navigationCoordinator.isPlayerExpanded = false
} else {
navigationCoordinator.expandPlayer()
}
}
// MARK: - Core Playback
private var playPauseButton: some View {
Button {
playerService.togglePlayPause()
} label: {
Text(String(localized: "menu.playback.playPause"))
}
.keyboardShortcut("k", modifiers: [.command])
.disabled(!hasActiveVideo)
}
// MARK: - Seeking
/// Seek durations follow the active player controls preset, matching the
/// in-player arrow key shortcuts.
private var seekBackwardSeconds: Int {
appEnvironment.activeControlsCenterSettings.seekBackwardSeconds
}
private var seekForwardSeconds: Int {
appEnvironment.activeControlsCenterSettings.seekForwardSeconds
}
private var seekBackwardButton: some View {
Button {
playerService.seekBackward(by: TimeInterval(seekBackwardSeconds))
} label: {
Text(String(localized: "menu.playback.seekBackward \(seekBackwardSeconds)"))
}
.keyboardShortcut(.leftArrow, modifiers: [.command])
.disabled(!hasActiveVideo)
}
private var seekForwardButton: some View {
Button {
playerService.seekForward(by: TimeInterval(seekForwardSeconds))
} label: {
Text(String(localized: "menu.playback.seekForward \(seekForwardSeconds)"))
}
.keyboardShortcut(.rightArrow, modifiers: [.command])
.disabled(!hasActiveVideo)
}
private var secondarySeekBackwardSeconds: Int {
appEnvironment.activeControlsCenterSettings.secondarySeekBackwardSeconds
}
private var secondarySeekForwardSeconds: Int {
appEnvironment.activeControlsCenterSettings.secondarySeekForwardSeconds
}
private var secondarySeekBackwardButton: some View {
Button {
playerService.seekBackward(by: TimeInterval(secondarySeekBackwardSeconds))
} label: {
Text(String(localized: "menu.playback.seekBackward \(secondarySeekBackwardSeconds)"))
}
.keyboardShortcut(.leftArrow, modifiers: [.command, .shift])
.disabled(!hasActiveVideo)
}
private var secondarySeekForwardButton: some View {
Button {
playerService.seekForward(by: TimeInterval(secondarySeekForwardSeconds))
} label: {
Text(String(localized: "menu.playback.seekForward \(secondarySeekForwardSeconds)"))
}
.keyboardShortcut(.rightArrow, modifiers: [.command, .shift])
.disabled(!hasActiveVideo)
}
// MARK: - Navigation
private var previousVideoButton: some View {
Button {
Task {
await playerService.playPrevious()
}
} label: {
Text(String(localized: "menu.playback.previousVideo"))
}
.keyboardShortcut(.leftArrow, modifiers: [.command, .option])
.disabled(!hasActiveVideo || !state.hasPrevious)
}
private var nextVideoButton: some View {
Button {
Task {
await playerService.playNext()
}
} label: {
Text(String(localized: "menu.playback.nextVideo"))
}
.keyboardShortcut(.rightArrow, modifiers: [.command, .option])
.disabled(!hasActiveVideo || !state.hasNext)
}
// MARK: - Speed
private var slowerButton: some View {
Button {
cycleSpeedDown()
} label: {
Text(String(localized: "menu.playback.slower"))
}
.keyboardShortcut("[", modifiers: [.command])
.disabled(!hasActiveVideo)
}
private var fasterButton: some View {
Button {
cycleSpeedUp()
} label: {
Text(String(localized: "menu.playback.faster"))
}
.keyboardShortcut("]", modifiers: [.command])
.disabled(!hasActiveVideo)
}
private var resetSpeedButton: some View {
Button {
state.rate = .x1
playerService.currentBackend?.rate = 1.0
} label: {
Text(String(localized: "menu.playback.resetSpeed"))
}
.keyboardShortcut("0", modifiers: [.command])
.disabled(!hasActiveVideo)
}
private func cycleSpeedDown() {
let rates = PlaybackRate.allCases
guard let currentIndex = rates.firstIndex(of: state.rate) else { return }
if currentIndex > 0 {
let newRate = rates[currentIndex - 1]
state.rate = newRate
playerService.currentBackend?.rate = Float(newRate.rawValue)
}
}
private func cycleSpeedUp() {
let rates = PlaybackRate.allCases
guard let currentIndex = rates.firstIndex(of: state.rate) else { return }
if currentIndex < rates.count - 1 {
let newRate = rates[currentIndex + 1]
state.rate = newRate
playerService.currentBackend?.rate = Float(newRate.rawValue)
}
}
// MARK: - Volume
private var volumeUpButton: some View {
Button {
let newVolume = min(1.0, state.volume + 0.1)
state.volume = newVolume
playerService.currentBackend?.volume = newVolume
} label: {
Text(String(localized: "menu.playback.volumeUp"))
}
.keyboardShortcut(.upArrow, modifiers: [.command])
.disabled(!hasActiveVideo)
}
private var volumeDownButton: some View {
Button {
let newVolume = max(0.0, state.volume - 0.1)
state.volume = newVolume
playerService.currentBackend?.volume = newVolume
} label: {
Text(String(localized: "menu.playback.volumeDown"))
}
.keyboardShortcut(.downArrow, modifiers: [.command])
.disabled(!hasActiveVideo)
}
private var muteButton: some View {
Button {
state.isMuted.toggle()
playerService.currentBackend?.isMuted = state.isMuted
} label: {
Text(String(localized: "menu.playback.mute"))
}
.keyboardShortcut("m", modifiers: [.command, .shift])
.disabled(!hasActiveVideo)
}
// MARK: - Display Modes
private var pipButton: some View {
Button {
if let mpvBackend = playerService.currentBackend as? MPVBackend {
mpvBackend.togglePiP()
}
} label: {
Text(String(localized: "menu.playback.pip"))
}
.keyboardShortcut("i", modifiers: [.command, .shift])
.disabled(!hasActiveVideo || !state.isPiPPossible || state.currentStream?.isAudioOnly == true)
}
// MARK: - Corner Layout Panels
private var isCornerLayoutActive: Bool {
isPlayerExpanded && navigationCoordinator.isCornerLayoutActive
}
private var toggleInfoPanelButton: some View {
Button {
navigationCoordinator.cornerPanelToggleRequest = CornerPanelToggleRequest(target: .belowVideo)
} label: {
Text(String(localized: "menu.playback.toggleInfoPanel"))
}
.keyboardShortcut("i", modifiers: [.command, .option])
.disabled(!isCornerLayoutActive)
}
private var toggleSidebarButton: some View {
Button {
navigationCoordinator.cornerPanelToggleRequest = CornerPanelToggleRequest(target: .sideColumn)
} label: {
Text(String(localized: "menu.playback.toggleSidebar"))
}
.keyboardShortcut("s", modifiers: [.command, .option])
.disabled(!isCornerLayoutActive)
}
private var toggleAllPanelsButton: some View {
Button {
navigationCoordinator.cornerPanelToggleRequest = CornerPanelToggleRequest(target: .all)
} label: {
Text(String(localized: "menu.playback.toggleAllPanels"))
}
.keyboardShortcut("p", modifiers: [.command, .option])
.disabled(!isCornerLayoutActive)
}
// MARK: - Close video button
private var closeVideoButton: some View {
Button {
closeVideo()
} label: {
Text(String(localized: "menu.playback.closeVideo"))
}
.keyboardShortcut(".", modifiers: [.command])
.disabled(!hasActiveVideo)
}
private func closeVideo() {
// Mark as closing to hide tab accessory before dismissal
state.isClosingVideo = true
// Clear the queue when closing video
appEnvironment.queueManager.clearQueue()
// Reset panel visibility when closing player
settingsManager.landscapeDetailsPanelVisible = false
// Stop player FIRST before dismissing window
playerService.stop()
// Then dismiss player window (after backend is stopped)
navigationCoordinator.isPlayerExpanded = false
}
}
#endif