mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
The mini player buttons set NavigationCoordinator flags, but only the iOS tab views hosted the corresponding sheets. Host QueueManagementSheet and PlaylistSelectorSheet on the macOS root view, bound directly to the coordinator flags. Also wire the add-to-playlist button in the full macOS player controls: add the missing .addToPlaylist case to MacOSControlsSectionRenderer and thread onShowPlaylistSelector from the expanded sheet layouts through MacOSPlayerControlsView to the existing playlist sheet host.
582 lines
21 KiB
Swift
582 lines
21 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
|
|
var onShowPlaylistSelector: (() -> Void)? = nil
|
|
/// Tapping the avatar / title / author in the top bar toggles the video details panel.
|
|
var onTitleTap: (() -> Void)? = nil
|
|
/// Whether the floating video details panel is currently visible. When it opens,
|
|
/// the controls hide immediately instead of waiting for the auto-hide timer.
|
|
var isDetailsPanelVisible: Bool = false
|
|
/// Change playback rate (used by the playback speed button).
|
|
var onRateChanged: ((PlaybackRate) -> 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?
|
|
|
|
/// Window hosting these controls; keyboard shortcuts only apply to events
|
|
/// destined for this window (not e.g. the Settings window).
|
|
@State private var hostWindow: NSWindow?
|
|
|
|
/// The active player controls layout (macOS preset). `nil` until loaded, so
|
|
/// the bars don't flash a wrong default before the preset arrives.
|
|
@State private var layout: PlayerControlsLayout?
|
|
|
|
/// Extra top inset for the top bar so its content drops below the window's
|
|
/// traffic-light buttons in the separate/floating window presentation, while
|
|
/// keeping the avatar/title aligned to the leading edge.
|
|
/// Stays 0 when there is no overlap (inline sheet, side panel, fullscreen).
|
|
@State private var trafficLightInset: CGFloat = 0
|
|
|
|
// 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
|
|
}
|
|
|
|
/// Yattee Server URL used by `ChannelAvatarView` for avatar fallback.
|
|
private var yatteeServerURL: URL? {
|
|
appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url
|
|
}
|
|
|
|
// MARK: - Top Bar
|
|
|
|
/// Top row rendered from the active preset's top section (title/author,
|
|
/// keep-on-top pin, close by default).
|
|
private func topBar(layout: PlayerControlsLayout) -> some View {
|
|
MacOSControlsSectionRenderer(
|
|
section: layout.topSection,
|
|
actions: controlsActions(layout: layout),
|
|
globalSettings: layout.globalSettings,
|
|
context: .overlay
|
|
)
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 16 + trafficLightInset)
|
|
.padding(.bottom, 24)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
LinearGradient(
|
|
colors: [.black.opacity(0.55), .clear],
|
|
startPoint: .top, endPoint: .bottom
|
|
)
|
|
.allowsHitTesting(false)
|
|
)
|
|
.background(
|
|
TrafficLightInsetReader(inset: $trafficLightInset)
|
|
.allowsHitTesting(false)
|
|
)
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
/// Consolidated actions for the section renderers.
|
|
private func controlsActions(layout: PlayerControlsLayout) -> PlayerControlsActions {
|
|
PlayerControlsActions(
|
|
playerState: playerState,
|
|
isWideScreenLayout: true,
|
|
isFullscreen: isFullscreen,
|
|
isWidescreenVideo: true,
|
|
isPanelVisible: isDetailsPanelVisible,
|
|
panelSide: .right,
|
|
showVolumeControls: layout.globalSettings.volumeMode == .mpv,
|
|
showDebugButton: true,
|
|
showCloseButton: onClose != nil,
|
|
currentVideo: playerState.currentVideo,
|
|
availableCaptions: [],
|
|
currentCaption: nil,
|
|
availableStreams: [],
|
|
currentStream: nil,
|
|
currentAudioStream: nil,
|
|
isAutoPlayNextEnabled: appEnvironment?.settingsManager.queueAutoPlayNext ?? true,
|
|
yatteeServerURL: yatteeServerURL,
|
|
deArrowBrandingProvider: appEnvironment?.deArrowBrandingProvider,
|
|
onClose: onClose,
|
|
onToggleDebug: { [self] in
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
playerState.showDebugOverlay.toggle()
|
|
}
|
|
},
|
|
onTogglePiP: onTogglePiP,
|
|
onToggleFullscreen: onToggleFullscreen,
|
|
onToggleDetailsVisibility: onTitleTap,
|
|
onToggleAutoPlayNext: { [weak appEnvironment] in
|
|
appEnvironment?.settingsManager.queueAutoPlayNext.toggle()
|
|
},
|
|
onShowSettings: onShowSettings,
|
|
onPlayNext: onPlayNext,
|
|
onPlayPrevious: onPlayPrevious,
|
|
onPlayPause: { [self] in
|
|
let wasPaused = playerState.playbackState == .paused
|
|
onPlayPause()
|
|
showControls = true
|
|
if wasPaused {
|
|
resetHideTimer()
|
|
}
|
|
},
|
|
onSeekForward: { seconds in await onSeekForward(seconds) },
|
|
onSeekBackward: { seconds in await onSeekBackward(seconds) },
|
|
onVolumeChanged: onVolumeChanged,
|
|
onMuteToggled: onMuteToggled,
|
|
onCancelHideTimer: { [self] in cancelHideTimer() },
|
|
onResetHideTimer: { [self] in resetHideTimer() },
|
|
onSliderAdjustmentChanged: { [self] adjusting in
|
|
isInteracting = adjusting
|
|
if adjusting {
|
|
cancelHideTimer()
|
|
} else {
|
|
resetHideTimer()
|
|
}
|
|
},
|
|
onRateChanged: onRateChanged,
|
|
onShowPlaylistSelector: onShowPlaylistSelector,
|
|
onShowQueue: onShowQueue,
|
|
onControlsLockToggled: { [self] locked in
|
|
playerState.isControlsLocked = locked
|
|
}
|
|
)
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Top bar (from preset top section) + control bar at bottom center
|
|
if let layout {
|
|
VStack(spacing: 0) {
|
|
topBar(layout: layout)
|
|
|
|
Spacer()
|
|
|
|
MacOSControlBar(
|
|
playerState: playerState,
|
|
section: layout.bottomSection,
|
|
globalSettings: layout.globalSettings,
|
|
actions: controlsActions(layout: layout),
|
|
onSeek: onSeek,
|
|
showChapters: layout.progressBarSettings.showChapters,
|
|
sponsorSegments: playerState.sponsorSegments,
|
|
sponsorBlockSettings: layout.progressBarSettings.sponsorBlockSettings,
|
|
playedColor: layout.progressBarSettings.playedColor.color,
|
|
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)
|
|
}
|
|
.background(
|
|
HostWindowReader(window: $hostWindow)
|
|
.allowsHitTesting(false)
|
|
)
|
|
.animation(.easeInOut(duration: 0.2), value: shouldShowControls)
|
|
.onContinuousHover { phase in
|
|
switch phase {
|
|
case .active:
|
|
isHovering = true
|
|
resetHideTimer()
|
|
case .ended:
|
|
isHovering = false
|
|
startHideTimer()
|
|
}
|
|
}
|
|
.onChange(of: isDetailsPanelVisible) { _, isVisible in
|
|
if isVisible {
|
|
// Hide controls immediately when the details panel opens.
|
|
showControls = false
|
|
cancelHideTimer()
|
|
} else {
|
|
// Restore default hover/paused-driven visibility when it closes.
|
|
showControls = nil
|
|
}
|
|
}
|
|
.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()
|
|
}
|
|
.task {
|
|
await loadLayout()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .playerControlsActivePresetDidChange)) { _ in
|
|
Task { await loadLayout() }
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .playerControlsPresetsDidChange)) { _ in
|
|
Task { await loadLayout() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Layout Loading
|
|
|
|
private func loadLayout() async {
|
|
guard let service = appEnvironment?.playerControlsLayoutService else { return }
|
|
layout = await service.activeLayout()
|
|
}
|
|
|
|
// MARK: - Keyboard Shortcuts
|
|
|
|
private func setupKeyboardMonitor() {
|
|
keyboardMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [self] event in
|
|
// Only handle keys destined for the window hosting the player;
|
|
// pass through events for other windows (e.g. Settings).
|
|
guard let window = event.window, window === hostWindow else { return event }
|
|
|
|
// Let text fields receive typing (the field editor is an NSText).
|
|
if window.firstResponder is NSText { return event }
|
|
|
|
// When controls are locked, ignore playback keys (space/arrows/mute) and
|
|
// only keep fullscreen (F) and exit-fullscreen (Esc) working.
|
|
if playerState.isControlsLocked {
|
|
switch event.keyCode {
|
|
case 3: // F
|
|
onToggleFullscreen?()
|
|
return nil
|
|
case 53: // Escape
|
|
if isFullscreen {
|
|
onToggleFullscreen?()
|
|
return nil
|
|
}
|
|
return event
|
|
default:
|
|
return event // Pass through space/arrows/M while locked
|
|
}
|
|
}
|
|
|
|
// Handle keyboard shortcuts
|
|
switch event.keyCode {
|
|
case 49: // Space
|
|
onPlayPause()
|
|
return nil // Consume event
|
|
|
|
case 123: // Left arrow
|
|
let seconds = TimeInterval(layout?.centerSettings.seekBackwardSeconds ?? 5)
|
|
Task { await onSeekBackward(seconds) }
|
|
return nil
|
|
|
|
case 124: // Right arrow
|
|
let seconds = TimeInterval(layout?.centerSettings.seekForwardSeconds ?? 5)
|
|
Task { await onSeekForward(seconds) }
|
|
return nil
|
|
|
|
case 126: // Up arrow
|
|
// In system volume mode MPV volume is pinned at 1.0; don't fight it.
|
|
guard layout?.globalSettings.volumeMode != .system else { return event }
|
|
let newVolume = min(1.0, playerState.volume + 0.1)
|
|
playerState.volume = newVolume
|
|
onVolumeChanged?(newVolume)
|
|
return nil
|
|
|
|
case 125: // Down arrow
|
|
guard layout?.globalSettings.volumeMode != .system else { return event }
|
|
let newVolume = max(0, playerState.volume - 0.1)
|
|
playerState.volume = newVolume
|
|
onVolumeChanged?(newVolume)
|
|
return nil
|
|
|
|
case 46: // M key
|
|
guard layout?.globalSettings.volumeMode != .system else { return event }
|
|
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: - Host Window Reader
|
|
|
|
/// Reports the `NSWindow` hosting this view so the keyboard monitor can ignore
|
|
/// events destined for other windows. Tracks re-parenting (mini bar <-> sheet
|
|
/// <-> floating window) via `viewDidMoveToWindow`.
|
|
private struct HostWindowReader: NSViewRepresentable {
|
|
@Binding var window: NSWindow?
|
|
|
|
func makeNSView(context: Context) -> ReaderView {
|
|
let view = ReaderView()
|
|
view.onWindowChange = { newWindow in
|
|
if window !== newWindow { window = newWindow }
|
|
}
|
|
return view
|
|
}
|
|
|
|
func updateNSView(_ nsView: ReaderView, context: Context) {
|
|
nsView.onWindowChange = { newWindow in
|
|
if window !== newWindow { window = newWindow }
|
|
}
|
|
}
|
|
|
|
final class ReaderView: NSView {
|
|
var onWindowChange: ((NSWindow?) -> Void)?
|
|
|
|
override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow()
|
|
let window = window
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.onWindowChange?(window)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Traffic Light Inset Reader
|
|
|
|
/// Reports how far the top bar's leading content must be pushed to clear the
|
|
/// hosting window's traffic-light buttons (close/minimize/zoom).
|
|
///
|
|
/// Returns 0 whenever the buttons don't actually overlap this view — e.g. in
|
|
/// fullscreen (buttons hidden), or when the controls render inside the inline
|
|
/// sheet / side panel where the top bar sits below the window's titlebar.
|
|
private struct TrafficLightInsetReader: NSViewRepresentable {
|
|
@Binding var inset: CGFloat
|
|
|
|
func makeNSView(context: Context) -> ReaderView {
|
|
let view = ReaderView()
|
|
view.onUpdate = { newInset in
|
|
if inset != newInset { inset = newInset }
|
|
}
|
|
return view
|
|
}
|
|
|
|
func updateNSView(_ nsView: ReaderView, context: Context) {
|
|
nsView.onUpdate = { newInset in
|
|
if inset != newInset { inset = newInset }
|
|
}
|
|
DispatchQueue.main.async { nsView.recompute() }
|
|
}
|
|
|
|
final class ReaderView: NSView {
|
|
var onUpdate: ((CGFloat) -> Void)?
|
|
private var observers: [NSObjectProtocol] = []
|
|
|
|
override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow()
|
|
removeObservers()
|
|
postsFrameChangedNotifications = true
|
|
|
|
guard let window else {
|
|
onUpdate?(0)
|
|
return
|
|
}
|
|
|
|
let center = NotificationCenter.default
|
|
let names: [NSNotification.Name] = [
|
|
NSWindow.didEnterFullScreenNotification,
|
|
NSWindow.didExitFullScreenNotification,
|
|
NSWindow.didResizeNotification,
|
|
NSWindow.didBecomeKeyNotification
|
|
]
|
|
for name in names {
|
|
observers.append(center.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in
|
|
self?.recompute()
|
|
})
|
|
}
|
|
observers.append(center.addObserver(forName: NSView.frameDidChangeNotification, object: self, queue: .main) { [weak self] _ in
|
|
self?.recompute()
|
|
})
|
|
|
|
recompute()
|
|
}
|
|
|
|
func recompute() {
|
|
guard let window,
|
|
!window.styleMask.contains(.fullScreen) else {
|
|
onUpdate?(0)
|
|
return
|
|
}
|
|
|
|
let buttons = [
|
|
window.standardWindowButton(.closeButton),
|
|
window.standardWindowButton(.miniaturizeButton),
|
|
window.standardWindowButton(.zoomButton)
|
|
].compactMap { $0 }.filter { !$0.isHidden && $0.superview != nil }
|
|
|
|
guard !buttons.isEmpty else {
|
|
onUpdate?(0)
|
|
return
|
|
}
|
|
|
|
// Union of the traffic lights and this view, both in window base coords.
|
|
let buttonsRect = buttons
|
|
.map { $0.convert($0.bounds, to: nil) }
|
|
.reduce(NSRect.null) { $0.union($1) }
|
|
let selfRect = convert(bounds, to: nil)
|
|
|
|
// Only inset when the buttons actually sit over this view.
|
|
guard selfRect.intersects(buttonsRect) else {
|
|
onUpdate?(0)
|
|
return
|
|
}
|
|
|
|
// Window base coords have a bottom-left origin (Y grows upward), so the
|
|
// buttons' bottom edge is `buttonsRect.minY` and this view's top edge is
|
|
// `selfRect.maxY`. Drop the content from its top edge down to just below
|
|
// the buttons, keeping it aligned to the leading edge.
|
|
let overlap = selfRect.maxY - buttonsRect.minY
|
|
onUpdate?(max(0, overlap + 8))
|
|
}
|
|
|
|
private func removeObservers() {
|
|
for observer in observers {
|
|
NotificationCenter.default.removeObserver(observer)
|
|
}
|
|
observers.removeAll()
|
|
}
|
|
|
|
deinit {
|
|
removeObservers()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|