From 1cd566ae292d5fba5a0daa1400a81289df60e549 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 21 Jun 2026 17:39:48 +0200 Subject: [PATCH] Allow dragging the macOS player controls bar to reposition it The glass controls capsule can now be dragged anywhere over the video. Position is stored as a fraction of the container size so it survives window resizes and aspect-ratio driven sheet resizes, persists across sessions via SettingsManager, and snaps back magnetically when dropped near the default bottom-center spot. Travel is clamped so the capsule stays inside the player and never overlaps the top button row. A mouseDownCanMoveWindow=false backing view keeps the window's movable-by-background behavior from swallowing the drag. --- Yattee/Core/Settings/SettingsKey.swift | 3 + .../Settings/SettingsManager+Player.swift | 28 ++++ Yattee/Core/SettingsManager.swift | 4 + .../macOS/MacOSPlayerControlsView.swift | 132 +++++++++++++++++- 4 files changed, 166 insertions(+), 1 deletion(-) diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index 8e44a01c..b9e2c140 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -47,6 +47,8 @@ enum SettingsKey: String, CaseIterable { // Platform-specific case macPlayerSeparateWindow case macPlayerFloating + case macControlsBarOffsetX // Normalized X offset of macOS control bar from default position + case macControlsBarOffsetY // Normalized Y offset of macOS control bar from default position case playerSheetAutoResize case listStyle @@ -140,6 +142,7 @@ enum SettingsKey: String, CaseIterable { switch self { case .preferredQuality, .cellularQuality, .allowSoftwareDecodedFormats, .macPlayerSeparateWindow, .macPlayerFloating, .listStyle, + .macControlsBarOffsetX, .macControlsBarOffsetY, // Home layout — different UI paradigms per platform .homeShortcutOrder, .homeShortcutVisibility, .homeShortcutLayout, .homeShortcutCardStyle, .homeShortcutCardColor, .homeShortcutColorfulPalette, .homeShortcutCustomPaletteColors, diff --git a/Yattee/Core/Settings/SettingsManager+Player.swift b/Yattee/Core/Settings/SettingsManager+Player.swift index fdcb09d6..b02bee44 100644 --- a/Yattee/Core/Settings/SettingsManager+Player.swift +++ b/Yattee/Core/Settings/SettingsManager+Player.swift @@ -95,6 +95,34 @@ extension SettingsManager { } } + /// Normalized X offset of the macOS floating control bar from its default + /// bottom-center position, stored as a fraction of the player container width. + /// 0 = default docked position. + var macControlsBarOffsetX: Double { + get { + if let cached = _macControlsBarOffsetX { return cached } + return double(for: .macControlsBarOffsetX) + } + set { + _macControlsBarOffsetX = newValue + set(newValue, for: .macControlsBarOffsetX) + } + } + + /// Normalized Y offset of the macOS floating control bar from its default + /// bottom-center position, stored as a fraction of the player container height. + /// 0 = default docked position. + var macControlsBarOffsetY: Double { + get { + if let cached = _macControlsBarOffsetY { return cached } + return double(for: .macControlsBarOffsetY) + } + set { + _macControlsBarOffsetY = newValue + set(newValue, for: .macControlsBarOffsetY) + } + } + /// Whether the player sheet automatically resizes to match video aspect ratio. /// When enabled, the sheet window will resize when video loads or changes. /// Default is true. diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index edc6f89a..14ac7a6c 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -74,6 +74,8 @@ final class SettingsManager { #if os(macOS) var _macPlayerSeparateWindow: Bool? var _macPlayerFloating: Bool? + var _macControlsBarOffsetX: Double? + var _macControlsBarOffsetY: Double? var _playerSheetAutoResize: Bool? #endif @@ -460,6 +462,8 @@ final class SettingsManager { #if os(macOS) _macPlayerSeparateWindow = nil _macPlayerFloating = nil + _macControlsBarOffsetX = nil + _macControlsBarOffsetY = nil _playerSheetAutoResize = nil #endif // miniPlayerShowVideo and miniPlayerVideoTapAction moved to preset diff --git a/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift b/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift index 5faebef0..97104d55 100644 --- a/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift +++ b/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift @@ -63,6 +63,28 @@ struct MacOSPlayerControlsView: View { /// Stays 0 when there is no overlap (inline sheet, side panel, fullscreen). @State private var trafficLightInset: CGFloat = 0 + // MARK: - Control Bar Drag State + + /// Committed offset of the control bar from its default bottom-center + /// position, as fractions of the container size so it survives window and + /// aspect-ratio driven resizes. Mirrors SettingsManager; (0, 0) = docked. + @State private var barOffsetFraction: CGSize = .zero + /// Live drag translation in points; nil when not dragging. + @State private var barDragTranslation: CGSize? + /// Pixel offset captured at drag start so the drag composes with the committed offset. + @State private var barDragBase: CGSize = .zero + /// Measured size of the visible glass capsule (narrower than its 650pt layout frame). + @State private var barSize = CGSize(width: 500, height: 90) + /// Measured height of the top bar (including its gradient padding) so the + /// dragged control bar can't overlap the top row of buttons. + @State private var topBarHeight: CGFloat = 0 + + private enum BarDrag { + static let edgeMargin: CGFloat = 12 + static let snapDistance: CGFloat = 28 + static let bottomPadding: CGFloat = 20 + } + // MARK: - Computed Properties /// Controls visibility - show when hovering, interacting, or paused @@ -200,6 +222,7 @@ struct MacOSPlayerControlsView: View { if let layout { VStack(spacing: 0) { topBar(layout: layout) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { topBarHeight = $0 } Spacer() @@ -222,8 +245,19 @@ struct MacOSPlayerControlsView: View { resetHideTimer() } ) + .onGeometryChange(for: CGSize.self) { $0.size } action: { barSize = $0 } + // The player window is movable by background; without this, + // AppKit claims drags on the glass capsule as window moves + // and the reposition gesture never fires. + .background(WindowDragBlockingView()) .frame(width: 650) - .padding(.bottom, 20) + .padding(.bottom, BarDrag.bottomPadding) + .offset(barOffset(in: geometry.size)) + .gesture(barDragGesture(in: geometry.size)) + .animation( + .spring(response: 0.3, dampingFraction: 0.75), + value: barOffset(in: geometry.size) == .zero + ) } .opacity(shouldShowControls ? 1 : 0) .allowsHitTesting(shouldShowControls) @@ -274,6 +308,12 @@ struct MacOSPlayerControlsView: View { } .onAppear { setupKeyboardMonitor() + if let settings = appEnvironment?.settingsManager { + barOffsetFraction = CGSize( + width: settings.macControlsBarOffsetX, + height: settings.macControlsBarOffsetY + ) + } } .onDisappear { removeKeyboardMonitor() @@ -289,6 +329,81 @@ struct MacOSPlayerControlsView: View { } } + // MARK: - Control Bar Drag + + /// Display offset of the control bar for the current container size: + /// live drag translation when dragging, otherwise the committed fraction, + /// clamped so the capsule stays visible, with magnetic snap to default. + private func barOffset(in container: CGSize) -> CGSize { + let raw: CGSize + if let drag = barDragTranslation { + raw = CGSize( + width: barDragBase.width + drag.width, + height: barDragBase.height + drag.height + ) + } else { + raw = CGSize( + width: barOffsetFraction.width * container.width, + height: barOffsetFraction.height * container.height + ) + } + let clamped = clampBarOffset(raw, in: container) + if hypot(clamped.width, clamped.height) < BarDrag.snapDistance { + return .zero + } + return clamped + } + + /// Clamps an offset so the visible capsule (measured `barSize`, not the + /// wider layout frame) keeps at least `edgeMargin` from every container edge + /// and never overlaps the top bar's button row. + private func clampBarOffset(_ offset: CGSize, in container: CGSize) -> CGSize { + let maxDX = max(0, (container.width - barSize.width) / 2 - BarDrag.edgeMargin) + let topInset = max(topBarHeight, BarDrag.edgeMargin) + let travelUp = max(0, container.height - BarDrag.bottomPadding - barSize.height - topInset) + let travelDown = max(0, BarDrag.bottomPadding - BarDrag.edgeMargin) + return CGSize( + width: min(max(offset.width, -maxDX), maxDX), + height: min(max(offset.height, -travelUp), travelDown) + ) + } + + private func barDragGesture(in container: CGSize) -> some Gesture { + DragGesture(minimumDistance: 4, coordinateSpace: .global) + .onChanged { value in + guard !playerState.isControlsLocked else { return } + if barDragTranslation == nil { + barDragBase = barOffset(in: container) + isInteracting = true + cancelHideTimer() + } + barDragTranslation = value.translation + } + .onEnded { value in + guard barDragTranslation != nil else { return } + let final = clampBarOffset( + CGSize( + width: barDragBase.width + value.translation.width, + height: barDragBase.height + value.translation.height + ), + in: container + ) + let docked = hypot(final.width, final.height) < BarDrag.snapDistance + let committed = docked ? .zero : final + barOffsetFraction = CGSize( + width: committed.width / max(1, container.width), + height: committed.height / max(1, container.height) + ) + barDragTranslation = nil + isInteracting = false + resetHideTimer() + if let settings = appEnvironment?.settingsManager { + settings.macControlsBarOffsetX = barOffsetFraction.width + settings.macControlsBarOffsetY = barOffsetFraction.height + } + } + } + // MARK: - Layout Loading private func loadLayout() async { @@ -429,6 +544,21 @@ struct MacOSPlayerControlsView: View { } } +// MARK: - Window Drag Blocking View + +/// Blocks `isMovableByWindowBackground` window-dragging within its bounds so +/// drags on the control bar reach the SwiftUI reposition gesture instead of +/// moving the player window. Doesn't handle any events itself — unhandled +/// mouse events continue up the responder chain to the hosting view. +private struct WindowDragBlockingView: NSViewRepresentable { + final class BlockingNSView: NSView { + override var mouseDownCanMoveWindow: Bool { false } + } + + func makeNSView(context: Context) -> BlockingNSView { BlockingNSView() } + func updateNSView(_ nsView: BlockingNSView, context: Context) {} +} + // MARK: - Host Window Reader /// Reports the `NSWindow` hosting this view so the keyboard monitor can ignore