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.
This commit is contained in:
Arkadiusz Fal
2026-06-21 17:39:48 +02:00
parent 76ae2717b2
commit 1cd566ae29
4 changed files with 166 additions and 1 deletions

View File

@@ -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,

View File

@@ -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.

View File

@@ -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

View File

@@ -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