mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
Yattee v2 rewrite
This commit is contained in:
259
Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift
Normal file
259
Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift
Normal file
@@ -0,0 +1,259 @@
|
||||
//
|
||||
// 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
|
||||
Reference in New Issue
Block a user