mirror of
https://github.com/yattee/yattee.git
synced 2026-07-21 23:02:04 +00:00
Add Queue (list.bullet), More (VideoContextMenuView ellipsis), and Play Previous buttons to the macOS control bar. Play Previous is shown always and disabled when the queue has no previous item. Remove the skip-back/forward 10s buttons and the fullscreen button from the bar (arrow-key seek and F-key fullscreen shortcuts are retained). Queue reuses the existing cross-platform queue sheet; More reuses the shared VideoContextMenuView (already !os(tvOS)), styled borderless to match the flat bar buttons.
264 lines
8.3 KiB
Swift
264 lines
8.3 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 onPlayPrevious: (() async -> Void)? = nil
|
|
var onVolumeChanged: ((Float) -> Void)? = nil
|
|
var onMuteToggled: (() -> Void)? = nil
|
|
var onShowSettings: (() -> Void)? = nil
|
|
var onShowQueue: (() -> 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,
|
|
onPlayPrevious: onPlayPrevious,
|
|
onVolumeChanged: onVolumeChanged,
|
|
onMuteToggled: onMuteToggled,
|
|
onShowSettings: onShowSettings,
|
|
onShowQueue: onShowQueue,
|
|
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
|