Files
yattee/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift
Arkadiusz Fal 2f2e436fe2 Split macOS player mode into window toggle + floating control
Replace the three-way macOS Player Mode picker (Separate Window /
Floating Window / Inline) with a single 'Play in a separate window'
toggle in Playback settings, and move the always-on-top choice to a
pin button in the player's top bar (window mode only).

Floating was really a transient window property (NSWindow.level), not
a peer of the window/inline structural choice — so it belongs on a live
control, not in Settings. The pin state persists across sessions.

- Replace MacPlayerMode enum with macPlayerSeparateWindow /
  macPlayerFloating bool settings
- Branch runtime presentation and window level off the two bools
- Add pin.fill/pin toggle to MacOSPlayerControlsView top bar
- Update localization; drop obsolete playerMode strings and enum tests
2026-05-30 12:42:18 +02:00

489 lines
17 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
/// 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
// 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?
/// 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 showing channel avatar, video title, author name, and a close button.
/// Mirrors the tvOS `topBar`, scaled down for macOS.
private var topBar: some View {
HStack(alignment: .center, spacing: 12) {
if let video = playerState.currentVideo {
Button {
onTitleTap?()
} label: {
HStack(alignment: .center, spacing: 12) {
ChannelAvatarView(
author: video.author,
size: 36,
yatteeServerURL: yatteeServerURL,
source: video.id.source
)
VStack(alignment: .leading, spacing: 2) {
Text(video.title)
.font(.headline)
.lineLimit(1)
.foregroundStyle(.white)
Text(video.author.name)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(onTitleTap == nil)
.help(Text("player.controls.info"))
}
Spacer(minLength: 12)
// Floating (always-on-top) toggle only meaningful for the separate
// window presentation, so hidden in the inline sheet and in fullscreen.
if let settings = appEnvironment?.settingsManager,
settings.macPlayerSeparateWindow, !isFullscreen {
Button {
settings.macPlayerFloating.toggle()
} label: {
Image(systemName: settings.macPlayerFloating ? "pin.fill" : "pin")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background(.ultraThinMaterial, in: Circle())
}
.buttonStyle(.plain)
.help(Text("player.controls.keepOnTop"))
.accessibilityLabel(Text("player.controls.keepOnTop"))
}
if onClose != nil {
Button {
onClose?()
} label: {
Image(systemName: "xmark")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background(.ultraThinMaterial, in: Circle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text("player.controls.close"))
}
}
.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: - 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 (title/author/avatar/close) + control bar at bottom center
VStack(spacing: 0) {
topBar
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: 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()
}
}
// 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: - 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