Files
yattee/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift
2026-02-08 18:33:56 +01:00

260 lines
8.1 KiB
Swift

//
// MacOSPlayerControlsView.swift
// Yattee
//
// QuickTime-style player controls for macOS with hover-to-show behavior and keyboard shortcuts.
//
#if os(macOS)
import AppKit
import SwiftUI
/// QuickTime-style player controls overlay for macOS.
/// Shows condensed control bar on hover, hides when mouse leaves.
struct MacOSPlayerControlsView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Bindable var playerState: PlayerState
// MARK: - Callbacks
let onPlayPause: () -> Void
let onSeek: (TimeInterval) async -> Void
let onSeekForward: (TimeInterval) async -> Void
let onSeekBackward: (TimeInterval) async -> Void
var onToggleFullscreen: (() -> Void)? = nil
var isFullscreen: Bool = false
var onClose: (() -> Void)? = nil
var onTogglePiP: (() -> Void)? = nil
var onPlayNext: (() async -> Void)? = nil
var onVolumeChanged: ((Float) -> Void)? = nil
var onMuteToggled: (() -> Void)? = nil
var onShowSettings: (() -> Void)? = nil
// MARK: - State
@State private var isHovering = false
@State private var hideTimer: Timer?
@State private var isInteracting = false
@State private var showControls: Bool?
@State private var keyboardMonitor: Any?
// MARK: - Computed Properties
/// Controls visibility - show when hovering, interacting, or paused
private var shouldShowControls: Bool {
if let showControls {
return showControls
}
// Show if hovering, interacting (scrubbing, adjusting volume), or paused
if isHovering || isInteracting {
return true
}
// Show when paused, loading, or failed
return playerState.playbackState == .paused ||
playerState.playbackState == .loading ||
playerState.playbackState == .ended ||
playerState.isFailed
}
// MARK: - Body
var body: some View {
GeometryReader { geometry in
ZStack {
// Invisible layer for tap/click to toggle controls
Color.clear
.contentShape(Rectangle())
.onTapGesture {
toggleControlsVisibility()
}
.allowsHitTesting(playerState.playbackState != .ended && !playerState.isFailed)
// Control bar at bottom center
VStack {
Spacer()
MacOSControlBar(
playerState: playerState,
onPlayPause: {
let wasPaused = playerState.playbackState == .paused
onPlayPause()
showControls = true
if wasPaused {
resetHideTimer()
}
},
onSeek: onSeek,
onSeekForward: onSeekForward,
onSeekBackward: onSeekBackward,
onToggleFullscreen: onToggleFullscreen,
isFullscreen: isFullscreen,
onTogglePiP: onTogglePiP,
onPlayNext: onPlayNext,
onVolumeChanged: onVolumeChanged,
onMuteToggled: onMuteToggled,
onShowSettings: onShowSettings,
sponsorSegments: playerState.sponsorSegments,
onInteractionStarted: {
isInteracting = true
cancelHideTimer()
},
onInteractionEnded: {
isInteracting = false
resetHideTimer()
}
)
.frame(width: 650)
}
.padding(.bottom, 20)
.opacity(shouldShowControls ? 1 : 0)
.allowsHitTesting(shouldShowControls)
}
.frame(width: geometry.size.width, height: geometry.size.height)
}
.animation(.easeInOut(duration: 0.2), value: shouldShowControls)
.onContinuousHover { phase in
switch phase {
case .active:
isHovering = true
resetHideTimer()
case .ended:
isHovering = false
startHideTimer()
}
}
.onChange(of: playerState.playbackState) { oldState, newState in
// When playback starts, start hide timer
if newState == .playing && shouldShowControls {
startHideTimer()
}
// Hide controls when video ends (ended overlay takes over)
if newState == .ended {
showControls = false
cancelHideTimer()
}
// Hide controls when video fails
if case .failed = newState {
showControls = false
cancelHideTimer()
}
}
.onAppear {
setupKeyboardMonitor()
}
.onDisappear {
removeKeyboardMonitor()
}
}
// MARK: - Keyboard Shortcuts
private func setupKeyboardMonitor() {
keyboardMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [self] event in
// Only handle events when the player window is key (active)
guard NSApp.keyWindow != nil else { return event }
// Handle keyboard shortcuts
switch event.keyCode {
case 49: // Space
onPlayPause()
return nil // Consume event
case 123: // Left arrow
Task { await onSeek(max(0, playerState.currentTime - 5)) }
return nil
case 124: // Right arrow
Task { await onSeek(min(playerState.duration, playerState.currentTime + 5)) }
return nil
case 126: // Up arrow
let newVolume = min(1.0, playerState.volume + 0.1)
playerState.volume = newVolume
onVolumeChanged?(newVolume)
return nil
case 125: // Down arrow
let newVolume = max(0, playerState.volume - 0.1)
playerState.volume = newVolume
onVolumeChanged?(newVolume)
return nil
case 46: // M key
onMuteToggled?()
return nil
case 3: // F key
onToggleFullscreen?()
return nil
case 53: // Escape
if isFullscreen {
onToggleFullscreen?()
return nil
}
return event // Pass through if not fullscreen
default:
return event // Pass through unhandled keys
}
}
}
private func removeKeyboardMonitor() {
if let monitor = keyboardMonitor {
NSEvent.removeMonitor(monitor)
keyboardMonitor = nil
}
}
// MARK: - Timer Management
private func toggleControlsVisibility() {
showControls = !shouldShowControls
if shouldShowControls {
startHideTimer()
}
}
private func startHideTimer() {
cancelHideTimer()
hideTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
Task { @MainActor in
if playerState.playbackState == .playing && !isInteracting && !isHovering {
showControls = false
}
}
}
}
private func resetHideTimer() {
startHideTimer()
}
private func cancelHideTimer() {
hideTimer?.invalidate()
hideTimer = nil
}
}
// MARK: - Preview
#Preview {
ZStack {
Color.black
MacOSPlayerControlsView(
playerState: PlayerState(),
onPlayPause: {},
onSeek: { _ in },
onSeekForward: { _ in },
onSeekBackward: { _ in }
)
}
.aspectRatio(16/9, contentMode: .fit)
.frame(width: 800, height: 450)
}
#endif