mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
1553 lines
63 KiB
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
|