Files
yattee/Yattee/Views/Player/CornerPlayerLayout.swift
Arkadiusz Fal fe109394df Split the corner layout on the hinge when partially open
On a half-open foldable the side column takes exactly the trailing half,
so the split lands on the hinge. Fully open, the column can now be
resized up to half the width. A re-presented player seeds the hinge
state from the last report so it doesn't animate into the split.
2026-10-09 23:19:39 +02:00

864 lines
47 KiB
Swift

//
// CornerPlayerLayout.swift
// Yattee
//
// Corner player layout for iPad/macOS: video in the top-left, the portrait-
// style info panel below it, and a resizable side column on the right with
// tabs for the remaining pages (related/chapters/queue/comments) —
// YouTube-style. The layout resolver in ExpandedPlayerSheet falls back to
// the stacked layout when the window is too narrow.
//
import SwiftUI
#if os(iOS) || os(macOS)
struct CornerPlayerLayout<PlayerContent: View>: View {
@Environment(\.appEnvironment) private var appEnvironment
let playerControlsLayout: PlayerControlsLayout
/// Video + controls area builder, sized by the layout.
@ViewBuilder let playerContent: (
_ onTogglePanel: @escaping () -> Void,
_ isPanelVisible: Bool,
_ width: CGFloat,
_ height: CGFloat
) -> PlayerContent
let onChannelTap: (() -> Void)?
var onFullscreen: (() -> Void)? = nil
/// Foldable hinge partially open (iPhone Duo, book posture): the side
/// column (grabber included) takes exactly the trailing half so the
/// split lands on the hinge. Not resizable then — only overdrag-hide.
var isHingeSplit = false
/// Seed the panel visibility BEFORE the first frame — applying the
/// preset's default visibility via onAppear/onChange animates the panels
/// popping in over an initially full-screen video on player expand
/// (same trap the portrait panel hit, fixed by init-time seeding).
init(
playerControlsLayout: PlayerControlsLayout,
initialPanelVisible: Bool = true,
initialColumnWidth: CGFloat? = nil,
@ViewBuilder playerContent: @escaping (
_ onTogglePanel: @escaping () -> Void,
_ isPanelVisible: Bool,
_ width: CGFloat,
_ height: CGFloat
) -> PlayerContent,
onChannelTap: (() -> Void)?,
onFullscreen: (() -> Void)? = nil,
isHingeSplit: Bool = false
) {
self.isHingeSplit = isHingeSplit
self.playerControlsLayout = playerControlsLayout
self.playerContent = playerContent
self.onChannelTap = onChannelTap
self.onFullscreen = onFullscreen
_isPanelVisible = State(initialValue: initialPanelVisible)
if let initialColumnWidth, initialColumnWidth > 0 {
_columnWidth = State(initialValue: initialColumnWidth)
}
}
// MARK: - Layout Constants
/// Side column width bounds.
static var minColumnWidth: CGFloat { 280 }
/// Resize grabber strip on the side column's leading edge — narrow,
/// matching the panel drag handle look.
static var grabberWidth: CGFloat { 12 }
static var maxColumnWidthCap: CGFloat { 560 }
/// Minimum width kept for the video column.
static var minVideoColumnWidth: CGFloat { 480 }
/// Maximum fraction of the window height the video area may take when a
/// below-video region needs space.
static var maxVideoHeightFraction: CGFloat { 0.72 }
// MARK: - State
@State private var isPanelVisible = true
@State private var columnWidth: CGFloat = SettingsManager.defaultCornerPanelColumnWidth
@State private var primaryPage: PanelPageType = .related
@State private var isGrabberActive = false
@State private var isBelowPanelVisible = true
@State private var belowPanelDragOffset: CGFloat = 0
@State private var isBelowPanelExpanded = false
@State private var belowPanelExpandOffset: CGFloat = 0
/// The below panel is layered under the video (the video grows over it
/// to hide) except while it expands over the video. Flipped only when
/// the panel sits at its home geometry — never mid-spring — so the
/// z-order swap is never visible.
@State private var isBelowPanelRaised = false
/// Swipe-up-on-video reveal in progress: the panel renders in the shown
/// layout, positioned by `belowPanelDragOffset` counting down from full
/// hide travel so it follows the finger.
@State private var isRevealDragActive = false
/// Side column collapsed by overdragging its resize grabber past the
/// minimum width — independent of `isPanelVisible`, like the below panel.
@State private var isSideColumnVisible = true
/// Overdrag travel of the side column toward the trailing edge (0 = home).
@State private var sideColumnDragOffset: CGFloat = 0
@State private var isSideColumnOverdragActive = false
/// Edge-swipe reveal in progress: the column renders in the shown layout,
/// positioned by `sideColumnDragOffset` counting down from full travel.
@State private var isSideColumnRevealDragActive = false
/// Bumped by the animated show/hide paths (toggle button, settings sync).
/// The visibility-driven `.animation` modifiers key on this instead of the
/// visibility flags themselves: drag hide/reveal flips those flags in a
/// disabled transaction, but `.animation(_:value:)` ignores that and would
/// still animate the frame height — an animation a quick follow-up swipe
/// then interrupts, leaving the video clipped to a shrunken box that
/// springs back (the "fast swipe" reveal glitch).
@State private var panelToggleGeneration = 0
/// Status bar height captured while the bar is visible. The layout
/// ignores the top safe area and pads by this fixed amount instead, so
/// hiding the bar in the fullscreen state (safe area → 0) doesn't
/// reflow the panels mid-transition; only the video grows into the
/// strip.
@State private var stableTopInset: CGFloat = 0
#if os(iOS)
@Environment(\.statusBarVisibilityController) private var statusBarController
#endif
/// Trailing screen-edge strip that starts the reveal swipe while the
/// column is collapsed; drags from anywhere else keep seeking.
static var sideColumnEdgeRevealInset: CGFloat { 20 }
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
/// Suppresses the leaf-level .animation modifiers while a panel drag
/// tracks the finger (the host sheet strips withAnimation transactions,
/// so these modifiers are what actually animate releases — like portrait).
private var isPanelDragActive: Bool { appEnvironment?.navigationCoordinator.isPanelDragging ?? false }
private var playerState: PlayerState? { appEnvironment?.playerService.state }
private var video: Video? { playerState?.currentVideo }
private var accentColor: Color { settingsManager?.resolvedAccentColor ?? .accentColor }
private var panelSettings: PanelSettings {
playerControlsLayout.effectivePanelSettings
}
/// Whether the info page is assigned below the video — the below panel
/// only exists to host it. (`effectiveRegion` already clamps to what
/// this platform can render; info is always enabled.)
private var hasBelowVideoInfo: Bool {
panelSettings.effectiveRegion(for: .info) == .belowVideo
}
/// Comments assigned below the video render as the portrait panel's
/// comments pill (iOS only, and only together with the info panel — the
/// pill lives inside it; `effectiveRegion` enforces both).
private var hasBelowVideoComments: Bool {
panelSettings.isPageEnabled(.comments)
&& panelSettings.effectiveRegion(for: .comments) == .belowVideo
}
/// Pages the below-video host renders; everything else goes to the side
/// column.
private var belowVideoPageTypes: Set<PanelPageType> {
var types: Set<PanelPageType> = []
if hasBelowVideoInfo { types.insert(.info) }
if hasBelowVideoComments { types.insert(.comments) }
return types
}
/// Column emptiness is decided at the config level: runtime-empty pages
/// (no chapters, no related yet) are hidden inside the panel content, not
/// by collapsing the column — layout jumps during loading would be jarring.
private var hasSideColumn: Bool {
panelSettings.enabledPages().contains { !belowVideoPageTypes.contains($0.pageType) }
}
/// The info page config drives the below-video panel's element settings.
private var infoPageSettings: InfoPageSettings {
panelSettings.pages.first { $0.pageType == .info }?.infoSettings ?? InfoPageSettings()
}
private var panelBackgroundColor: Color {
#if os(iOS)
Color(uiColor: .systemBackground)
#else
Color(nsColor: .windowBackgroundColor)
#endif
}
private func clampColumnWidth(_ width: CGFloat, availableWidth: CGFloat) -> CGFloat {
// Up to half the window, grabber included
let maxWidth = max(
Self.minColumnWidth,
min(Self.maxColumnWidthCap, availableWidth / 2 - Self.grabberWidth, availableWidth - Self.minVideoColumnWidth)
)
return min(max(width, Self.minColumnWidth), maxWidth)
}
// MARK: - Body
var body: some View {
GeometryReader { outer in
let liveTopInset = outer.safeAreaInsets.top
#if os(iOS)
// iPhone (Duo interior) never shows the status bar in this
// layout, so there is no strip to hold: pad by the live inset
// only. Remembering a past inset here would keep the 82pt top
// inset seen transiently while unfolding.
let holdsStatusBarStrip = UIDevice.current.userInterfaceIdiom == .pad
let knownTopInset = holdsStatusBarStrip ? (statusBarController?.knownStatusBarHeight ?? 0) : 0
#else
let holdsStatusBarStrip = true
let knownTopInset: CGFloat = 0
#endif
let topInset = liveTopInset > 0 || !holdsStatusBarStrip ? liveTopInset : max(stableTopInset, knownTopInset)
layout(topInset: topInset)
.padding(.top, topInset)
.ignoresSafeArea(edges: .top)
.onChange(of: liveTopInset, initial: true) { _, inset in
if inset > 0 { stableTopInset = inset }
}
}
.background(Color.black.ignoresSafeArea(.all))
}
@ViewBuilder
private func layout(topInset: CGFloat) -> some View {
GeometryReader { geometry in
let width = max(1, geometry.size.width)
let height = max(1, geometry.size.height)
let showsSideColumn = isPanelVisible && isSideColumnVisible && hasSideColumn
let isSideColumnCollapsed = isPanelVisible && !isSideColumnVisible && hasSideColumn
let showsBelowVideo = isPanelVisible && isBelowPanelVisible && hasBelowVideoInfo
// What the player actually sees: every region can be dragged
// away on its own while the panel stays nominally visible.
let showsAnyRegion = showsSideColumn || showsBelowVideo
// Grabber sits on the column's leading edge, inside the column
// background — narrow strip matching the panel drag handle look.
let grabberWidth = Self.grabberWidth
// Hinge split ignores (and never overwrites) the saved width.
let hingeColumnWidth = max(Self.minColumnWidth, width / 2 - grabberWidth)
let effectiveColumnWidth = isHingeSplit ? hingeColumnWidth : clampColumnWidth(columnWidth, availableWidth: width)
let totalColumnWidth = effectiveColumnWidth + grabberWidth
// Panels never move. Both sit at their home positions (column on
// the trailing edge, info panel under the shown video) and the
// video — layered above them — grows over them to hide and
// shrinks back to reveal. Every hide/reveal (button, grabber
// overdrag, panel handle, swipe-up/edge-swipe reveal) drives the
// video's trailing/bottom edge, never a panel offset.
let shownVideoWidth = hasSideColumn ? max(1, width - totalColumnWidth) : width
// Column hide travel: how far the video's trailing edge grows to
// cover the column. `sideColumnDragOffset` counts 0 → travel
// while hiding and travel → 0 while revealing. At full travel
// the dragged geometry matches the hidden geometry exactly, so
// the state flips after the animations cause no visible jump.
let sideColumnHideTravel = totalColumnWidth
// Column geometry ignores the panel toggle on purpose: the
// below panel's home (width + top) follows the column state —
// it widens over the column's lower part in lockstep with the
// video when the column is dragged away — but must stay put
// while the toggle grows the video over everything.
let hasSideColumnLayout = hasSideColumn && (isSideColumnVisible || isSideColumnRevealDragActive)
let sideColumnSlide = hasSideColumnLayout ? min(sideColumnHideTravel, max(0, sideColumnDragOffset)) : 0
let columnVideoWidth = hasSideColumnLayout ? min(width, shownVideoWidth + sideColumnSlide) : width
let videoWidth = isPanelVisible ? columnVideoWidth : width
let shownVideoHeight: CGFloat = {
let rawAspectRatio = playerState?.displayAspectRatio ?? (16.0 / 9.0)
let aspectRatio = rawAspectRatio > 0 ? rawAspectRatio : (16.0 / 9.0)
return max(1, min(columnVideoWidth / aspectRatio, height * Self.maxVideoHeightFraction))
}()
// Reveal drag renders the shown geometry so the video's bottom
// edge can follow the finger while `isBelowPanelVisible` is
// still false.
let showsPanelLayout = showsBelowVideo || (isPanelVisible && isRevealDragActive)
// Below-panel hide travel: the video's bottom edge grows from the
// shown height to the physical bottom edge (incl. bottom safe
// area + the player window's extra PiP inset). Same offset
// convention as the column.
let bottomInset = geometry.safeAreaInsets.bottom
let fullColumnHeight = height + bottomInset
let normalPanelHeight = max(1, fullColumnHeight - shownVideoHeight)
let panelHideTravel = normalPanelHeight
let belowPanelSlide = showsPanelLayout ? min(panelHideTravel, max(0, belowPanelDragOffset)) : 0
// With the below panel hidden the video (and its bottom controls)
// extends to the physical bottom edge, not the safe-area line.
let videoHeight = showsPanelLayout ? min(fullColumnHeight, shownVideoHeight + belowPanelSlide) : fullColumnHeight
// Fullscreen state: the status bar is hidden, so the video also
// grows up into the fixed status bar strip above the layout.
let videoTopExtension: CGFloat = isPanelVisible ? 0 : topInset
// One shared duration for the whole panel toggle — both video
// dimensions animate in lockstep so the video corner grows
// diagonally over the panels and everything finishes together.
// Scaled to the larger travel so the speed stays reasonable.
let panelExitSpeed: CGFloat = 1400
let panelToggleDuration = Double(min(max(max(totalColumnWidth, normalPanelHeight) / panelExitSpeed, 0.25), 0.45))
// Portrait-style expand: dragging up grows the panel over the
// video (expandOffset is negative while dragging up; when
// expanded, positive while dragging down to collapse). The
// panel's home top is the shown video height — the video's live
// height only matters for hide/reveal, which never overlap an
// expand.
let panelHeight: CGFloat = {
if isBelowPanelExpanded {
return max(normalPanelHeight, fullColumnHeight - belowPanelExpandOffset)
}
return min(fullColumnHeight, normalPanelHeight - belowPanelExpandOffset)
}()
let panelSpacerHeight: CGFloat = {
if isBelowPanelExpanded {
return min(shownVideoHeight, belowPanelExpandOffset)
}
return max(0, shownVideoHeight + belowPanelExpandOffset)
}()
ZStack(alignment: .topLeading) {
// Side column — pinned to the trailing edge, never moves.
// Stays in the hierarchy while hidden (covered by the video)
// so its scroll position and loaded content survive toggles.
if hasSideColumn, let video {
HStack(spacing: 0) {
PanelResizeGrabber(
panelWidth: isHingeSplit ? .constant(hingeColumnWidth) : $columnWidth,
minWidth: isHingeSplit ? hingeColumnWidth : Self.minColumnWidth,
maxWidth: isHingeSplit ? hingeColumnWidth : clampColumnWidth(.infinity, availableWidth: width),
panelSide: .right,
isPinned: true,
isDragActive: $isGrabberActive,
hitWidth: grabberWidth,
// A covered grabber must not block the window
// dismiss pan from starting over its strip
publishesFrame: showsSideColumn,
onOverdragChanged: { overshoot in
guard overshoot > 0 || isSideColumnOverdragActive else { return }
if appEnvironment?.navigationCoordinator.isPanelDragging != true {
appEnvironment?.navigationCoordinator.isPanelDragging = true
}
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
isSideColumnOverdragActive = true
sideColumnDragOffset = min(sideColumnHideTravel, overshoot)
}
},
onOverdragEnded: { overshoot, predicted in
guard isSideColumnOverdragActive else { return }
isSideColumnOverdragActive = false
appEnvironment?.navigationCoordinator.isPanelDragging = false
if overshoot > 80 || predicted > 160 {
// Grow the video fully over the column,
// then flip the collapsed state without
// animation — like the below panel's
// hide. `.removed`, not
// `.logicallyComplete`: flipping while
// the spring is still registered on the
// video frame leaves a stale animation
// that a quick follow-up reveal swipe
// interrupts, and the release spring
// then starts from a bogus shrunken
// rect (video clipped, growing back).
withAnimation(.spring(response: 0.3, dampingFraction: 1.0), completionCriteria: .removed) {
sideColumnDragOffset = sideColumnHideTravel
} completion: {
// The grabber may have been picked up again
guard isSideColumnVisible, !isSideColumnOverdragActive,
sideColumnDragOffset == sideColumnHideTravel else { return }
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
isSideColumnVisible = false
sideColumnDragOffset = 0
}
}
} else {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
sideColumnDragOffset = 0
}
}
}
)
sideColumnPanel(video: video)
.frame(width: effectiveColumnWidth)
}
// Bottom only — the background must not run under
// the system status bar at the top
.background(panelBackgroundColor.ignoresSafeArea(edges: .bottom))
.frame(width: totalColumnWidth)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
// Toggle only: fade with the video growing over it (and
// back in as it shrinks). Drag hides keep the panel solid
// — the finger drives the edge, the panel is uncovered
.opacity(isPanelVisible ? 1 : 0)
.animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: panelToggleGeneration)
.allowsHitTesting(showsSideColumn)
.accessibilityHidden(!showsSideColumn)
}
// Below-video panel — home is directly under the shown video.
// Its width follows the column geometry only (it covers the
// column's lower part when the column is dragged away, in
// lockstep with the video covering the upper part) and stays
// put during the panel toggle. Its own handle drag slides it
// off the bottom with the video growing in lockstep (the
// edges coincide) — a drag on the panel should move the
// panel. Raised above the video only while expanded over it;
// otherwise the video covers it.
if hasBelowVideoInfo, let video {
VStack(spacing: 0) {
Spacer(minLength: 0)
.frame(height: panelSpacerHeight)
belowVideoInfoPanel(video: video, hideTravel: panelHideTravel, publishesFrame: showsBelowVideo)
.frame(width: columnVideoWidth)
.frame(height: panelHeight)
.offset(y: belowPanelSlide)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// Extend into the bottom safe area (incl. the player
// window's extra PiP inset) so the panel and its pills
// reach the true bottom edge
.ignoresSafeArea(edges: .bottom)
.animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: belowPanelDragOffset)
.animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 0.8), value: isBelowPanelExpanded)
.animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 0.8), value: belowPanelExpandOffset)
.animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: playerState?.displayAspectRatio)
// Toggle only: fade with the video (see the column)
.opacity(isPanelVisible ? 1 : 0)
.animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: panelToggleGeneration)
.animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: sideColumnDragOffset)
.zIndex(isBelowPanelRaised ? 2 : 0)
.allowsHitTesting(showsBelowVideo)
.accessibilityHidden(!showsBelowVideo)
}
// Video — layered above the panels; its frame is the only
// thing that moves during hide/reveal.
// Sized from the live (animated) geometry, not the target
// values — during panel toggles the video then resizes
// smoothly through every animation frame instead of snapping
// to the final size.
GeometryReader { videoProxy in
playerContent(
togglePanel,
showsAnyRegion,
max(1, videoProxy.size.width),
max(1, videoProxy.size.height)
)
}
.frame(width: videoWidth, height: videoHeight + videoTopExtension)
// Clip in the frame's own coordinates, BEFORE the offset —
// clipping after it uses the unshifted layout frame and cuts
// the top strip (status bar height) off the fullscreen video
// and its top controls.
.clipped()
.offset(y: -videoTopExtension)
// Keyed to gesture/visibility state, NOT to derived geometry —
// appear settling must not animate. The drag-offset springs
// are what animate releases (the host sheet strips
// withAnimation transactions); every state flip happens at
// an offset where the derived frame is continuous.
.animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: panelToggleGeneration)
.animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: sideColumnDragOffset)
.animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: belowPanelDragOffset)
// Aspect ratio changes (new video, stream switch) resize
// the video/panel split — animate them
.animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: playerState?.displayAspectRatio)
// Swipe up on the video to bring the panel back — the
// video's bottom edge follows the finger (like the portrait
// reveal) via belowPanelDragOffset counting down from full
// hide travel.
.simultaneousGesture(
(isPanelVisible && !isBelowPanelVisible && hasBelowVideoInfo)
? DragGesture()
.onChanged { value in
guard appEnvironment?.navigationCoordinator.isAdjustingPlayerSliders != true else { return }
guard value.translation.height < 0 || isRevealDragActive else { return }
if appEnvironment?.navigationCoordinator.isPanelDragging != true {
appEnvironment?.navigationCoordinator.isPanelDragging = true
}
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
isRevealDragActive = true
belowPanelDragOffset = min(panelHideTravel, max(0, panelHideTravel + value.translation.height))
}
}
.onEnded { value in
guard isRevealDragActive else { return }
appEnvironment?.navigationCoordinator.isPanelDragging = false
if value.translation.height < -80 || value.predictedEndTranslation.height < -160 {
// Revealing: flip to visible at the
// current position, then animate home
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
isBelowPanelVisible = true
isRevealDragActive = false
}
withAnimation(.spring(response: 0.3, dampingFraction: 1.0)) {
belowPanelDragOffset = 0
}
} else {
// Grow back over the panel, then drop the
// reveal geometry without animation
withAnimation(.spring(response: 0.3, dampingFraction: 1.0), completionCriteria: .removed) {
belowPanelDragOffset = panelHideTravel
} completion: {
// A new reveal drag may have taken over
guard isRevealDragActive, belowPanelDragOffset == panelHideTravel,
appEnvironment?.navigationCoordinator.isPanelDragging != true else { return }
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
isRevealDragActive = false
belowPanelDragOffset = 0
}
}
}
}
: nil
)
// Swipe in from the trailing screen edge to bring a
// collapsed side column back — the video's trailing edge
// follows the finger via sideColumnDragOffset counting down
// from full travel. Only the edge strip: the seek pan is
// told to skip it, everywhere else seeks.
.simultaneousGesture(
isSideColumnCollapsed
? DragGesture(coordinateSpace: .local)
.onChanged { value in
guard appEnvironment?.navigationCoordinator.isAdjustingPlayerSliders != true else { return }
guard isSideColumnRevealDragActive
|| (value.startLocation.x >= videoWidth - Self.sideColumnEdgeRevealInset
&& value.translation.width < 0) else { return }
if appEnvironment?.navigationCoordinator.isPanelDragging != true {
appEnvironment?.navigationCoordinator.isPanelDragging = true
}
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
isSideColumnRevealDragActive = true
sideColumnDragOffset = min(sideColumnHideTravel, max(0, sideColumnHideTravel + value.translation.width))
}
}
.onEnded { value in
guard isSideColumnRevealDragActive else { return }
appEnvironment?.navigationCoordinator.isPanelDragging = false
if value.translation.width < -80 || value.predictedEndTranslation.width < -160 {
// Revealing: flip to visible at the
// current position, then animate home
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
isSideColumnVisible = true
isSideColumnRevealDragActive = false
}
withAnimation(.spring(response: 0.3, dampingFraction: 1.0)) {
sideColumnDragOffset = 0
}
} else {
// Grow back over the column, then drop
// the reveal geometry without animation
withAnimation(.spring(response: 0.3, dampingFraction: 1.0), completionCriteria: .removed) {
sideColumnDragOffset = sideColumnHideTravel
} completion: {
// A new reveal drag may have taken over
guard isSideColumnRevealDragActive, sideColumnDragOffset == sideColumnHideTravel,
appEnvironment?.navigationCoordinator.isPanelDragging != true else { return }
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
isSideColumnRevealDragActive = false
sideColumnDragOffset = 0
}
}
}
}
: nil
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// Extend into the bottom safe area (incl. the player
// window's extra PiP inset) so the full-height video reaches
// the true bottom edge
.ignoresSafeArea(edges: .bottom)
.zIndex(1)
}
// Container-level animation so the video-area resize plays as
// one transition (the host sheet strips withAnimation
// transactions, so key it here)
.animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: panelToggleGeneration)
.animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: sideColumnDragOffset)
.onChange(of: isSideColumnCollapsed, initial: true) { _, collapsed in
appEnvironment?.navigationCoordinator.cornerSideColumnEdgeRevealInset = collapsed ? Self.sideColumnEdgeRevealInset : 0
}
.onChange(of: width) { _, newWidth in
// Re-clamp when the window shrinks (still above the corner
// threshold — below it the resolver switches layouts).
columnWidth = clampColumnWidth(columnWidth, availableWidth: newWidth)
}
}
.onDisappear {
appEnvironment?.navigationCoordinator.cornerSideColumnEdgeRevealInset = 0
}
.onAppear {
// Only sync from the setting once the session's default
// visibility was applied — before that the setting is stale
// (the init seed already resolved the correct initial value).
if appEnvironment?.navigationCoordinator.hasAppliedPanelDefaultVisibility == true,
let visible = settingsManager?.landscapeDetailsPanelVisible {
isPanelVisible = visible
}
// Default the side column to the first configured page (skipping
// pages hosted below the video).
if let first = panelSettings.enabledPages().first(where: { !belowVideoPageTypes.contains($0.pageType) })?.pageType {
primaryPage = first
}
}
.onChange(of: columnWidth) { _, newWidth in
settingsManager?.cornerPanelColumnWidth = newWidth
}
.onChange(of: settingsManager?.landscapeDetailsPanelVisible) { _, newValue in
// External writes (preset default visibility, other layout hosts)
// must reach the local state too.
guard let newValue, newValue != isPanelVisible else { return }
withAnimation(.easeInOut(duration: 0.25)) {
if newValue { restoreDraggedAwayRegions() } else { collapseBelowPanelUnderVideo() }
isPanelVisible = newValue
panelToggleGeneration += 1
}
}
}
// MARK: - Panels
/// Portrait-style panel below the video: the actual PortraitDetailsPanel
/// (drag handle, player pill, collapsible description) with the comments
/// pill hidden and no inline chapters/related — those live in the side
/// column tabs.
@ViewBuilder
private func belowVideoInfoPanel(video: Video, hideTravel: CGFloat, publishesFrame: Bool) -> some View {
Group {
#if os(iOS)
PortraitDetailsPanel(
onChannelTap: onChannelTap,
playerControlsLayout: playerControlsLayout,
onFullscreen: onFullscreen,
pillsOverlayOpacity: 1,
hidesCommentsPill: !hasBelowVideoComments,
inlineInfoSections: false,
panelCornerRadius: 0,
onDragChanged: { offset in
if appEnvironment?.navigationCoordinator.isPanelDragging != true {
appEnvironment?.navigationCoordinator.isPanelDragging = true
}
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
if isBelowPanelExpanded {
// When expanded, only track drag down to collapse
belowPanelExpandOffset = max(0, offset)
} else if offset < 0 {
// Dragging UP - expand panel over the video.
// Raised at home (no hide travel yet), so the
// z-order swap happens with nothing overlapping.
isBelowPanelRaised = true
belowPanelExpandOffset = offset
belowPanelDragOffset = 0
} else {
// Dragging DOWN - the video grows over the panel
isBelowPanelRaised = false
belowPanelDragOffset = offset
belowPanelExpandOffset = 0
}
}
},
onDragEnded: { offset, predicted in
appEnvironment?.navigationCoordinator.isPanelDragging = false
if isBelowPanelExpanded {
if offset > 80 || predicted > 160 {
// Collapse back to home, then drop under the
// video once the spring is fully gone (`.removed`
// — see the hide path below)
withAnimation(.spring(response: 0.3, dampingFraction: 1.0), completionCriteria: .removed) {
isBelowPanelExpanded = false
belowPanelExpandOffset = 0
} completion: {
lowerBelowPanelIfHome()
}
} else {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
belowPanelExpandOffset = 0
}
}
} else if offset < -80 || predicted < -160 {
// Expand panel to cover the video
withAnimation(.spring(response: 0.3, dampingFraction: 1.0)) {
isBelowPanelExpanded = true
belowPanelExpandOffset = 0
belowPanelDragOffset = 0
}
} else if offset > 80 || predicted > 160 {
// Grow the video fully over the panel, then flip the
// hidden state without animation — the dragged
// geometry at full travel matches the hidden geometry
// exactly. Must wait
// for `.removed`: flipping at `.logicallyComplete`
// leaves the spring registered on the video frame, and
// a quick follow-up reveal swipe that interrupts it
// makes the release spring start from a bogus shrunken
// rect (video clipped to a short box, growing back).
withAnimation(.spring(response: 0.3, dampingFraction: 1.0), completionCriteria: .removed) {
belowPanelDragOffset = hideTravel
} completion: {
// The panel may have been grabbed again meanwhile
guard isBelowPanelVisible, belowPanelDragOffset == hideTravel,
appEnvironment?.navigationCoordinator.isPanelDragging != true else { return }
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
isBelowPanelVisible = false
isBelowPanelExpanded = false
belowPanelDragOffset = 0
}
}
} else {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8), completionCriteria: .removed) {
belowPanelDragOffset = 0
belowPanelExpandOffset = 0
} completion: {
lowerBelowPanelIfHome()
}
}
},
onDragCancelled: {
appEnvironment?.navigationCoordinator.isPanelDragging = false
withAnimation(.spring(response: 0.3, dampingFraction: 0.8), completionCriteria: .removed) {
belowPanelDragOffset = 0
belowPanelExpandOffset = 0
} completion: {
lowerBelowPanelIfHome()
}
}
)
#else
// macOS: static info panel, no drag-to-hide
InfoPanelPage(
context: PanelPageContext(
appEnvironment: appEnvironment,
video: video,
accentColor: accentColor,
onChannelTap: onChannelTap
),
settings: infoPageSettings,
scrollConfig: PanelScrollConfig(
topInset: 4,
bottomInset: 16
)
)
.background(panelBackgroundColor.ignoresSafeArea(edges: .vertical))
#endif
}
// Publish the panel frame so the window-level dismiss pan gesture
// doesn't begin from touches inside this panel (drag handle conflict).
// Not while the video covers the panel — it stays in the hierarchy
// hidden, and must not block the pan from starting over the video.
.background {
GeometryReader { proxy in
let frame = publishesFrame ? proxy.frame(in: .global) : .zero
Color.clear
.onChange(of: frame, initial: true) { _, frame in
appEnvironment?.navigationCoordinator.cornerBelowPanelFrame = frame
}
.onDisappear {
appEnvironment?.navigationCoordinator.cornerBelowPanelFrame = .zero
appEnvironment?.navigationCoordinator.isPanelDragging = false
}
}
}
}
/// Side column with tabs for every enabled page the below-video host
/// doesn't render.
@ViewBuilder
private func sideColumnPanel(video: Video) -> some View {
PlayerPanelContentView(
panelSettings: panelSettings,
selectedPage: $primaryPage,
context: PanelPageContext(
appEnvironment: appEnvironment,
video: video,
accentColor: accentColor,
onChannelTap: onChannelTap
),
scrollConfig: PanelScrollConfig(
topInset: 4,
bottomInset: 16
),
pageBarTopPadding: 8,
excludedPages: belowVideoPageTypes
)
// The grabber strip already separates the column from the video;
// trim the leading inset so content sits 16pt from the column's
// outer edge on both sides instead of grabber + 16 on the left.
.environment(\.panelHorizontalInsets, PanelHorizontalInsets(
leading: max(0, PanelHorizontalInsets.standard - Self.grabberWidth),
trailing: PanelHorizontalInsets.standard
))
.background(panelBackgroundColor.ignoresSafeArea(edges: .bottom))
}
// MARK: - Actions
private func togglePanel() {
// Regions dragged away on their own leave the panel nominally
// visible — once nothing is left on screen the toggle shows
// everything again instead of "hiding" an empty panel.
let showsAnyRegion = isPanelVisible
&& ((isSideColumnVisible && hasSideColumn) || (isBelowPanelVisible && hasBelowVideoInfo))
let show = !showsAnyRegion
withAnimation(.easeInOut(duration: 0.25)) {
if show { restoreDraggedAwayRegions() } else { collapseBelowPanelUnderVideo() }
isPanelVisible = show
panelToggleGeneration += 1
settingsManager?.landscapeDetailsPanelVisible = show
settingsManager?.landscapeDetailsPanelLastVisible = show
}
}
/// Hiding everything grows the video over the below panel, so an
/// expanded panel (raised above the video) must drop back to its home
/// geometry under the video first.
private func collapseBelowPanelUnderVideo() {
isBelowPanelExpanded = false
belowPanelExpandOffset = 0
isBelowPanelRaised = false
}
/// Completion of an expand-collapse / snap-back spring: drop the panel
/// under the video once it sits at home again — unless a new drag has
/// picked it up meanwhile.
private func lowerBelowPanelIfHome() {
guard !isBelowPanelExpanded, belowPanelExpandOffset == 0,
appEnvironment?.navigationCoordinator.isPanelDragging != true else { return }
isBelowPanelRaised = false
}
/// Showing the panel brings back every region hidden by its own drag
/// handle (the portrait layout shares one flag for both, so this keeps
/// the toggle's meaning the same across layouts).
private func restoreDraggedAwayRegions() {
isSideColumnVisible = true
sideColumnDragOffset = 0
isBelowPanelVisible = true
belowPanelDragOffset = 0
}
}
#endif