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