mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
Add a Custom swatch to the appearance settings accent color grid, backed by a hex value stored in settings (synced via iCloud like the rest). iOS uses the native ColorPicker wheel; macOS uses a circular swatch that opens NSColorPanel, since the SwiftUI color well looks out of place among the circles. All accent consumers now read the resolved color through SettingsManager.resolvedAccentColor. The indigo preset is retired from the grid but kept in the enum, so users who selected it keep their color until they pick another one. Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
1019 lines
42 KiB
Swift
1019 lines
42 KiB
Swift
//
|
|
// ExpandedPlayerSheet.swift
|
|
// Yattee
|
|
//
|
|
// Full-screen player sheet with video, info, and controls.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
#if os(iOS) || os(macOS) || os(tvOS)
|
|
|
|
// MARK: - Expanded Player Sheet
|
|
|
|
struct ExpandedPlayerSheet: View {
|
|
@Environment(\.appEnvironment) var appEnvironment
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
// MARK: - Sheet State
|
|
|
|
@State var showingQualitySheet = false
|
|
@State var showingPlaylistSheet = false
|
|
@State var showingDownloadSheet = false
|
|
@State var showingDeleteDownloadAlert = false
|
|
@State var showingQueueSheet = false
|
|
@State var showingErrorSheet = false
|
|
@State var isCommentsExpanded: Bool = false
|
|
@State var commentsDismissOffset: CGFloat = 0
|
|
/// Direct scroll position control - use scrollTo(y:) to scroll to top
|
|
@State var scrollPosition: ScrollPosition = .init(y: 0)
|
|
@State private var showScrollButton = false
|
|
@State private var currentScrollOffset: CGFloat = 0
|
|
@State var currentPlayerHeight: CGFloat = 0
|
|
@State private var isBottomOverscroll: Bool = false
|
|
@State var isPortraitPanelVisible: Bool = true
|
|
/// Temporarily hide controls during fullscreen transition to avoid animation glitch
|
|
@State var hideControlsDuringTransition: Bool = false
|
|
|
|
// MARK: - Panel Drag State
|
|
|
|
/// Current drag offset when dismissing panel (positive = dragging down)
|
|
@State var panelDragOffset: CGFloat = 0
|
|
/// Whether panel is currently being dragged
|
|
@State var isPanelDragging: Bool = false
|
|
/// Current reveal offset when revealing hidden panel (negative = dragging up)
|
|
@State var panelRevealOffset: CGFloat = 0
|
|
/// Video Y offset for animating position (separate from computed position for smooth animation)
|
|
@State var videoYOffset: CGFloat = 0
|
|
/// Whether panel is expanded to cover the player (full-screen panel)
|
|
@State var isPanelExpanded: Bool = false
|
|
/// Current drag offset when expanding panel (negative = dragging up)
|
|
@State var panelExpandOffset: CGFloat = 0
|
|
/// Whether to use compact panel (no description) - animated state
|
|
@State var useCompactPanel: Bool = false
|
|
@State var showFormattedDate = false
|
|
@State var showOriginalTitle = false
|
|
/// Track if we're in widescreen layout for status bar hiding
|
|
@State private var isInWideScreenLayout = false
|
|
/// Active player controls layout from preset (loaded once, updated on notification)
|
|
@State var playerControlsLayout: PlayerControlsLayout = .default
|
|
|
|
// MARK: - Autoplay Countdown State
|
|
|
|
/// Current countdown value (seconds remaining)
|
|
@State var autoplayCountdown: Int = 0
|
|
/// Timer for countdown
|
|
@State var autoplayTimer: Timer?
|
|
/// Whether user cancelled the autoplay
|
|
@State var isAutoplayCancelled: Bool = false
|
|
/// Thumbnail URL to display - controlled to prevent old thumbnail flash during transitions
|
|
@State var displayedThumbnailURL: URL?
|
|
/// Loaded thumbnail image stored in @State to survive view re-renders without flashing
|
|
@State var displayedThumbnailImage: Image?
|
|
/// Whether we're in transition and should freeze the thumbnail
|
|
@State var isThumbnailFrozen: Bool = false
|
|
/// Whether we need to scroll to top when player height changes (during video transition)
|
|
@State private var pendingScrollToTopOnHeightChange: Bool = false
|
|
/// Scroll position for recommended videos carousel on ended screen
|
|
@State var recommendedScrollPosition: Int? = 0
|
|
/// Task for preloading comments - cancelled when video changes
|
|
@State var commentsPreloadTask: Task<Void, Never>?
|
|
|
|
/// Namespace for matchedGeometryEffect transitions
|
|
@Namespace var playerNamespace
|
|
|
|
// MARK: - Layout State (iOS)
|
|
|
|
#if os(iOS)
|
|
/// Track previous orientation to detect changes
|
|
@State private var previousIsLandscape: Bool?
|
|
#endif
|
|
|
|
#if os(iOS) || os(macOS)
|
|
/// Debug stats for overlay
|
|
@State var debugStats: MPVDebugStats = MPVDebugStats()
|
|
/// Timer for updating debug stats
|
|
@State var debugUpdateTimer: Timer?
|
|
#endif
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
var playerService: PlayerService? { appEnvironment?.playerService }
|
|
var playerState: PlayerState? { playerService?.state }
|
|
var downloadManager: DownloadManager? { appEnvironment?.downloadManager }
|
|
var navigationCoordinator: NavigationCoordinator? { appEnvironment?.navigationCoordinator }
|
|
var accentColor: Color { appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor }
|
|
|
|
#if os(iOS)
|
|
var inAppOrientationLock: Bool { appEnvironment?.settingsManager.inAppOrientationLock ?? false }
|
|
#endif
|
|
|
|
// MARK: - Comments Helpers
|
|
|
|
var comments: [Comment] { playerState?.comments ?? [] }
|
|
var commentsState: CommentsLoadState { playerState?.commentsState ?? .idle }
|
|
|
|
// MARK: - Queue Helpers
|
|
|
|
var queue: [QueuedVideo] { playerState?.queue ?? [] }
|
|
var history: [QueuedVideo] { playerState?.history ?? [] }
|
|
var isQueueEnabled: Bool { appEnvironment?.settingsManager.queueEnabled ?? true }
|
|
var hasQueueItems: Bool { !queue.isEmpty && isQueueEnabled }
|
|
|
|
// MARK: - Player Pill Helpers
|
|
|
|
var playerPillSettings: PlayerPillSettings {
|
|
playerControlsLayout.effectivePlayerPillSettings
|
|
}
|
|
var shouldShowPlayerPill: Bool {
|
|
playerPillSettings.visibility.isVisible(isWideLayout: true) &&
|
|
!playerPillSettings.buttons.isEmpty
|
|
}
|
|
|
|
// MARK: - Autoplay Helpers
|
|
|
|
var isAutoPlayEnabled: Bool {
|
|
(appEnvironment?.settingsManager.queueEnabled ?? true) &&
|
|
(appEnvironment?.settingsManager.queueAutoPlayNext ?? true)
|
|
}
|
|
|
|
var autoPlayCountdownDuration: Int {
|
|
appEnvironment?.settingsManager.queueAutoPlayCountdown ?? 5
|
|
}
|
|
|
|
var nextQueuedVideo: QueuedVideo? { queue.first }
|
|
|
|
// MARK: - Playback State Helpers
|
|
|
|
/// Returns current playback state info for rendering decisions
|
|
var playbackInfo: PlaybackInfo {
|
|
let state = playerState?.playbackState ?? .idle
|
|
let isLoading = state == .loading
|
|
let isIdle = state == .idle
|
|
let isEnded = state == .ended
|
|
let isFailed = if case .failed = state { true } else { false }
|
|
let hasBackend = playerService?.currentBackend != nil && !isLoading && !isIdle && !isEnded && !isFailed
|
|
return PlaybackInfo(
|
|
state: state,
|
|
isLoading: isLoading,
|
|
isIdle: isIdle,
|
|
isEnded: isEnded,
|
|
isFailed: isFailed,
|
|
hasBackend: hasBackend
|
|
)
|
|
}
|
|
|
|
// MARK: - Body
|
|
|
|
var body: some View {
|
|
playerContentWithOverlays
|
|
.applyPlayerSheets(
|
|
showingQualitySheet: $showingQualitySheet,
|
|
showingPlaylistSheet: $showingPlaylistSheet,
|
|
showingDownloadSheet: $showingDownloadSheet,
|
|
showingDeleteDownloadAlert: $showingDeleteDownloadAlert,
|
|
onStreamSelected: { stream, audioStream in
|
|
switchToStream(stream, audioStream: audioStream)
|
|
}
|
|
)
|
|
}
|
|
|
|
// MARK: - Player Content with Overlays
|
|
|
|
/// Main player content with overlays and event handlers - extracted to help compiler type-checking
|
|
@ViewBuilder
|
|
private var playerContentWithOverlays: some View {
|
|
playerContentWithCommentsOverlay
|
|
.modifier(PlayerEventHandlersModifier(
|
|
isCommentsExpanded: $isCommentsExpanded,
|
|
scrollPosition: $scrollPosition,
|
|
isPanelExpanded: $isPanelExpanded,
|
|
panelExpandOffset: $panelExpandOffset,
|
|
showOriginalTitle: $showOriginalTitle,
|
|
isThumbnailFrozen: $isThumbnailFrozen,
|
|
displayedThumbnailURL: $displayedThumbnailURL,
|
|
displayedThumbnailImage: $displayedThumbnailImage,
|
|
isAutoplayCancelled: $isAutoplayCancelled,
|
|
pendingScrollToTopOnHeightChange: $pendingScrollToTopOnHeightChange,
|
|
playerControlsLayout: $playerControlsLayout,
|
|
stopAutoplayCountdown: stopAutoplayCountdown,
|
|
startAutoplayCountdown: startAutoplayCountdown,
|
|
startPreloadingComments: startPreloadingComments,
|
|
cancelCommentsPreload: cancelCommentsPreload,
|
|
isAutoPlayEnabled: isAutoPlayEnabled,
|
|
nextQueuedVideo: nextQueuedVideo
|
|
))
|
|
#if os(iOS)
|
|
.modifier(PlayerIOSEventHandlersModifier(
|
|
inAppOrientationLock: inAppOrientationLock,
|
|
toggleFullscreen: toggleFullscreen,
|
|
startDebugUpdates: startDebugUpdates,
|
|
stopDebugUpdates: stopDebugUpdates,
|
|
setupRotationMonitoring: setupRotationMonitoring,
|
|
setupOrientationLockCallback: setupOrientationLockCallback
|
|
))
|
|
#endif
|
|
#if os(macOS)
|
|
.modifier(PlayerMacOSEventHandlersModifier(
|
|
startDebugUpdates: startDebugUpdates,
|
|
stopDebugUpdates: stopDebugUpdates
|
|
))
|
|
#endif
|
|
}
|
|
|
|
/// Player content with comments overlay - extracted to help compiler type-checking
|
|
@ViewBuilder
|
|
private var playerContentWithCommentsOverlay: some View {
|
|
playerContent
|
|
.accessibilityIdentifier("player.expandedSheet")
|
|
.accessibilityLabel("player.expandedSheet")
|
|
.playerToastOverlay()
|
|
.overlay(alignment: .bottom) {
|
|
expandedCommentsOverlay
|
|
}
|
|
.animation(.smooth(duration: 0.3), value: isCommentsExpanded)
|
|
}
|
|
|
|
// MARK: - Comments Overlay
|
|
|
|
/// Expanded comments overlay - extracted to help compiler type-checking
|
|
@ViewBuilder
|
|
private var expandedCommentsOverlay: some View {
|
|
GeometryReader { geometry in
|
|
let availableHeight = geometry.size.height - currentPlayerHeight + geometry.safeAreaInsets.bottom
|
|
let commentsMinHeight: CGFloat = 500
|
|
let shouldCoverFullSheet = availableHeight < commentsMinHeight
|
|
let commentsHeight = shouldCoverFullSheet
|
|
? geometry.size.height + geometry.safeAreaInsets.bottom
|
|
: availableHeight
|
|
let spacerHeight = shouldCoverFullSheet ? 0 : currentPlayerHeight
|
|
let isVisible = !isInWideScreenLayout && isCommentsExpanded
|
|
|
|
VStack(spacing: 0) {
|
|
Spacer()
|
|
.frame(height: spacerHeight)
|
|
|
|
ExpandedCommentsView(
|
|
videoID: playerState?.currentVideo?.id.videoID ?? "",
|
|
onClose: collapseComments,
|
|
onDismissOffsetChanged: handleCommentsDismissOffset,
|
|
onDismissGestureEnded: handleCommentsDismissGestureEnded,
|
|
dismissThreshold: 30
|
|
)
|
|
.frame(height: max(0, commentsHeight))
|
|
.clipped()
|
|
}
|
|
.offset(y: commentsDismissOffset)
|
|
.visualEffect { content, proxy in
|
|
content.offset(y: isVisible ? 0 : proxy.size.height)
|
|
}
|
|
.opacity(isVisible ? 1 : 0)
|
|
.allowsHitTesting(isVisible)
|
|
.background(
|
|
GeometryReader { commentsGeometry in
|
|
Color.clear
|
|
.onChange(of: commentsGeometry.frame(in: .global)) { _, newFrame in
|
|
if isCommentsExpanded {
|
|
navigationCoordinator?.commentsFrame = newFrame
|
|
}
|
|
}
|
|
.onChange(of: isCommentsExpanded) { _, expanded in
|
|
if expanded {
|
|
navigationCoordinator?.commentsFrame = commentsGeometry.frame(in: .global)
|
|
} else {
|
|
navigationCoordinator?.commentsFrame = .zero
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
.ignoresSafeArea(edges: .bottom)
|
|
}
|
|
|
|
// MARK: - Player Content
|
|
|
|
@ViewBuilder
|
|
private var playerContent: some View {
|
|
GeometryReader { geometry in
|
|
let wideScreen = isWideScreenLayout(size: geometry.size)
|
|
|
|
ZStack(alignment: .top) {
|
|
// Full-screen background - prevents content leaking during aspect ratio animation
|
|
// Use black for all modes since panel-based layout uses black backgrounds
|
|
Color.black.ignoresSafeArea(edges: .bottom)
|
|
|
|
#if os(iOS) || os(macOS)
|
|
if geometry.size.width <= 0 || geometry.size.height <= 0 {
|
|
// Transient zero-size layout pass (e.g. the deferred macOS
|
|
// player window before its first real layout). Don't mount
|
|
// either layout branch — a branch mounted here would grab
|
|
// the shared render view for one frame and immediately hand
|
|
// it off when the real size picks the other branch.
|
|
Color.black.ignoresSafeArea(.all)
|
|
} else if wideScreen {
|
|
if let video = playerState?.currentVideo {
|
|
// Widescreen layout with floating panel
|
|
// Must ignore safe area to get full screen geometry
|
|
wideScreenContent(video: video)
|
|
.ignoresSafeArea(.all)
|
|
} else {
|
|
// Widescreen loading state - just show black with spinner
|
|
Color.black
|
|
.ignoresSafeArea(.all)
|
|
.overlay {
|
|
ProgressView()
|
|
.tint(.white)
|
|
.controlSize(.large)
|
|
}
|
|
}
|
|
} else {
|
|
// Portrait/standard scrolling layout
|
|
standardPlayerContent(geometry: geometry)
|
|
}
|
|
#else
|
|
// tvOS uses standard layout only
|
|
standardPlayerContent(geometry: geometry)
|
|
#endif
|
|
}
|
|
.transaction { transaction in
|
|
// Disable animations during layout switch to prevent position interpolation
|
|
transaction.animation = nil
|
|
}
|
|
.onChange(of: wideScreen) { _, newValue in
|
|
isInWideScreenLayout = newValue
|
|
#if os(iOS)
|
|
// Detect orientation changes for panscan reset
|
|
if let previous = previousIsLandscape, previous != newValue {
|
|
// Landscape→portrait: panscan reset
|
|
if previous && !newValue {
|
|
navigationCoordinator?.pinchPanscan = 0
|
|
// Restore portrait panel visibility state
|
|
navigationCoordinator?.isPortraitPanelVisible = isPortraitPanelVisible
|
|
}
|
|
|
|
// Reset panel expansion state on orientation change
|
|
isPanelExpanded = false
|
|
panelExpandOffset = 0
|
|
|
|
// Reset panel state to orientation defaults
|
|
if newValue {
|
|
// Entering landscape: reset to hidden/unpinned
|
|
appEnvironment?.settingsManager.landscapeDetailsPanelVisible = false
|
|
appEnvironment?.settingsManager.landscapeDetailsPanelPinned = false
|
|
// Portrait panel doesn't exist in wide layout - clear flag so gesture handler allows pinch
|
|
navigationCoordinator?.isPortraitPanelVisible = false
|
|
}
|
|
// Note: Entering portrait preserves panel visibility state
|
|
// (if hidden before landscape, stays hidden after returning to portrait)
|
|
}
|
|
previousIsLandscape = newValue
|
|
#endif
|
|
}
|
|
.onAppear {
|
|
isInWideScreenLayout = wideScreen
|
|
#if os(iOS)
|
|
previousIsLandscape = wideScreen
|
|
// Reset panel state on fresh player open (prevents stale state from previous session)
|
|
appEnvironment?.settingsManager.landscapeDetailsPanelVisible = false
|
|
appEnvironment?.settingsManager.landscapeDetailsPanelPinned = false
|
|
if wideScreen {
|
|
navigationCoordinator?.isPortraitPanelVisible = false
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: commentsState == .loaded && !comments.isEmpty)
|
|
.sheet(isPresented: $showingQueueSheet) {
|
|
QueueManagementSheet()
|
|
}
|
|
.sheet(isPresented: $showingErrorSheet) {
|
|
ErrorDetailsSheet(errorMessage: playerState?.errorMessage ?? "Unknown error")
|
|
}
|
|
.sheet(item: Binding<Video?>(
|
|
get: { appEnvironment?.navigationCoordinator.descriptionLinkQueueSheetVideo },
|
|
set: { newValue in
|
|
if newValue == nil {
|
|
appEnvironment?.navigationCoordinator.descriptionLinkQueueSheetVideo = nil
|
|
}
|
|
}
|
|
)) { video in
|
|
if let appEnvironment {
|
|
QueueActionSheet(video: video)
|
|
.appEnvironment(appEnvironment)
|
|
}
|
|
}
|
|
// Host the resolved/ambiguous link confirmation dialogs while the
|
|
// expanded player is up so they appear above this sheet rather than
|
|
// being buried underneath it on the root app view.
|
|
.resolvedLinkPrompts(
|
|
shouldHost: (appEnvironment?.navigationCoordinator.isPlayerExpanded == true),
|
|
appEnvironment: appEnvironment
|
|
)
|
|
#if os(iOS)
|
|
.toolbar(.hidden, for: .navigationBar)
|
|
.playerStatusBarHidden(isInWideScreenLayout || !isPortraitPanelVisible)
|
|
.persistentSystemOverlays((isInWideScreenLayout || !isPortraitPanelVisible) ? .hidden : .automatic)
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Bottom Pills Overlay (iOS)
|
|
|
|
#if os(iOS)
|
|
/// Extracted overlay for player pill, comments pill and scroll button to help compiler type-checking
|
|
@ViewBuilder
|
|
private var bottomPillsOverlay: some View {
|
|
GeometryReader { geometry in
|
|
let hasCommentsPill = commentsState == .loaded && !comments.isEmpty
|
|
let isPlaying = playerState?.playbackState == .playing
|
|
// Disable play/pause when loading OR when thumbnail is still visible (buffer/frame not ready)
|
|
let isPlayPauseDisabled = playerState?.playbackState == .loading ||
|
|
playerState?.playbackState == .buffering ||
|
|
!(playerState?.isFirstFrameReady ?? false) ||
|
|
!(playerState?.isBufferReady ?? false)
|
|
let hasNext = playerState?.hasNext ?? false
|
|
// On narrow devices, use smaller side buttons so the player pill gets more horizontal space
|
|
let isCompactPillRow = geometry.size.width <= 390
|
|
let collapsedCommentWidth: CGFloat = isCompactPillRow ? 40 : 52
|
|
let scrollButtonWidth: CGFloat = isCompactPillRow ? 40 : 52
|
|
let pillRowSpacing: CGFloat = isCompactPillRow ? 8 : 12
|
|
|
|
VStack {
|
|
Spacer()
|
|
|
|
VStack(spacing: 12) {
|
|
// Comments pill - on its own row (only when player pill exists)
|
|
if hasCommentsPill, let firstComment = comments.first, shouldShowPlayerPill {
|
|
CommentsPillView(
|
|
comment: firstComment,
|
|
isCollapsed: false,
|
|
onTap: expandComments
|
|
)
|
|
.transition(.scale.combined(with: .opacity))
|
|
}
|
|
|
|
// Bottom row - centered group
|
|
bottomPillsRow(
|
|
hasCommentsPill: hasCommentsPill,
|
|
collapsedCommentWidth: collapsedCommentWidth,
|
|
pillRowSpacing: pillRowSpacing,
|
|
isCompact: isCompactPillRow,
|
|
isPlaying: isPlaying,
|
|
hasNext: hasNext,
|
|
isPlayPauseDisabled: isPlayPauseDisabled,
|
|
scrollButtonWidth: scrollButtonWidth,
|
|
availableWidth: geometry.size.width
|
|
)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 24)
|
|
.background {
|
|
// Block touches on description links behind pills area
|
|
// Prevents accidental link taps when trying to tap pills
|
|
Color.clear
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: hasCommentsPill)
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: shouldShowPlayerPill)
|
|
.animation(.easeInOut(duration: 0.2), value: showScrollButton)
|
|
}
|
|
.ignoresSafeArea(edges: .bottom)
|
|
}
|
|
|
|
/// Bottom row of pills (comments, player pill, scroll button) - extracted to help compiler
|
|
@ViewBuilder
|
|
private func bottomPillsRow(
|
|
hasCommentsPill: Bool,
|
|
collapsedCommentWidth: CGFloat,
|
|
pillRowSpacing: CGFloat,
|
|
isCompact: Bool,
|
|
isPlaying: Bool,
|
|
hasNext: Bool,
|
|
isPlayPauseDisabled: Bool,
|
|
scrollButtonWidth: CGFloat,
|
|
availableWidth: CGFloat
|
|
) -> some View {
|
|
// Calculate max pill width: available width minus placeholders and spacing
|
|
// Layout: [16 padding] [left] [spacing] [PILL] [spacing] [right] [16 padding]
|
|
let edgeElementsWidth: CGFloat = 32 + collapsedCommentWidth + scrollButtonWidth + (pillRowSpacing * 2)
|
|
let maxPillWidth = max(availableWidth - edgeElementsWidth, 150)
|
|
|
|
HStack(spacing: pillRowSpacing) {
|
|
// Left side: comments pill or placeholder
|
|
if hasCommentsPill, let firstComment = comments.first {
|
|
if !shouldShowPlayerPill {
|
|
// No player pill: expanded comments in bottom row
|
|
CommentsPillView(
|
|
comment: firstComment,
|
|
isCollapsed: false,
|
|
fillWidth: true,
|
|
onTap: expandComments
|
|
)
|
|
.frame(maxWidth: 400)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.transition(.scale.combined(with: .opacity))
|
|
} else {
|
|
// Placeholder (expanded comments on its own row)
|
|
Color.clear
|
|
.frame(width: collapsedCommentWidth, height: collapsedCommentWidth)
|
|
}
|
|
} else if shouldShowPlayerPill {
|
|
Color.clear
|
|
.frame(width: collapsedCommentWidth, height: collapsedCommentWidth)
|
|
}
|
|
|
|
// Player pill
|
|
if shouldShowPlayerPill {
|
|
PlayerPillView(
|
|
settings: playerPillSettings,
|
|
maxWidth: maxPillWidth,
|
|
isPlaying: isPlaying,
|
|
hasNext: hasNext,
|
|
queueCount: queue.count,
|
|
queueModeIcon: playerState?.queueMode.icon ?? "list.bullet",
|
|
isPlayPauseDisabled: isPlayPauseDisabled,
|
|
isOrientationLocked: inAppOrientationLock,
|
|
video: playerState?.currentVideo,
|
|
playbackRate: playerState?.rate ?? .x1,
|
|
showingPlaylistSheet: $showingPlaylistSheet,
|
|
onQueueTap: { showingQueueSheet = true },
|
|
onPrevious: {
|
|
Task { await playerService?.playPrevious() }
|
|
},
|
|
onPlayPause: {
|
|
playerService?.togglePlayPause()
|
|
},
|
|
onNext: {
|
|
playNextInQueue()
|
|
},
|
|
onSeek: { signedSeconds in
|
|
if signedSeconds >= 0 {
|
|
playerService?.seekForward(by: signedSeconds)
|
|
} else {
|
|
playerService?.seekBackward(by: -signedSeconds)
|
|
}
|
|
},
|
|
onClose: {
|
|
appEnvironment?.queueManager.clearQueue()
|
|
playerService?.stop()
|
|
navigationCoordinator?.isPlayerExpanded = false
|
|
},
|
|
onAirPlay: { /* AirPlay - handled by system */ },
|
|
onPiP: {
|
|
if let mpvBackend = playerService?.currentBackend as? MPVBackend {
|
|
mpvBackend.togglePiP()
|
|
}
|
|
},
|
|
onOrientationLock: {
|
|
appEnvironment?.settingsManager.inAppOrientationLock.toggle()
|
|
},
|
|
onRateChanged: { rate in
|
|
playerState?.rate = rate
|
|
playerService?.currentBackend?.rate = Float(rate.rawValue)
|
|
}
|
|
)
|
|
}
|
|
|
|
// Scroll button or placeholder
|
|
if showScrollButton {
|
|
Button {
|
|
scrollToTop()
|
|
} label: {
|
|
Image(systemName: "arrow.up")
|
|
.font(.system(size: isCompact ? 16 : 18, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
.frame(width: isCompact ? 28 : 32, height: isCompact ? 28 : 32)
|
|
.padding(isCompact ? 6 : 10)
|
|
.contentShape(Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.glassBackground(.regular, in: .circle, fallback: .thinMaterial)
|
|
.shadow(color: .black.opacity(0.1), radius: 8, y: 2)
|
|
.contentShape(Circle())
|
|
.transition(.scale.combined(with: .opacity))
|
|
} else if shouldShowPlayerPill {
|
|
Color.clear
|
|
.frame(width: scrollButtonWidth, height: scrollButtonWidth)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Player Sheet Modifiers
|
|
|
|
/// View extension for applying player sheets - extracted to help compiler type-checking
|
|
private extension View {
|
|
@ViewBuilder
|
|
func applyPlayerSheets(
|
|
showingQualitySheet: Binding<Bool>,
|
|
showingPlaylistSheet: Binding<Bool>,
|
|
showingDownloadSheet: Binding<Bool>,
|
|
showingDeleteDownloadAlert: Binding<Bool>,
|
|
onStreamSelected: @escaping (Stream, Stream?) -> Void
|
|
) -> some View {
|
|
modifier(PlayerSheetsModifier(
|
|
showingQualitySheet: showingQualitySheet,
|
|
showingPlaylistSheet: showingPlaylistSheet,
|
|
showingDownloadSheet: showingDownloadSheet,
|
|
showingDeleteDownloadAlert: showingDeleteDownloadAlert,
|
|
onStreamSelected: onStreamSelected
|
|
))
|
|
}
|
|
}
|
|
|
|
/// ViewModifier for player sheets - extracted to help compiler type-checking
|
|
private struct PlayerSheetsModifier: ViewModifier {
|
|
@Environment(\.appEnvironment) var appEnvironment
|
|
@Binding var showingQualitySheet: Bool
|
|
@Binding var showingPlaylistSheet: Bool
|
|
@Binding var showingDownloadSheet: Bool
|
|
@Binding var showingDeleteDownloadAlert: Bool
|
|
let onStreamSelected: (Stream, Stream?) -> Void
|
|
|
|
var playerService: PlayerService? { appEnvironment?.playerService }
|
|
var playerState: PlayerState? { playerService?.state }
|
|
var downloadManager: DownloadManager? { appEnvironment?.downloadManager }
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.sheet(isPresented: $showingQualitySheet) {
|
|
qualitySelectorSheet
|
|
}
|
|
.sheet(isPresented: $showingPlaylistSheet) {
|
|
if let video = playerState?.currentVideo {
|
|
PlaylistSelectorSheet(video: video)
|
|
}
|
|
}
|
|
#if !os(tvOS)
|
|
.sheet(isPresented: $showingDownloadSheet) {
|
|
downloadSheet
|
|
}
|
|
.alert(
|
|
String(localized: "player.deleteDownload.title"),
|
|
isPresented: $showingDeleteDownloadAlert
|
|
) {
|
|
Button(String(localized: "player.deleteDownload.cancel"), role: .cancel) {}
|
|
Button(String(localized: "player.deleteDownload.delete"), role: .destructive) {
|
|
if let video = playerState?.currentVideo,
|
|
let download = downloadManager?.download(for: video.id)
|
|
{
|
|
Task {
|
|
await downloadManager?.delete(download)
|
|
}
|
|
}
|
|
}
|
|
} message: {
|
|
Text(String(localized: "player.deleteDownload.message"))
|
|
}
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var qualitySelectorSheet: some View {
|
|
if let playerService {
|
|
let supportedFormats = playerService.currentBackendType.supportedFormats
|
|
QualitySelectorView(
|
|
streams: playerService.availableStreams.filter { stream in
|
|
let format = StreamFormat.detect(from: stream)
|
|
// DASH is never offered for manual selection
|
|
if format == .dash {
|
|
return false
|
|
}
|
|
return supportedFormats.contains(format)
|
|
},
|
|
captions: playerService.availableCaptions,
|
|
currentStream: playerState?.currentStream,
|
|
currentAudioStream: playerState?.currentAudioStream,
|
|
currentCaption: playerService.currentCaption,
|
|
isLoading: playerService.availableStreams.isEmpty && playerState?.playbackState == .loading,
|
|
currentDownload: playerService.currentDownload,
|
|
isLoadingOnlineStreams: playerService.isLoadingOnlineStreams,
|
|
localCaptionURL: playerService.currentDownload.flatMap { download in
|
|
guard let path = download.localCaptionPath else { return nil }
|
|
return appEnvironment?.downloadManager.downloadsDirectory().appendingPathComponent(path)
|
|
},
|
|
currentRate: playerState?.rate ?? .x1,
|
|
isControlsLocked: playerState?.isControlsLocked ?? false,
|
|
onStreamSelected: { stream, audioStream in
|
|
onStreamSelected(stream, audioStream)
|
|
},
|
|
onCaptionSelected: { caption in
|
|
playerService.loadCaption(caption)
|
|
},
|
|
onLoadOnlineStreams: {
|
|
Task {
|
|
await playerService.loadOnlineStreams()
|
|
}
|
|
},
|
|
onSwitchToOnlineStream: { stream, audioStream in
|
|
Task {
|
|
await playerService.switchToOnlineStream(stream, audioStream: audioStream)
|
|
}
|
|
},
|
|
onRateChanged: { rate in
|
|
playerState?.rate = rate
|
|
playerService.currentBackend?.rate = Float(rate.rawValue)
|
|
},
|
|
onLockToggled: { locked in
|
|
playerState?.isControlsLocked = locked
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
@ViewBuilder
|
|
private var downloadSheet: some View {
|
|
if let video = playerState?.currentVideo, let playerService {
|
|
DownloadQualitySheet(
|
|
video: video,
|
|
streams: playerService.availableStreams,
|
|
captions: playerService.availableCaptions,
|
|
dislikeCount: playerState?.dislikeCount
|
|
)
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Player Event Handlers Modifier
|
|
|
|
/// ViewModifier for common player event handlers - extracted to help compiler type-checking
|
|
private struct PlayerEventHandlersModifier: ViewModifier {
|
|
@Environment(\.appEnvironment) var appEnvironment
|
|
@Binding var isCommentsExpanded: Bool
|
|
@Binding var scrollPosition: ScrollPosition
|
|
@Binding var isPanelExpanded: Bool
|
|
@Binding var panelExpandOffset: CGFloat
|
|
@Binding var showOriginalTitle: Bool
|
|
@Binding var isThumbnailFrozen: Bool
|
|
@Binding var displayedThumbnailURL: URL?
|
|
@Binding var displayedThumbnailImage: Image?
|
|
@Binding var isAutoplayCancelled: Bool
|
|
@Binding var pendingScrollToTopOnHeightChange: Bool
|
|
@Binding var playerControlsLayout: PlayerControlsLayout
|
|
|
|
let stopAutoplayCountdown: () -> Void
|
|
let startAutoplayCountdown: () -> Void
|
|
let startPreloadingComments: () -> Void
|
|
let cancelCommentsPreload: () -> Void
|
|
let isAutoPlayEnabled: Bool
|
|
let nextQueuedVideo: QueuedVideo?
|
|
|
|
var playerState: PlayerState? { appEnvironment?.playerService.state }
|
|
var navigationCoordinator: NavigationCoordinator? { appEnvironment?.navigationCoordinator }
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.onChange(of: isCommentsExpanded) { _, newValue in
|
|
navigationCoordinator?.isCommentsExpanded = newValue
|
|
}
|
|
.onChange(of: playerState?.currentVideo?.id) { _, _ in
|
|
handleVideoChange()
|
|
}
|
|
.onChange(of: playerState?.playbackState) { oldState, newState in
|
|
handlePlaybackStateChange(oldState: oldState, newState: newState)
|
|
}
|
|
.onChange(of: playerState?.isBufferReady) { oldValue, newValue in
|
|
if newValue == true && oldValue != true {
|
|
startPreloadingComments()
|
|
// Unfreeze thumbnail now that player is ready
|
|
isThumbnailFrozen = false
|
|
}
|
|
}
|
|
.onChange(of: navigationCoordinator?.scrollPlayerIntoViewTrigger) { _, _ in
|
|
handleScrollIntoView()
|
|
}
|
|
.onAppear {
|
|
handleOnAppear()
|
|
}
|
|
.task {
|
|
await loadPlayerControlsLayout()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .playerControlsActivePresetDidChange)) { _ in
|
|
Task { await loadPlayerControlsLayout() }
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .playerControlsPresetsDidChange)) { _ in
|
|
Task { await loadPlayerControlsLayout() }
|
|
}
|
|
}
|
|
|
|
private func handleVideoChange() {
|
|
cancelCommentsPreload()
|
|
|
|
pendingScrollToTopOnHeightChange = true
|
|
scrollPosition.scrollTo(y: 0)
|
|
|
|
var transaction = Transaction()
|
|
transaction.disablesAnimations = true
|
|
withTransaction(transaction) {
|
|
playerState?.comments = []
|
|
playerState?.commentsState = .idle
|
|
playerState?.commentsContinuation = nil
|
|
isCommentsExpanded = false
|
|
navigationCoordinator?.commentsFrame = .zero
|
|
isPanelExpanded = false
|
|
panelExpandOffset = 0
|
|
}
|
|
showOriginalTitle = false
|
|
stopAutoplayCountdown()
|
|
isAutoplayCancelled = false
|
|
|
|
// Clear loaded image so new video gets fresh thumbnail
|
|
displayedThumbnailImage = nil
|
|
// Capture thumbnail URL immediately and freeze to prevent flash during details load
|
|
displayedThumbnailURL = playerState?.currentVideo?.bestThumbnail?.url
|
|
isThumbnailFrozen = true
|
|
}
|
|
|
|
private func handlePlaybackStateChange(oldState: PlaybackState?, newState: PlaybackState?) {
|
|
if newState == .ended {
|
|
if isAutoPlayEnabled && nextQueuedVideo != nil {
|
|
startAutoplayCountdown()
|
|
}
|
|
} else if oldState == .ended {
|
|
stopAutoplayCountdown()
|
|
isAutoplayCancelled = false
|
|
}
|
|
}
|
|
|
|
private func handleScrollIntoView() {
|
|
let animationDuration = 0.25
|
|
withAnimation(.easeOut(duration: animationDuration)) {
|
|
scrollPosition.scrollTo(y: 0)
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) { [weak navigationCoordinator] in
|
|
navigationCoordinator?.isPlayerScrollAnimating = false
|
|
}
|
|
}
|
|
|
|
private func handleOnAppear() {
|
|
if playerState?.isBufferReady == true && playerState?.commentsState == .idle {
|
|
startPreloadingComments()
|
|
}
|
|
|
|
if playerState?.playbackState == .ended,
|
|
isAutoPlayEnabled,
|
|
nextQueuedVideo != nil,
|
|
!isAutoplayCancelled {
|
|
startAutoplayCountdown()
|
|
}
|
|
}
|
|
|
|
private func loadPlayerControlsLayout() async {
|
|
if let service = appEnvironment?.playerControlsLayoutService {
|
|
let layout = await service.activeLayout()
|
|
playerControlsLayout = layout
|
|
GlobalLayoutSettings.cached = layout.globalSettings
|
|
MiniPlayerSettings.cached = layout.effectiveMiniPlayerSettings
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - iOS Event Handlers Modifier
|
|
|
|
#if os(iOS)
|
|
/// ViewModifier for iOS-specific player event handlers - extracted to help compiler type-checking
|
|
private struct PlayerIOSEventHandlersModifier: ViewModifier {
|
|
@Environment(\.appEnvironment) var appEnvironment
|
|
|
|
let inAppOrientationLock: Bool
|
|
let toggleFullscreen: () -> Void
|
|
let startDebugUpdates: () -> Void
|
|
let stopDebugUpdates: () -> Void
|
|
let setupRotationMonitoring: () -> Void
|
|
let setupOrientationLockCallback: () -> Void
|
|
|
|
var playerState: PlayerState? { appEnvironment?.playerService.state }
|
|
var navigationCoordinator: NavigationCoordinator? { appEnvironment?.navigationCoordinator }
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.onAppear {
|
|
setupRotationMonitoring()
|
|
setupOrientationLockCallback()
|
|
if inAppOrientationLock {
|
|
OrientationManager.shared.lock()
|
|
}
|
|
}
|
|
.onDisappear {
|
|
DeviceRotationManager.shared.stopMonitoring()
|
|
DeviceRotationManager.shared.isOrientationLocked = nil
|
|
OrientationManager.shared.unlock()
|
|
stopDebugUpdates()
|
|
}
|
|
.onChange(of: inAppOrientationLock) { _, isLocked in
|
|
if isLocked {
|
|
OrientationManager.shared.lock()
|
|
} else {
|
|
OrientationManager.shared.unlock()
|
|
}
|
|
}
|
|
.onChange(of: navigationCoordinator?.pendingFullscreenToggle) { _, _ in
|
|
toggleFullscreen()
|
|
}
|
|
.onChange(of: playerState?.showDebugOverlay) { _, isVisible in
|
|
if isVisible == true {
|
|
startDebugUpdates()
|
|
} else {
|
|
stopDebugUpdates()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// MARK: - macOS Event Handlers Modifier
|
|
|
|
#if os(macOS)
|
|
/// ViewModifier for macOS-specific player event handlers - extracted to help compiler type-checking
|
|
private struct PlayerMacOSEventHandlersModifier: ViewModifier {
|
|
@Environment(\.appEnvironment) var appEnvironment
|
|
|
|
let startDebugUpdates: () -> Void
|
|
let stopDebugUpdates: () -> Void
|
|
|
|
var playerState: PlayerState? { appEnvironment?.playerService.state }
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.onChange(of: playerState?.showDebugOverlay) { _, isVisible in
|
|
if isVisible == true {
|
|
startDebugUpdates()
|
|
} else {
|
|
stopDebugUpdates()
|
|
}
|
|
}
|
|
.onChange(of: playerState?.videoAspectRatio) { oldValue, newValue in
|
|
handleAspectRatioChange(oldValue: oldValue, newValue: newValue)
|
|
}
|
|
.onChange(of: playerState?.currentVideo?.id) { oldValue, newValue in
|
|
handleVideoChangeForResize(oldValue: oldValue, newValue: newValue)
|
|
}
|
|
.onAppear {
|
|
handleMacOSAppear()
|
|
}
|
|
}
|
|
|
|
private func handleAspectRatioChange(oldValue: Double?, newValue: Double?) {
|
|
guard let newValue, newValue > 0 else { return }
|
|
|
|
// Always keep the manual-resize aspect lock in sync, regardless of the
|
|
// auto-resize setting — the user always wants resize to be ratio-locked.
|
|
ExpandedPlayerWindowManager.shared.lockAspectRatio(newValue)
|
|
|
|
guard appEnvironment?.settingsManager.playerSheetAutoResize == true else { return }
|
|
|
|
let shouldAnimate = oldValue != nil
|
|
|
|
Task { @MainActor in
|
|
try? await Task.sleep(for: .milliseconds(100))
|
|
ExpandedPlayerWindowManager.shared.resizeToFitAspectRatio(
|
|
newValue,
|
|
animated: shouldAnimate
|
|
)
|
|
}
|
|
}
|
|
|
|
private func handleVideoChangeForResize(oldValue: Video.ID?, newValue: Video.ID?) {
|
|
guard oldValue != nil, newValue != nil else { return }
|
|
guard appEnvironment?.settingsManager.playerSheetAutoResize == true else { return }
|
|
|
|
Task { @MainActor in
|
|
try? await Task.sleep(for: .milliseconds(500))
|
|
if let aspectRatio = playerState?.videoAspectRatio, aspectRatio > 0 {
|
|
ExpandedPlayerWindowManager.shared.resizeToFitAspectRatio(
|
|
aspectRatio,
|
|
animated: true
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleMacOSAppear() {
|
|
guard let aspectRatio = playerState?.videoAspectRatio, aspectRatio > 0 else { return }
|
|
|
|
// Lock aspect ratio for manual resize unconditionally.
|
|
ExpandedPlayerWindowManager.shared.lockAspectRatio(aspectRatio)
|
|
|
|
if appEnvironment?.settingsManager.playerSheetAutoResize == true {
|
|
ExpandedPlayerWindowManager.shared.resizeToFitAspectRatio(aspectRatio, animated: false)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview("Player Sheet") {
|
|
Text("Tap to open")
|
|
.sheet(isPresented: .constant(true)) {
|
|
ExpandedPlayerSheet()
|
|
}
|
|
.appEnvironment(.preview)
|
|
}
|
|
|
|
#endif
|