Files
yattee/Yattee/Core/PlaybackCommands.swift
Arkadiusz Fal ca67d480ca Make seek durations configurable in Playback menu and add secondary seek
Playback menu seek items now follow the durations from the active player
controls preset instead of hardcoded 10s/30s labels. AppEnvironment caches
the active preset's center settings (refreshed on preset change
notifications) so menu commands can read them synchronously.

Add secondary seek durations (default 30s) to CenterSectionSettings,
configurable on macOS in Seek Durations settings, used by Shift+arrows in
the player and Cmd+Shift+arrows in the Playback menu.

Also let Cmd-based key equivalents pass through the player keyboard
monitor so menu shortcuts (e.g. Cmd+Option+arrow previous/next video)
work while the player window has focus, and unify macOS built-in preset
primary seek at 10s to match iOS/tvOS (built-in presets version 8).
2026-06-19 08:50:39 +02:00

343 lines
9.7 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()
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)
}
// 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 state when closing player
settingsManager.landscapeDetailsPanelVisible = false
settingsManager.landscapeDetailsPanelPinned = false
// Stop player FIRST before dismissing window
playerService.stop()
// Then dismiss player window (after backend is stopped)
navigationCoordinator.isPlayerExpanded = false
}
}
#endif