Files
yattee/Yattee/Views/Player/PlayerControlsView.swift
Arkadiusz Fal e50817c043 Add separate glass capsule for chapter title above seek preview
Extract chapter title from inside the storyboard preview into a
standalone ChapterCapsuleView with its own glass capsule background.
The capsule follows the seek position horizontally but independently
clamps to screen edges using alignmentGuide, allowing it to be wider
than the storyboard thumbnail without going offscreen.
2026-03-28 14:00:48 +01:00

1604 lines
65 KiB
Swift

//
// PlayerControlsView.swift
// Yattee
//
// Custom player controls overlay for iOS with play/pause, seek, and time display.
//
import SwiftUI
#if os(iOS)
// MARK: - Controls Theme Modifier
/// A view modifier that applies the controls theme color scheme.
private struct ControlsThemeModifier: ViewModifier {
let theme: ControlsTheme
@Environment(\.colorScheme) private var systemColorScheme
func body(content: Content) -> some View {
if let forcedScheme = theme.colorScheme {
content.environment(\.colorScheme, forcedScheme)
} else {
content.environment(\.colorScheme, systemColorScheme)
}
}
}
struct PlayerControlsView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Bindable var playerState: PlayerState
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 isWidescreenVideo: Bool = false
var onClose: (() -> Void)? = nil
var onTogglePiP: (() -> Void)? = nil
var onToggleDebug: (() -> Void)? = nil
var isWideScreenLayout: Bool = false
var onTogglePanel: (() -> Void)? = nil
var isPanelVisible: Bool = true
var panelSide: FloatingPanelSide = .right
var isPanelPinned: Bool = false
/// Safe area padding from layout (when panel is pinned)
var layoutLeadingSafeArea: CGFloat = 0
var layoutTrailingSafeArea: CGFloat = 0
var onToggleOrientationLock: (() -> Void)? = nil
var isOrientationLocked: Bool = false
var onToggleDetailsVisibility: (() -> Void)? = nil
var onShowSettings: (() -> Void)? = nil
/// Callback to play next video in queue
var onPlayNext: (() async -> Void)? = nil
/// Callback to play previous video in queue
var onPlayPrevious: (() async -> Void)? = nil
/// Callback to show queue management sheet
var onShowQueue: (() -> Void)? = nil
/// When true, forces controls to start hidden (user must tap to reveal)
var forceInitialHidden: Bool = false
/// Video area top offset from screen top (for positioning controls within video bounds)
var videoAreaTop: CGFloat = 0
/// Video area height (for positioning controls within video bounds, nil = use full geometry height)
var videoAreaHeight: CGFloat? = nil
/// Video fit height for slider sizing (stable during drag, unlike videoAreaHeight)
var videoFitHeight: CGFloat? = nil
/// Callback when volume changes (value 0.0-1.0)
var onVolumeChanged: ((Float) -> Void)? = nil
/// Callback when mute state toggles
var onMuteToggled: (() -> Void)? = nil
// MARK: - Video Context Properties (for share, playlist, captions, context menu)
/// Currently playing video (for share, playlist, context menu)
var currentVideo: Video? = nil
/// Available captions for captions button
var availableCaptions: [Caption] = []
/// Currently selected caption
var currentCaption: Caption? = nil
/// Available streams for quality selector (when showing captions)
var availableStreams: [Stream] = []
/// Current video stream
var currentStream: Stream? = nil
/// Current audio stream
var currentAudioStream: Stream? = nil
/// Callback when playback rate changes
var onRateChanged: ((PlaybackRate) -> Void)? = nil
/// Callback when caption is selected
var onCaptionSelected: ((Caption?) -> Void)? = nil
/// Callback when stream is selected (for quality selector integration)
var onStreamSelected: ((Stream, Stream?) -> Void)? = nil
/// Current panscan value (0.0 = fit, 1.0 = fill)
var panscanValue: Double = 0.0
/// Whether panscan change is currently allowed
var isPanscanAllowed: Bool = false
/// Callback to toggle panscan between 0 and 1
var onTogglePanscan: (() -> Void)? = nil
// MARK: - Sheet State
@State private var showingPlaylistSheet = false
@State private var showingCaptionsSheet = false
@State private var showingVideoTrackSheet = false
@State private var showingAudioTrackSheet = false
@State private var showingChaptersSheet = false
private var showPlayerAreaDebug: Bool {
appEnvironment?.settingsManager.showPlayerAreaDebug ?? false
}
private var isDismissGestureActive: Bool {
appEnvironment?.navigationCoordinator.isPlayerDismissGestureActive ?? false
}
@State private var isDragging = false
@State private var dragProgress: Double = 0
@State private var isPendingSeek = false
/// Target progress for pending seek - used to detect when playerState.progress converges
@State private var targetSeekProgress: Double = 0
@State private var hideTimer: Timer?
@State private var showControls: Bool?
@State private var isAdjustingVolume = false
@State private var isAdjustingBrightness = false
/// Tracks brightness value for side slider (needed because UIScreen.main.brightness isn't observable)
@State private var currentBrightness: Double = UIScreen.main.brightness
@State private var playNextTapCount = 0
/// Track safe area to force re-render when it changes
@State private var currentSafeArea: UIEdgeInsets = .zero
/// Active player controls layout from preset (passed from parent to avoid flashing during view recreation)
var activeLayout: PlayerControlsLayout = .default
/// Animation trigger for seek backward button
@State private var seekBackwardTrigger = false
/// Animation trigger for seek forward button
@State private var seekForwardTrigger = false
// MARK: - Gesture State
/// Gesture action handler for seek accumulation
private let gestureActionHandler = PlayerGestureActionHandler()
/// Current tap gesture feedback to display
@State private var currentTapFeedback: (action: TapGestureAction, position: TapZonePosition, accumulated: Int?)?
/// Pending seek to execute when feedback completes (direction, accumulated seconds)
@State private var pendingSeek: (isForward: Bool, seconds: Int)?
// MARK: - Seek Gesture State
/// Whether the seek gesture is currently active (shared with NavigationCoordinator for mutual exclusion with pinch gesture)
private var isSeekGestureActive: Bool {
get { appEnvironment?.navigationCoordinator.isSeekGestureActive ?? false }
nonmutating set { appEnvironment?.navigationCoordinator.isSeekGestureActive = newValue }
}
/// Preview time during seek gesture
@State private var seekGesturePreviewTime: TimeInterval = 0
/// Current time when seek gesture started (for relative seeking)
@State private var seekGestureStartTime: TimeInterval = 0
/// Screen width for seek calculation (captured from geometry)
@State private var screenWidth: CGFloat = 0
/// Whether boundary haptic was already triggered during current gesture
@State private var seekGestureBoundaryHapticTriggered = false
/// Controls visibility - only show when user interacts or explicitly pauses
private var shouldShowControls: Bool {
if let showControls {
return showControls
}
// If forced hidden, require explicit tap to show
if forceInitialHidden {
return false
}
// Initial state: show if paused, hide otherwise (including during loading/buffering)
return playerState.playbackState == .paused
}
var body: some View {
GeometryReader { geometry in
ZStack {
// Gesture overlay (active when controls are hidden)
gestureOverlayLayer(geometry: geometry)
// Tap to show/hide controls (fallback when gestures disabled)
if !gesturesEffectivelyEnabled {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
toggleControlsVisibility()
}
.allowsHitTesting(playerState.playbackState != .ended && !playerState.isFailed)
}
// Controls overlay
controlsOverlay
.frame(width: geometry.size.width, height: geometry.size.height)
.opacity(shouldShowControls ? 1 : 0)
.allowsHitTesting(shouldShowControls)
.modifier(ControlsThemeModifier(theme: activeLayout.globalSettings.theme))
// Gesture feedback overlays
gestureFeedbackLayer(geometry: geometry)
}
.frame(width: geometry.size.width, height: geometry.size.height)
}
.animation(.easeInOut(duration: 0.2), value: shouldShowControls)
.onChange(of: playerState.playbackState) { oldState, newState in
// When playback starts after being paused/loading, start hide timer if controls are showing
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 (failed overlay takes over)
if case .failed = newState {
showControls = false
cancelHideTimer()
}
}
.onChange(of: playerState.retryState.exhausted) { _, exhausted in
// Hide controls when retries are exhausted (retry exhausted overlay takes over)
if exhausted {
showControls = false
cancelHideTimer()
}
}
.onChange(of: forceInitialHidden) { _, hidden in
// Reset controls visibility when forced hidden (e.g., during fullscreen transition)
if hidden {
showControls = nil
cancelHideTimer()
}
}
.onChange(of: showControls) { _, newValue in
// Sync local controls visibility to playerState for other views to observe
playerState.controlsVisible = newValue ?? (playerState.playbackState == .paused)
}
.onAppear {
// Update safe area on appear to ensure correct layout
currentSafeArea = windowSafeArea
// Sync initial controls visibility to playerState
playerState.controlsVisible = shouldShowControls
}
.onGeometryChange(for: CGSize.self, of: { $0.size }) { _ in
// Update safe area when geometry changes (rotation)
currentSafeArea = windowSafeArea
}
.onChange(of: activeLayout.effectiveGesturesSettings.panscanGesture.snapToEnds, initial: true) { _, newValue in
// Sync panscan snap setting to navigation coordinator (initial: true ensures sync on first render)
appEnvironment?.navigationCoordinator.shouldSnapPanscan = newValue
}
.onReceive(NotificationCenter.default.publisher(for: UIScreen.brightnessDidChangeNotification)) { _ in
currentBrightness = UIScreen.main.brightness
}
.sheet(isPresented: $showingPlaylistSheet) {
if let video = currentVideo {
PlaylistSelectorSheet(video: video)
}
}
.sheet(isPresented: $showingCaptionsSheet) {
captionsSheet
}
.sheet(isPresented: $showingVideoTrackSheet) {
videoTrackSheet
}
.sheet(isPresented: $showingAudioTrackSheet) {
audioTrackSheet
}
.sheet(isPresented: $showingChaptersSheet) {
chaptersSheet
}
}
// MARK: - Track Selector Sheets
@ViewBuilder
private var captionsSheet: some View {
QualitySelectorView(
streams: [],
captions: availableCaptions,
currentStream: nil,
currentCaption: currentCaption,
initialTab: .subtitles,
showTabPicker: false,
onStreamSelected: { _, _ in },
onCaptionSelected: { caption in
onCaptionSelected?(caption)
}
)
}
@ViewBuilder
private var videoTrackSheet: some View {
QualitySelectorView(
streams: availableStreams,
captions: [],
currentStream: currentStream,
currentAudioStream: currentAudioStream,
initialTab: .video,
showTabPicker: false,
onStreamSelected: { stream, audioStream in
onStreamSelected?(stream, audioStream)
},
onCaptionSelected: { _ in }
)
}
@ViewBuilder
private var audioTrackSheet: some View {
QualitySelectorView(
streams: availableStreams,
captions: [],
currentStream: currentStream,
currentAudioStream: currentAudioStream,
initialTab: .audio,
showTabPicker: false,
onStreamSelected: { stream, audioStream in
onStreamSelected?(stream, audioStream)
},
onCaptionSelected: { _ in }
)
}
@ViewBuilder
private var chaptersSheet: some View {
ChaptersView(
chapters: playerState.chapters,
currentTime: playerState.currentTime,
storyboard: playerState.preferredStoryboard,
onChapterTap: { chapter in
await onSeek(chapter.startTime)
}
)
}
// MARK: - Controls Overlay
/// Whether running on iPad
private var isIPad: Bool {
UIDevice.current.userInterfaceIdiom == .pad
}
/// Get safe area from window (works even when view ignores safe area)
private var windowSafeArea: UIEdgeInsets {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first?
.windows
.first?
.safeAreaInsets ?? .zero
}
/// Whether to show in-app volume controls (only when volume mode is .mpv)
private var showVolumeControls: Bool {
GlobalLayoutSettings.cached.volumeMode == .mpv
}
/// Build the consolidated actions for the section renderer
private var controlsActions: PlayerControlsActions {
PlayerControlsActions(
playerState: playerState,
isWideScreenLayout: isWideScreenLayout,
isFullscreen: isFullscreen,
isWidescreenVideo: isWidescreenVideo,
isOrientationLocked: isOrientationLocked,
isPanelVisible: isPanelVisible,
isPanelPinned: isPanelPinned,
panelSide: panelSide,
isIPad: isIPad,
showVolumeControls: showVolumeControls,
showDebugButton: onToggleDebug != nil,
showCloseButton: onClose != nil,
currentVideo: currentVideo,
availableCaptions: availableCaptions,
currentCaption: currentCaption,
availableStreams: availableStreams,
currentStream: currentStream,
currentAudioStream: currentAudioStream,
panscanValue: panscanValue,
isPanscanAllowed: isPanscanAllowed,
isAutoPlayNextEnabled: appEnvironment?.settingsManager.queueAutoPlayNext ?? true,
yatteeServerURL: appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url,
deArrowBrandingProvider: appEnvironment?.deArrowBrandingProvider,
onClose: onClose,
onToggleDebug: onToggleDebug,
onTogglePiP: onTogglePiP,
onToggleFullscreen: onToggleFullscreen,
onToggleDetailsVisibility: onToggleDetailsVisibility,
onToggleOrientationLock: onToggleOrientationLock,
onTogglePanel: onTogglePanel,
onTogglePanscan: onTogglePanscan,
onToggleAutoPlayNext: {
appEnvironment?.settingsManager.queueAutoPlayNext.toggle()
},
onShowSettings: onShowSettings,
onPlayNext: onPlayNext,
onPlayPrevious: onPlayPrevious,
onPlayPause: onPlayPause,
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: { isAdjusting in
appEnvironment?.navigationCoordinator.isAdjustingPlayerSliders = isAdjusting
},
onRateChanged: onRateChanged,
onCaptionSelected: onCaptionSelected,
onShowPlaylistSelector: { [self] in showingPlaylistSheet = true },
onShowQueue: onShowQueue,
onShowCaptionsSelector: { [self] in showingCaptionsSheet = true },
onShowChaptersSelector: { [self] in showingChaptersSheet = true },
onShowVideoTrackSelector: { [self] in showingVideoTrackSheet = true },
onShowAudioTrackSelector: { [self] in showingAudioTrackSheet = true },
onControlsLockToggled: { locked in
playerState.isControlsLocked = locked
}
)
}
private var controlsOverlay: some View {
ZStack {
// Background fill - extends into safe areas (except during dismiss gesture)
if isDismissGestureActive {
Color.black.opacity(0.5)
.allowsHitTesting(false)
} else {
Color.black.opacity(0.5)
.ignoresSafeArea()
.allowsHitTesting(false)
}
// Controls layout - explicitly fill parent bounds
controlsVStack
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private var controlsVStack: some View {
// Use currentSafeArea state to ensure view updates when safe area changes
let safeArea = currentSafeArea
let basePadding: CGFloat = 12
// Vertical padding - always apply in widescreen (layout ignores safe area)
let topPadding: CGFloat = 2 + (isWideScreenLayout ? safeArea.top + 8 : 0)
// Bottom padding - add safe area when controls are at screen bottom in fullscreen modes
// When floating panel is visible (isPanelVisible && !isPanelPinned),
// bottomExtraPadding handles positioning, so no safe area needed
// In landscape (widescreen), cap the safe area since home indicator needs less clearance
let floatingPanelActive = isPanelVisible && !isPanelPinned
let needsBottomSafeArea = (isWideScreenLayout || isFullscreen) && !floatingPanelActive
let bottomSafeArea = isWideScreenLayout ? min(safeArea.bottom, 12) : safeArea.bottom
let bottomPadding: CGFloat = 2 + (needsBottomSafeArea ? bottomSafeArea : 0)
// Horizontal padding - depends on panel state
// When pinned, use layout-provided safe areas which account for panel and Dynamic Island
let leftPadding: CGFloat
let rightPadding: CGFloat
// When panel is pinned AND visible, the controls frame is already constrained by layout
// so we only need base padding (no additional safe area padding needed)
// When panel is hidden or not pinned, controls must handle safe areas themselves
let frameAlreadySafeAreaAdjusted = isWideScreenLayout && isPanelPinned && isPanelVisible
if isWideScreenLayout {
if frameAlreadySafeAreaAdjusted {
// Frame is already positioned/sized to avoid panel and Dynamic Island
// Just use base padding for aesthetics
leftPadding = basePadding
rightPadding = basePadding
} else {
// Panel not visible or not pinned - controls handle all safe areas (symmetric with max)
let safeAreaPadding = max(safeArea.left, safeArea.right) + 4
leftPadding = basePadding + safeAreaPadding
rightPadding = basePadding + safeAreaPadding
}
} else {
leftPadding = basePadding
rightPadding = basePadding
}
let safeAreaOffset = isWideScreenLayout ? (safeArea.top - safeArea.bottom) / 2 : 0
let centerSettings = activeLayout.centerSettings
let buttonBackground = activeLayout.globalSettings.buttonBackground
let theme = activeLayout.globalSettings.theme
return GeometryReader { geometry in
// Detect compact vertical mode for wide aspect ratio videos with limited height
let isCompactVertical = geometry.size.height < 200
// Video area bounds - use provided values or default to full geometry
let effectiveVideoTop = videoAreaTop
let effectiveVideoHeight = videoAreaHeight ?? geometry.size.height
let effectiveVideoBottom = effectiveVideoTop + effectiveVideoHeight
// Calculate center offset for video area (to position center controls within video bounds)
let videoCenterY = effectiveVideoTop + effectiveVideoHeight / 2
let screenCenterY = geometry.size.height / 2
let centerYOffset = videoCenterY - screenCenterY
// Only apply safeAreaOffset when no explicit video area bounds are provided
// (i.e., when videoAreaHeight is nil and we're using full geometry)
let hasExplicitVideoArea = videoAreaHeight != nil
let effectiveSafeAreaOffset = hasExplicitVideoArea ? 0 : safeAreaOffset
// Extra padding at bottom to account for space below video area
let bottomExtraPadding = geometry.size.height - effectiveVideoBottom
ZStack {
// Top bar - aligned to screen top (stays fixed regardless of video area)
VStack {
topBar
.padding(.top, topPadding)
.padding(.leading, leftPadding)
.padding(.trailing, rightPadding)
.applyCornerAdaptationOffset()
.overlay {
if showPlayerAreaDebug {
Rectangle().stroke(Color.green, lineWidth: 1)
}
}
Spacer()
}
// Calculate horizontal space for center controls scaling
let sliderWidth: CGFloat = 36
let leftSliderOccupies = centerSettings.leftSlider != .disabled ? sliderWidth + leftPadding : 0
let rightSliderOccupies = centerSettings.rightSlider != .disabled ? sliderWidth + rightPadding : 0
let availableForCenter = geometry.size.width - leftSliderOccupies - rightSliderOccupies - 24 // 24pt margin
let requiredWidth = centerControlsRequiredWidth(
compact: isCompactVertical,
hasBackground: buttonBackground.glassStyle != nil,
settings: centerSettings
)
let horizontalScale = requiredWidth > 0 ? min(1.0, max(0.6, availableForCenter / requiredWidth)) : 1.0
// Center controls - centered within video area
centerControls(compact: isCompactVertical, scale: horizontalScale)
.offset(y: centerYOffset - effectiveSafeAreaOffset)
// Vertical side sliders - constrained to video area
// Position using same offset as center controls for proper vertical alignment
// Rendered BEFORE bottom bar so seek preview appears above sliders
if !isCompactVertical {
// Calculate available height for sliders
// Use full video area height for sliders (extends into letterbox areas)
let sliderBasisHeight = min(effectiveVideoHeight, geometry.size.height)
let baseSliderAreaHeight = sliderBasisHeight - 48 - 100
// When videoFitHeight provided (panel/drag): use stable sizing within video
// When nil (fullscreen): target 250pt sliders when there's extra space
let isFullscreenWithSpace = videoFitHeight == nil && geometry.size.height > sliderBasisHeight * 1.5
let sliderAreaHeight: CGFloat = isFullscreenWithSpace
? min(250, geometry.size.height - 200)
: max(80, baseSliderAreaHeight)
HStack {
// Left slider
if centerSettings.leftSlider != .disabled {
verticalSideSlider(
type: centerSettings.leftSlider,
isLeading: true,
buttonBackground: buttonBackground,
theme: theme
)
.padding(.leading, leftPadding)
}
Spacer()
// Right slider
if centerSettings.rightSlider != .disabled {
verticalSideSlider(
type: centerSettings.rightSlider,
isLeading: false,
buttonBackground: buttonBackground,
theme: theme
)
.padding(.trailing, rightPadding)
}
}
.frame(maxWidth: .infinity)
.frame(height: sliderAreaHeight)
// Use same offset as center controls for proper vertical alignment
.offset(y: centerYOffset - effectiveSafeAreaOffset)
}
// Bottom bar - at screen bottom in fullscreen, at video area bottom when panel visible
// Rendered after side sliders so seek preview appears above them
VStack {
Spacer()
bottomBar
// Only apply bottomExtraPadding when floating panel is visible (to stay above panel)
// When panel is pinned (side panel), bottom bar stays at screen bottom
.padding(.bottom, bottomPadding + (isPanelVisible && !isPanelPinned ? bottomExtraPadding : 0))
.padding(.leading, leftPadding)
.padding(.trailing, rightPadding)
.overlay {
if showPlayerAreaDebug {
Rectangle().stroke(Color.blue, lineWidth: 1)
}
}
}
// DEBUG: Show internal padding values when setting enabled
// Position at bottom-left to avoid overlap with yellow layout debug
if showPlayerAreaDebug {
VStack(alignment: .leading, spacing: 2) {
Text(verbatim: "Controls (cyan):")
.fontWeight(.bold)
Text(verbatim: "winSA T:\(Int(safeArea.top)) B:\(Int(safeArea.bottom)) L:\(Int(safeArea.left)) R:\(Int(safeArea.right))")
Text(verbatim: "topPad: \(Int(topPadding)) btmPad: \(Int(bottomPadding)) wide: \(isWideScreenLayout ? "Y" : "N")")
Text(verbatim: "geom W:\(Int(geometry.size.width)) H:\(Int(geometry.size.height))")
Text(verbatim: "vidTop: \(Int(effectiveVideoTop)) vidH: \(Int(effectiveVideoHeight))")
Text(verbatim: "centerOff: \(Int(centerYOffset)) btmExtra: \(Int(bottomExtraPadding))")
Text(verbatim: "vis: \(isPanelVisible ? "Y" : "N") fitH: \(Int(videoFitHeight ?? -1)) effVH: \(Int(effectiveVideoHeight))")
}
.font(.system(size: 9, weight: .medium, design: .monospaced))
.foregroundStyle(.cyan)
.padding(4)
.background(.black.opacity(0.8))
.position(x: 120, y: geometry.size.height - 120)
}
}
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
// MARK: - Top Bar
private var topBar: some View {
ControlsSectionRenderer(
section: activeLayout.topSection,
actions: controlsActions,
globalSettings: activeLayout.globalSettings,
controlsVisible: shouldShowControls
)
}
// MARK: - Center Controls
private func centerControls(compact: Bool, scale: CGFloat = 1.0) -> some View {
let settings = activeLayout.centerSettings
let buttonBackground = activeLayout.globalSettings.buttonBackground
let theme = activeLayout.globalSettings.theme
let hasBackground = buttonBackground.glassStyle != nil
// Base sizes - scale down in compact mode to prevent overlapping with top/bottom bars
let baseSpacing: CGFloat
let baseSeekButtonSize: CGFloat
let basePlayButtonSize: CGFloat
let baseSeekFontSize: CGFloat
let basePlayFontSize: CGFloat
if compact {
baseSpacing = 16
baseSeekButtonSize = 36
basePlayButtonSize = 48
baseSeekFontSize = 20
basePlayFontSize = 32
} else {
// Normal sizes - slightly larger when backgrounds are enabled
baseSpacing = hasBackground ? 24 : 16
baseSeekButtonSize = hasBackground ? 64 : 56
basePlayButtonSize = hasBackground ? 82 : 72
baseSeekFontSize = 36
basePlayFontSize = 56
}
// Apply horizontal scale factor
let spacing = baseSpacing * scale
let seekButtonSize = baseSeekButtonSize * scale
let playButtonSize = basePlayButtonSize * scale
let seekFontSize = baseSeekFontSize * scale
let playFontSize = basePlayFontSize * scale
return HStack(spacing: spacing) {
// Skip backward - conditionally shown based on layout settings
if settings.showSeekBackward {
Button {
seekBackwardTrigger.toggle()
Task { await onSeekBackward(TimeInterval(settings.seekBackwardSeconds)) }
resetHideTimer()
} label: {
centerButtonContent(
systemImage: settings.seekBackwardSystemImage,
fontSize: seekFontSize,
frameSize: seekButtonSize,
buttonBackground: buttonBackground,
theme: theme
)
}
.symbolEffect(.rotate.byLayer, options: .speed(2).nonRepeating, value: seekBackwardTrigger)
.disabled(isTransportDisabled || playerState.isControlsLocked)
.opacity(playerState.isControlsLocked ? 0.5 : 1.0)
}
// Play/Pause - conditionally shown based on layout settings
if settings.showPlayPause {
if !isTransportDisabled && !playerState.isControlsLocked {
Button {
let wasPaused = playerState.playbackState == .paused
onPlayPause()
// Keep controls visible; start hide timer when resuming playback
showControls = true
if wasPaused {
resetHideTimer()
}
} label: {
centerButtonContent(
systemImage: playPauseIcon,
fontSize: playFontSize,
frameSize: playButtonSize,
buttonBackground: buttonBackground,
theme: theme
)
}
.accessibilityIdentifier("player.playPauseButton")
.accessibilityLabel("player.playPauseButton")
} else if playerState.isControlsLocked {
// Show dimmed play/pause when locked
centerButtonContent(
systemImage: playPauseIcon,
fontSize: playFontSize,
frameSize: playButtonSize,
buttonBackground: buttonBackground,
theme: theme
)
.opacity(0.5)
} else {
// Invisible spacer maintains layout stability when transport disabled
Color.clear
.frame(width: playButtonSize, height: playButtonSize)
.allowsHitTesting(false)
}
}
// Skip forward - conditionally shown based on layout settings
if settings.showSeekForward {
Button {
seekForwardTrigger.toggle()
Task { await onSeekForward(TimeInterval(settings.seekForwardSeconds)) }
resetHideTimer()
} label: {
centerButtonContent(
systemImage: settings.seekForwardSystemImage,
fontSize: seekFontSize,
frameSize: seekButtonSize,
buttonBackground: buttonBackground,
theme: theme
)
}
.symbolEffect(.rotate.byLayer, options: .speed(2).nonRepeating, value: seekForwardTrigger)
.disabled(isTransportDisabled || playerState.isControlsLocked)
.opacity(playerState.isControlsLocked ? 0.5 : 1.0)
}
}
}
/// Calculates the required width for center controls based on current settings.
private func centerControlsRequiredWidth(compact: Bool, hasBackground: Bool, settings: CenterSectionSettings) -> CGFloat {
let spacing: CGFloat = compact ? 16 : (hasBackground ? 24 : 16)
let seekSize: CGFloat = compact ? 36 : (hasBackground ? 64 : 56)
let playSize: CGFloat = compact ? 48 : (hasBackground ? 82 : 72)
var width: CGFloat = 0
var buttonCount = 0
if settings.showSeekBackward { width += seekSize; buttonCount += 1 }
if settings.showPlayPause { width += playSize; buttonCount += 1 }
if settings.showSeekForward { width += seekSize; buttonCount += 1 }
if buttonCount > 1 { width += CGFloat(buttonCount - 1) * spacing }
return width
}
/// Creates content for center control buttons with optional glass background.
@ViewBuilder
private func centerButtonContent(
systemImage: String,
fontSize: CGFloat,
frameSize: CGFloat,
buttonBackground: ButtonBackgroundStyle,
theme: ControlsTheme
) -> some View {
if let glassStyle = buttonBackground.glassStyle {
Image(systemName: systemImage)
.contentTransition(.symbolEffect(.replace, options: .speed(2)))
.font(.system(size: fontSize))
.foregroundStyle(.white)
.frame(width: frameSize, height: frameSize)
.glassBackground(glassStyle, in: .circle, fallback: .ultraThinMaterial, colorScheme: theme.colorScheme)
.contentShape(Circle())
} else {
Image(systemName: systemImage)
.contentTransition(.symbolEffect(.replace, options: .speed(2)))
.font(.system(size: fontSize))
.foregroundStyle(.white)
.frame(width: frameSize, height: frameSize)
.contentShape(Rectangle())
}
}
/// Whether transport controls should be disabled (during loading/buffering or buffer not ready)
private var isTransportDisabled: Bool {
playerState.playbackState == .loading ||
playerState.playbackState == .buffering ||
!playerState.isFirstFrameReady ||
!playerState.isBufferReady
}
private var playPauseIcon: String {
switch playerState.playbackState {
case .playing:
return "pause.fill"
default:
return "play.fill"
}
}
// MARK: - Bottom Bar
private var bottomBar: some View {
VStack(spacing: 4) {
// Progress bar (stays hardcoded - not customizable)
progressBar
// Dynamic button row from layout configuration
ControlsSectionRenderer(
section: activeLayout.bottomSection,
actions: controlsActions,
globalSettings: activeLayout.globalSettings,
controlsVisible: shouldShowControls
)
}
}
private var progressBar: some View {
GeometryReader { geometry in
ZStack(alignment: .bottomLeading) {
if playerState.isLive {
// For live streams, show simple red indicator (no scrubbing)
Rectangle()
.fill(.red.opacity(0.6))
.frame(height: 4)
} else {
// Regular VOD progress bar with chapter segments
SegmentedProgressBar(
chapters: activeLayout.progressBarSettings.showChapters ? playerState.chapters : [],
duration: playerState.duration,
currentTime: (isDragging || isPendingSeek) ? dragProgress * playerState.duration : playerState.currentTime,
bufferedTime: playerState.bufferedTime,
height: 4,
gapWidth: 2,
playedColor: activeLayout.progressBarSettings.playedColor.color,
bufferedColor: .white.opacity(0.5),
backgroundColor: .white.opacity(0.3),
sponsorSegments: playerState.sponsorSegments,
sponsorBlockSettings: activeLayout.progressBarSettings.sponsorBlockSettings
)
// Scrubber handle - fixed 20x20 frame to prevent layout shift
// Hidden when controls are locked to show only the progress bar
Circle()
.fill(activeLayout.progressBarSettings.playedColor.color)
.frame(width: 20, height: 20)
.scaleEffect(isDragging ? 1.0 : 0.75)
.offset(x: geometry.size.width * displayProgress - 10, y: 8)
.animation(.easeInOut(duration: 0.1), value: isDragging)
.opacity(playerState.isControlsLocked ? 0 : 1)
}
}
.frame(height: 20)
.contentShape(Rectangle())
// Only allow interaction for non-live streams and when controls are not locked
.allowsHitTesting(!playerState.isLive && !playerState.isControlsLocked)
.opacity(playerState.isControlsLocked ? 0.5 : 1.0)
// Seek preview as overlay - doesn't affect layout
.overlay {
if !playerState.isLive, let storyboard = playerState.preferredStoryboard {
seekPreviewOverlay(storyboard: storyboard, geometry: geometry)
} else if !playerState.isLive, isDragging {
seekTimePreviewOverlay(geometry: geometry)
}
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
guard !playerState.isLive, !playerState.isControlsLocked else { return }
isDragging = true
let progress = max(0, min(1, value.location.x / geometry.size.width))
dragProgress = progress
cancelHideTimer()
}
.onEnded { value in
guard !playerState.isLive, !playerState.isControlsLocked else { return }
let progress = max(0, min(1, value.location.x / geometry.size.width))
dragProgress = progress
targetSeekProgress = progress
isDragging = false
isPendingSeek = true
let seekTime = progress * playerState.duration
Task { await onSeek(seekTime) }
resetHideTimer()
}
)
.accessibilityIdentifier("player.progressBar")
.background {
GeometryReader { geo in
Color.clear
.onAppear {
appEnvironment?.navigationCoordinator.progressBarFrame = geo.frame(in: .global)
}
.onChange(of: geo.frame(in: .global)) { _, newFrame in
appEnvironment?.navigationCoordinator.progressBarFrame = newFrame
}
}
}
.onChange(of: playerState.progress) { _, newProgress in
// Clear isPendingSeek when playerState.progress converges to target (within 2%)
if isPendingSeek && abs(newProgress - targetSeekProgress) < 0.02 {
isPendingSeek = false
}
}
}
.frame(height: 20)
}
@ViewBuilder
private func seekPreviewOverlay(storyboard: Storyboard, geometry: GeometryProxy) -> some View {
if isDragging {
let seekTime = dragProgress * playerState.duration
// Use fixed display dimensions (160x90) for positioning, not storyboard resolution
let previewWidth: CGFloat = 160 + 16 // thumbnail width + padding
let previewHeight: CGFloat = 90
let halfWidth = previewWidth / 2
let xPosition = max(halfWidth, min(geometry.size.width - halfWidth, geometry.size.width * dragProgress))
let yPosition = -previewHeight / 2 - 12
SeekPreviewView(
storyboard: storyboard,
seekTime: seekTime,
storyboardService: StoryboardService.shared,
buttonBackground: activeLayout.globalSettings.buttonBackground,
theme: activeLayout.globalSettings.theme
)
.position(x: xPosition, y: yPosition)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.animation(.easeInOut(duration: 0.15), value: isDragging)
// Chapter capsule follows storyboard x but clamps to screen edges
if let chapter = chapterForSeekTime(seekTime) {
let capsuleY = yPosition - previewHeight / 2 - 6 - 12
ChapterCapsuleView(
title: chapter.title,
buttonBackground: activeLayout.globalSettings.buttonBackground
)
.positioned(xTarget: xPosition, availableWidth: geometry.size.width)
.position(x: geometry.size.width / 2, y: capsuleY)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.animation(.easeInOut(duration: 0.15), value: isDragging)
}
}
}
@ViewBuilder
private func seekTimePreviewOverlay(geometry: GeometryProxy) -> some View {
let seekTime = dragProgress * playerState.duration
let previewWidth: CGFloat = 80
let halfWidth = previewWidth / 2
let xPosition = max(halfWidth, min(geometry.size.width - halfWidth, geometry.size.width * dragProgress))
let yPosition: CGFloat = -20
SeekTimePreviewView(
seekTime: seekTime,
buttonBackground: activeLayout.globalSettings.buttonBackground,
theme: activeLayout.globalSettings.theme
)
.position(x: xPosition, y: yPosition)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.animation(.easeInOut(duration: 0.15), value: isDragging)
if let chapter = chapterForSeekTime(seekTime) {
let capsuleY = yPosition - 24
ChapterCapsuleView(
title: chapter.title,
buttonBackground: activeLayout.globalSettings.buttonBackground
)
.positioned(xTarget: xPosition, availableWidth: geometry.size.width)
.position(x: geometry.size.width / 2, y: capsuleY)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.animation(.easeInOut(duration: 0.15), value: isDragging)
}
}
private func chapterForSeekTime(_ seekTime: TimeInterval) -> VideoChapter? {
playerState.chapters.last { $0.startTime <= seekTime }
}
private var displayProgress: Double {
(isDragging || isPendingSeek) ? dragProgress : playerState.progress
}
private var bufferedProgress: Double {
guard playerState.duration > 0 else { return 0 }
return playerState.bufferedTime / playerState.duration
}
// 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 {
showControls = false
}
}
}
}
private func resetHideTimer() {
startHideTimer()
}
private func cancelHideTimer() {
hideTimer?.invalidate()
hideTimer = nil
}
// MARK: - Gesture Handling
/// Whether gestures are effectively enabled (master toggle + controls hidden).
private var gesturesEffectivelyEnabled: Bool {
let settings = activeLayout.effectiveGesturesSettings
return settings.hasActiveGestures
}
/// Whether gestures should currently respond (controls hidden, not in failed/ended state).
private var gesturesActive: Bool {
// Disable gestures when controls are locked
if playerState.isControlsLocked {
return false
}
return gesturesEffectivelyEnabled &&
!shouldShowControls &&
playerState.playbackState != .ended &&
!playerState.isFailed
}
@ViewBuilder
private func gestureOverlayLayer(geometry: GeometryProxy) -> some View {
let settings = activeLayout.effectiveGesturesSettings
// Only render gesture overlay during normal playback
// When video ends or fails, don't intercept taps - let ended/failed overlays handle them
if gesturesEffectivelyEnabled && playerState.playbackState != .ended && !playerState.isFailed {
PlayerGestureOverlay(
settings: settings,
isActive: gesturesActive,
isSeekable: !playerState.isLive,
onTapAction: { action, position in
handleTapGestureAction(action, position: position)
},
onSingleTap: {
toggleControlsVisibility()
},
onSeekGestureStarted: {
handleSeekGestureStarted(screenWidth: geometry.size.width)
},
onSeekGestureChanged: { horizontalDelta in
handleSeekGestureChanged(horizontalDelta: horizontalDelta)
},
onSeekGestureEnded: { horizontalDelta in
handleSeekGestureEnded(horizontalDelta: horizontalDelta)
},
isPinchGestureActive: { [weak appEnvironment] in
appEnvironment?.navigationCoordinator.isPinchGestureActive ?? false
},
isPanelDragging: { [weak appEnvironment] in
appEnvironment?.navigationCoordinator.isPanelDragging ?? false
}
)
}
}
@ViewBuilder
private func gestureFeedbackLayer(geometry: GeometryProxy) -> some View {
ZStack {
// Tap feedback
if let feedback = currentTapFeedback {
TapGestureFeedbackView(
action: feedback.action,
accumulatedSeconds: feedback.accumulated,
onComplete: {
currentTapFeedback = nil
executePendingSeek()
}
)
// Stable identity based on action type + position (not accumulated value)
// This prevents view recreation when accumulated seconds changes
.id("\(feedback.action.actionType.rawValue)-\(feedback.position.rawValue)")
}
// Seek gesture preview (top-aligned)
GeometryReader { gestureGeometry in
VStack {
GestureSeekPreviewView(
storyboard: playerState.preferredStoryboard,
currentTime: seekGestureStartTime,
seekTime: seekGesturePreviewTime,
duration: playerState.duration,
storyboardService: StoryboardService.shared,
buttonBackground: activeLayout.globalSettings.buttonBackground,
theme: activeLayout.globalSettings.theme,
chapters: playerState.chapters,
isActive: isSeekGestureActive,
availableWidth: gestureGeometry.size.width
)
.padding(.top, 16)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.safeAreaPadding(.top)
}
// Allow taps to pass through feedback visuals to gesture recognizer below
.allowsHitTesting(false)
}
private func handleTapGestureAction(_ action: TapGestureAction, position: TapZonePosition) {
Task {
// Update player state for clamping calculations
await gestureActionHandler.updatePlayerState(
currentTime: playerState.currentTime,
duration: playerState.duration
)
// Check if switching seek direction or performing non-seek action - cancel pending seek
switch action {
case .seekForward:
// Switching from backward to forward - cancel (option B)
if pendingSeek?.isForward == false {
pendingSeek = nil
currentTapFeedback = nil
await gestureActionHandler.cancelAccumulation()
}
case .seekBackward:
// Switching from forward to backward - cancel (option B)
if pendingSeek?.isForward == true {
pendingSeek = nil
currentTapFeedback = nil
await gestureActionHandler.cancelAccumulation()
}
default:
// Non-seek action cancels any pending seek
if pendingSeek != nil {
pendingSeek = nil
currentTapFeedback = nil
await gestureActionHandler.cancelAccumulation()
}
}
let result = await gestureActionHandler.handleTapAction(action, position: position)
// Show feedback
await MainActor.run {
currentTapFeedback = (action, position, result.accumulatedSeconds)
}
// Handle the action
switch action {
case .togglePlayPause:
// Execute immediately
onPlayPause()
case .seekForward(let seconds):
// Defer execution - update pending seek
let seekSeconds = result.accumulatedSeconds ?? seconds
await MainActor.run {
pendingSeek = (isForward: true, seconds: seekSeconds)
}
case .seekBackward(let seconds):
// Defer execution - update pending seek
let seekSeconds = result.accumulatedSeconds ?? seconds
await MainActor.run {
pendingSeek = (isForward: false, seconds: seekSeconds)
}
case .toggleFullscreen:
// Execute immediately
onToggleFullscreen?()
case .togglePiP:
// Execute immediately
onTogglePiP?()
case .playNext:
// Execute immediately
await onPlayNext?()
case .playPrevious:
// Not implemented yet - would need onPlayPrevious callback
break
case .cyclePlaybackSpeed:
// Execute immediately
let currentSpeed = playerState.rate.rawValue
let nextSpeed = await gestureActionHandler.nextPlaybackSpeed(currentSpeed: currentSpeed)
if let newRate = PlaybackRate(rawValue: nextSpeed) {
onRateChanged?(newRate)
}
case .toggleMute:
// Execute immediately
onMuteToggled?()
}
}
}
/// Executes the pending seek action when feedback completes.
private func executePendingSeek() {
guard let seek = pendingSeek else { return }
pendingSeek = nil
// Don't seek if accumulated time is 0 (already at boundary)
guard seek.seconds > 0 else { return }
Task {
if seek.isForward {
await onSeekForward(TimeInterval(seek.seconds))
} else {
await onSeekBackward(TimeInterval(seek.seconds))
}
// Reset accumulation in handler
await gestureActionHandler.cancelAccumulation()
}
}
// MARK: - Seek Gesture Handlers
/// Called when horizontal seek gesture is recognized (after activation threshold).
private func handleSeekGestureStarted(screenWidth: CGFloat) {
// Capture initial state
seekGestureStartTime = playerState.currentTime
seekGesturePreviewTime = playerState.currentTime
self.screenWidth = screenWidth
seekGestureBoundaryHapticTriggered = false
isSeekGestureActive = true
// Trigger activation haptic
appEnvironment?.settingsManager.triggerHapticFeedback(for: .seekGestureActivation)
}
/// Called during seek gesture with cumulative horizontal delta.
private func handleSeekGestureChanged(horizontalDelta: CGFloat) {
guard isSeekGestureActive, screenWidth > 0 else { return }
let settings = activeLayout.effectiveGesturesSettings.seekGesture
// Calculate raw delta for continuous preview (even if below minimum threshold)
let rawDelta = Double(horizontalDelta / screenWidth) * settings.sensitivity.baseSecondsPerScreenWidth *
SeekGestureCalculator.calculateDurationMultiplier(videoDuration: playerState.duration)
// Clamp to boundaries
let clampResult = SeekGestureCalculator.clampSeekTime(
currentTime: seekGestureStartTime,
seekDelta: rawDelta,
duration: playerState.duration
)
// Update preview time
seekGesturePreviewTime = clampResult.seekTime
// Trigger boundary haptic if needed (only once per boundary hit)
if clampResult.hitBoundary && !seekGestureBoundaryHapticTriggered {
appEnvironment?.settingsManager.triggerHapticFeedback(for: .seekGestureBoundary)
seekGestureBoundaryHapticTriggered = true
} else if !clampResult.hitBoundary {
// Reset if moved away from boundary
seekGestureBoundaryHapticTriggered = false
}
}
/// Called when seek gesture ends with final horizontal delta.
private func handleSeekGestureEnded(horizontalDelta: CGFloat) {
guard isSeekGestureActive else { return }
let settings = activeLayout.effectiveGesturesSettings.seekGesture
// Calculate final seek delta
let seekDelta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: horizontalDelta,
screenWidth: screenWidth,
videoDuration: playerState.duration,
sensitivity: settings.sensitivity
)
// Reset state
isSeekGestureActive = false
// Only seek if delta meets minimum threshold
guard let delta = seekDelta else { return }
// Clamp final seek time
let clampResult = SeekGestureCalculator.clampSeekTime(
currentTime: seekGestureStartTime,
seekDelta: delta,
duration: playerState.duration
)
// Execute the seek
Task {
await onSeek(clampResult.seekTime)
}
}
// MARK: - Vertical Side Sliders
/// Renders a vertical slider on the left or right edge of the player.
@ViewBuilder
private func verticalSideSlider(
type: SideSliderType,
isLeading: Bool,
buttonBackground: ButtonBackgroundStyle,
theme: ControlsTheme
) -> some View {
if type != .disabled {
VStack(spacing: 4) {
// Tappable icon at top with expanded tap area
Button {
handleSliderIconTap(type: type)
} label: {
sliderIcon(type: type)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(type == .volume && playerState.isMuted ? .red : .white)
.frame(width: 28, height: 28)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// Vertical slider
verticalSliderControl(type: type)
}
.padding(.top, 4)
.padding(.bottom, 8)
.padding(.horizontal, 4)
.frame(width: 36)
.modifier(SideSliderBackgroundModifier(buttonBackground: buttonBackground, theme: theme))
.opacity(playerState.isControlsLocked ? 0.5 : 1.0)
.allowsHitTesting(!playerState.isControlsLocked)
}
}
/// Returns the appropriate icon for the slider using variable value SF Symbols.
@ViewBuilder
private func sliderIcon(type: SideSliderType) -> some View {
switch type {
case .volume:
if playerState.isMuted {
Image(systemName: "speaker.slash.fill")
} else {
Image(systemName: "speaker.wave.3.fill", variableValue: Double(playerState.volume))
}
case .brightness:
Image(systemName: "sun.max.fill", variableValue: currentBrightness)
case .disabled:
EmptyView()
}
}
/// Handles tap on the slider icon.
private func handleSliderIconTap(type: SideSliderType) {
switch type {
case .volume:
// Toggle mute
onMuteToggled?()
resetHideTimer()
case .brightness:
// Cycle to next brightness preset
cycleBrightnessPreset()
resetHideTimer()
case .disabled:
break
}
}
/// Cycles brightness to the next preset value (0, 25, 50, 75, 100%).
/// Always goes to the next higher preset from current position, wrapping from 100 to 0.
private func cycleBrightnessPreset() {
let presets: [Double] = [0.0, 0.25, 0.5, 0.75, 1.0]
let current = UIScreen.main.brightness
// Find next higher preset (with small tolerance for floating point)
let newValue: Double
if let next = presets.first(where: { $0 > current + 0.01 }) {
newValue = next
} else {
// At or above 100%, wrap to 0
newValue = 0.0
}
UIScreen.main.brightness = newValue
currentBrightness = newValue
}
/// Creates the appropriate vertical slider control based on type.
@ViewBuilder
private func verticalSliderControl(type: SideSliderType) -> some View {
switch type {
case .volume:
VerticalSlider(
value: Binding(
get: { Double(playerState.volume) },
set: { newValue in
// Auto-unmute when dragging volume slider
if playerState.isMuted {
onMuteToggled?()
}
playerState.volume = Float(newValue)
onVolumeChanged?(Float(newValue))
}
),
onEditingChanged: { editing in
// Disable sheet dismiss gesture while adjusting slider
appEnvironment?.navigationCoordinator.isAdjustingPlayerSliders = editing
if editing {
cancelHideTimer()
} else {
resetHideTimer()
}
}
)
case .brightness:
VerticalSlider(
value: Binding(
get: { currentBrightness },
set: { newValue in
UIScreen.main.brightness = newValue
currentBrightness = newValue
}
),
onEditingChanged: { editing in
// Disable sheet dismiss gesture while adjusting slider
appEnvironment?.navigationCoordinator.isAdjustingPlayerSliders = editing
if editing {
cancelHideTimer()
} else {
resetHideTimer()
}
}
)
case .disabled:
EmptyView()
}
}
}
// MARK: - Vertical Slider Component
/// A vertical slider control for volume/brightness adjustment.
private struct VerticalSlider: View {
@Binding var value: Double
var range: ClosedRange<Double> = 0...1
var onEditingChanged: ((Bool) -> Void)?
@State private var isDragging = false
/// Local value tracked during drag for immediate visual feedback
@State private var dragValue: Double?
/// The display value - uses local drag value during drag, otherwise the binding
private var displayValue: Double {
dragValue ?? value
}
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .bottom) {
// Track background
Capsule()
.fill(.white.opacity(0.3))
// Fill
Capsule()
.fill(.white)
.frame(height: geometry.size.height * normalizedValue)
}
.frame(width: 4)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { gesture in
if !isDragging {
isDragging = true
dragValue = value
onEditingChanged?(true)
}
// Invert Y since 0 is at top
let newValue = 1.0 - (gesture.location.y / geometry.size.height)
let clampedValue = min(max(newValue, range.lowerBound), range.upperBound)
dragValue = clampedValue
value = clampedValue
}
.onEnded { _ in
isDragging = false
dragValue = nil
onEditingChanged?(false)
}
)
}
.frame(width: 32)
.frame(minHeight: 40, maxHeight: 180)
}
private var normalizedValue: CGFloat {
let rangeSize = range.upperBound - range.lowerBound
guard rangeSize > 0 else { return 0 }
return (displayValue - range.lowerBound) / rangeSize
}
}
// MARK: - Corner Adaptation Offset (iPadOS 26+ Stage Manager)
extension View {
/// Applies corner adaptation offset for iPadOS 26+ Stage Manager window controls.
/// This prevents the video info bar from colliding with traffic lights when the window is resized.
@ViewBuilder
func applyCornerAdaptationOffset() -> some View {
if #available(iOS 26, *) {
self.containerCornerOffset(.leading, sizeToFit: true)
} else {
self
}
}
}
// MARK: - Side Slider Background Modifier
/// Applies glass background to side sliders when enabled.
private struct SideSliderBackgroundModifier: ViewModifier {
let buttonBackground: ButtonBackgroundStyle
let theme: ControlsTheme
func body(content: Content) -> some View {
if let glassStyle = buttonBackground.glassStyle {
content
.glassBackground(
glassStyle,
in: .capsule,
fallback: .ultraThinMaterial,
colorScheme: theme.colorScheme
)
} else {
// No background when "None" is selected
content
}
}
}
// MARK: - Preview
#Preview {
ZStack {
Color.black
PlayerControlsView(
playerState: PlayerState(),
onPlayPause: {},
onSeek: { _ in },
onSeekForward: { _ in },
onSeekBackward: { _ in }
)
}
.aspectRatio(16/9, contentMode: .fit)
}
#Preview("Slider") {
@Previewable @State var value: Double = 50
ZStack {
Color.black
VerticalSlider(value: $value, range: 0...100)
.border(Color.red, width: 3)
}
}
#endif