Files
yattee/Yattee/Views/Player/PlayerControlsView.swift

1553 lines
63 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)
}
}
.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,
chapters: playerState.chapters
)
.position(x: xPosition, y: yPosition)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.animation(.easeInOut(duration: 0.15), value: isDragging)
}
}
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)
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
)
.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