Rework corner layout with portrait-style info panel below the video

The corner layout now always renders the info page as a portrait-style
panel below the video (collapsible description, drag to hide/expand),
while the side column holds tabs for the remaining pages. Key pieces:

- Below-video panel reuses PortraitDetailsPanel (no comments pill,
  square corners, chapters/related stay in the side column tabs)
- Drag down hides the panel with the video recentering in lockstep;
  swipe up on the video reveals it following the finger
- Fullscreen button toggles the panels in corner layout (controls hide
  first so their resize never plays visibly); status bar and home
  indicator hide only while the panels are hidden
- Panel visibility and column width are seeded at init from a warmed
  PlayerControlsLayout cache, so the first frame resolves corner mode
  without flashing the default layout or animating the panels in
- Dismiss pan is blocked inside the below-video panel frame
  (cornerBelowPanelFrame in NavigationCoordinator)
- Resize grabber slimmed to match the panel drag handle look
This commit is contained in:
Arkadiusz Fal
2026-09-23 07:25:02 +02:00
parent ec13fda115
commit 05a03723f6
14 changed files with 586 additions and 435 deletions

View File

@@ -740,6 +740,12 @@ final class AppEnvironment {
func refreshActiveControlsSettings() async {
let layout = await playerControlsLayoutService.activeLayout()
activeControlsCenterSettings = layout.centerSettings
// Warm the layout caches so the expanded player resolves the correct
// layout (corner mode, panel settings) on its very first frame.
PlayerControlsLayout.cached = layout
GlobalLayoutSettings.cached = layout.globalSettings
MiniPlayerSettings.cached = layout.effectiveMiniPlayerSettings
PanelSettings.cached = layout.effectivePanelSettings
}
/// Updates the HTTP client's User-Agent configuration from current settings.

View File

@@ -6530,16 +6530,6 @@
}
}
},
"migration.accounts.prompt.review" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Review"
}
}
}
},
"migration.accounts.prompt.title" : {
"localizations" : {
"en" : {
@@ -6550,26 +6540,6 @@
}
}
},
"migration.accounts.prompt.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Import from Previous App"
}
}
}
},
"migration.accounts.remove.confirm" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Remove"
}
}
}
},
"migration.accounts.remove.confirm" : {
"localizations" : {
"en" : {
@@ -6590,16 +6560,6 @@
}
}
},
"migration.accounts.remove.message %@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "This will remove \"%@\" from the legacy import list. It will not be imported."
}
}
}
},
"migration.accounts.remove.title" : {
"localizations" : {
"en" : {
@@ -6610,26 +6570,6 @@
}
}
},
"migration.accounts.remove.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Remove Legacy Account?"
}
}
}
},
"migration.accounts.section" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Accounts"
}
}
}
},
"migration.accounts.section" : {
"localizations" : {
"en" : {
@@ -6650,16 +6590,6 @@
}
}
},
"migration.accounts.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Legacy Accounts"
}
}
}
},
"migration.import" : {
"localizations" : {
"en" : {
@@ -6670,26 +6600,6 @@
}
}
},
"migration.import" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Import"
}
}
}
},
"migration.importing" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Importing..."
}
}
}
},
"migration.importing" : {
"localizations" : {
"en" : {
@@ -6710,16 +6620,6 @@
}
}
},
"migration.sources.footer" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add these sources from the previous Yattee app. No sign-in is required."
}
}
}
},
"migration.sources.imported.title" : {
"localizations" : {
"en" : {
@@ -6730,26 +6630,6 @@
}
}
},
"migration.sources.imported.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Source Added"
}
}
}
},
"migration.sources.remove.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Remove Legacy Source?"
}
}
}
},
"migration.sources.remove.title" : {
"localizations" : {
"en" : {
@@ -6770,16 +6650,6 @@
}
}
},
"migration.sources.section" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sources"
}
}
}
},
"miniPlayer.addButton.header" : {
"localizations" : {
"en" : {
@@ -6790,26 +6660,6 @@
}
}
},
"miniPlayer.addButton.header" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add Button"
}
}
}
},
"miniPlayer.buttons.empty" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No buttons configured"
}
}
}
},
"miniPlayer.buttons.empty" : {
"localizations" : {
"en" : {
@@ -6830,26 +6680,6 @@
}
}
},
"miniPlayer.buttons.footer" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Customize which buttons appear in the mini player."
}
}
}
},
"miniPlayer.buttons.header" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Buttons"
}
}
}
},
"miniPlayer.buttons.header" : {
"localizations" : {
"en" : {
@@ -6862,19 +6692,6 @@
},
"my-server.example.com" : {
},
"my-server.example.com" : {
},
"navigation.sourceNotFound" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Source Not Found"
}
}
}
},
"navigation.sourceNotFound" : {
"localizations" : {
@@ -6896,27 +6713,6 @@
}
}
},
"navigation.sourceNotFound.description" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "This media source may have been removed."
}
}
}
},
"notification.action.watch" : {
"comment" : "Action button on notification to watch video",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Watch"
}
}
}
},
"notification.action.watch" : {
"comment" : "Action button on notification to watch video",
"localizations" : {
@@ -6939,17 +6735,6 @@
}
}
},
"notification.multipleChannels %@ %@" : {
"comment" : "Multiple channels notification",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "New videos from %1$@ and %2$@"
}
}
}
},
"notification.multipleVideosOneChannel %lld %@" : {
"comment" : "Multiple videos from one channel",
"localizations" : {
@@ -6961,28 +6746,6 @@
}
}
},
"notification.multipleVideosOneChannel %lld %@" : {
"comment" : "Multiple videos from one channel",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%1$lld new videos from %2$@"
}
}
}
},
"notification.newVideos.title" : {
"comment" : "Notification title for new videos",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "New Videos"
}
}
}
},
"notification.newVideos.title" : {
"comment" : "Notification title for new videos",
"localizations" : {
@@ -7005,28 +6768,6 @@
}
}
},
"notification.singleVideo %@" : {
"comment" : "Notification body for single video",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "New video: %@"
}
}
}
},
"notification.twoChannels %@ %@" : {
"comment" : "Two channels notification",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "New videos from %1$@ and %2$@"
}
}
}
},
"notification.twoChannels %@ %@" : {
"comment" : "Two channels notification",
"localizations" : {
@@ -7049,17 +6790,6 @@
}
}
},
"notification.videoItem %@ %@" : {
"comment" : "Video item: Author: Title",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%1$@: %2$@"
}
}
}
},
"onboarding.cloud.syncing.preparing" : {
"comment" : "Preparing sync text on iCloud onboarding",
"localizations" : {
@@ -7071,28 +6801,6 @@
}
}
},
"onboarding.cloud.syncing.preparing" : {
"comment" : "Preparing sync text on iCloud onboarding",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Preparing sync..."
}
}
}
},
"onboarding.cloud.syncing.title" : {
"comment" : "iCloud syncing title on onboarding",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Syncing Your Data"
}
}
}
},
"onboarding.cloud.syncing.title" : {
"comment" : "iCloud syncing title on onboarding",
"localizations" : {
@@ -7566,6 +7274,17 @@
}
}
},
"panel.settings.comments.showAvatars" : {
"comment" : "Comments page element toggle",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Author Avatars"
}
}
}
},
"panel.settings.defaultVisibility" : {
"comment" : "Details panel settings picker label",
"localizations" : {
@@ -7599,17 +7318,6 @@
}
}
},
"panel.settings.comments.showAvatars" : {
"comment" : "Comments page element toggle",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Author Avatars"
}
}
}
},
"panel.settings.elements.header" : {
"comment" : "Details panel page editor section header",
"localizations" : {
@@ -7698,28 +7406,6 @@
}
}
},
"panel.settings.pageBarLabels" : {
"comment" : "Details panel page bar labels toggle",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Page Bar Labels"
}
}
}
},
"panel.settings.pageBarLabels.footer" : {
"comment" : "Details panel page bar labels footer",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Show a name next to each page icon. With labels off, only the selected page shows its name."
}
}
}
},
"panel.settings.page.enabled" : {
"comment" : "Details panel page editor toggle",
"localizations" : {
@@ -7764,6 +7450,50 @@
}
}
},
"panel.settings.pageBarLabels" : {
"comment" : "Details panel page bar labels toggle",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Page Bar Labels"
}
}
}
},
"panel.settings.pageBarLabels.footer" : {
"comment" : "Details panel page bar labels footer",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Show a name next to each page icon. With labels off, only the selected page shows its name."
}
}
}
},
"panel.settings.pages.footer" : {
"comment" : "Details panel settings section footer",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pages appear in this order. The page bar shows when at least two pages are enabled; pages without content hide automatically."
}
}
}
},
"panel.settings.pages.header" : {
"comment" : "Details panel settings section header",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pages"
}
}
}
},
"panel.settings.queue.showHistory" : {
"comment" : "Queue page element toggle",
"localizations" : {
@@ -7797,28 +7527,6 @@
}
}
},
"panel.settings.pages.header" : {
"comment" : "Details panel settings section header",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pages"
}
}
}
},
"panel.settings.pages.footer" : {
"comment" : "Details panel settings section footer",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pages appear in this order. The page bar shows when at least two pages are enabled; pages without content hide automatically."
}
}
}
},
"peertube.explore.added" : {
"localizations" : {
"en" : {
@@ -8651,6 +8359,7 @@
}
},
"player.tab.comments" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -8661,6 +8370,7 @@
}
},
"player.tab.info" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -17345,17 +17055,6 @@
}
}
},
"sources.field.invidiousInstance" : {
"comment" : "Field label for Invidious instance in Yattee Server info",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Invidious"
}
}
}
},
"sources.field.frontendURL" : {
"comment" : "Field label for optional Piped web frontend URL",
"localizations" : {
@@ -17367,6 +17066,17 @@
}
}
},
"sources.field.invidiousInstance" : {
"comment" : "Field label for Invidious instance in Yattee Server info",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Invidious"
}
}
}
},
"sources.field.name" : {
"comment" : "Field label for source name",
"localizations" : {

View File

@@ -106,6 +106,12 @@ struct PlayerControlsLayout: Codable, Hashable, Sendable {
// MARK: - Defaults
/// Last loaded active layout — lets the expanded player seed its layout
/// state synchronously before the async load, so the first frames resolve
/// the correct layout (e.g. corner mode) instead of flashing the default.
/// Warmed at app start and kept fresh by the expanded player's loads.
nonisolated(unsafe) static var cached: PlayerControlsLayout = .default
/// Default layout matching the current hardcoded player controls.
static let `default`: PlayerControlsLayout = {
// Top section: spacer, brightness (widescreen), volume, airplay, debug, close

View File

@@ -158,6 +158,10 @@ final class NavigationCoordinator {
/// Portrait panel frame in screen coordinates (for gesture conflict resolution).
var portraitPanelFrame: CGRect = .zero
/// Corner layout below-video panel frame in screen coordinates — touches
/// inside it must not start the sheet dismiss pan (drag handle conflict).
var cornerBelowPanelFrame: CGRect = .zero
/// Progress bar frame in screen coordinates (for gesture conflict resolution).
var progressBarFrame: CGRect = .zero

View File

@@ -2,11 +2,11 @@
// CornerPlayerLayout.swift
// Yattee
//
// Corner player layout for iPad/macOS: video in the top-left, a resizable
// side column on the right, and an optional region below the video —
// YouTube-style. Pages are assigned to regions in the preset's panel
// settings; the layout resolver in ExpandedPlayerSheet falls back to the
// stacked layout when the window is too narrow.
// 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
@@ -27,6 +27,34 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
) -> PlayerContent
let onChannelTap: (() -> Void)?
var onFullscreen: (() -> Void)? = nil
/// 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
) {
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
@@ -44,10 +72,21 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
@State private var isPanelVisible = true
@State private var columnWidth: CGFloat = SettingsManager.defaultCornerPanelColumnWidth
@State private var primaryPage: PanelPageType = .related
@State private var belowVideoPage: PanelPageType = .info
@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
/// 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
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 }
@@ -56,15 +95,17 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
playerControlsLayout.effectivePanelSettings
}
/// Region emptiness is decided at the config level: runtime-empty pages
/// 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 regions — layout jumps during loading would be jarring.
private var hasPrimaryRegion: Bool {
!panelSettings.enabledPages(in: .primary).isEmpty
/// by collapsing the column — layout jumps during loading would be jarring.
/// Info always renders below the video; the side column holds the rest.
private var hasSideColumn: Bool {
panelSettings.enabledPages().contains { $0.pageType != .info }
}
private var hasBelowVideoRegion: Bool {
!panelSettings.enabledPages(in: .belowVideo).isEmpty
/// 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 {
@@ -90,47 +131,182 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
let width = max(1, geometry.size.width)
let height = max(1, geometry.size.height)
let showsPrimary = isPanelVisible && hasPrimaryRegion
let showsBelowVideo = isPanelVisible && hasBelowVideoRegion
let showsSideColumn = isPanelVisible && hasSideColumn
let showsBelowVideo = isPanelVisible && isBelowPanelVisible
let effectiveColumnWidth = clampColumnWidth(columnWidth, availableWidth: width)
// Grabber sits on the column's leading edge, inside the column background.
let totalColumnWidth = effectiveColumnWidth + 20
// Grabber sits on the column's leading edge, inside the column
// background — narrow strip matching the panel drag handle look.
let grabberWidth: CGFloat = 12
let totalColumnWidth = effectiveColumnWidth + grabberWidth
let videoWidth = showsPrimary ? max(1, width - totalColumnWidth) : width
let videoHeight: CGFloat = {
guard showsBelowVideo else { return height }
let videoWidth = showsSideColumn ? max(1, width - totalColumnWidth) : 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(videoWidth / aspectRatio, height * Self.maxVideoHeightFraction))
}()
// Reveal drag renders the shown layout so the panel can follow
// the finger while `isBelowPanelVisible` is still false.
let showsPanelLayout = showsBelowVideo || (isPanelVisible && isRevealDragActive)
// Panel travel needed to fully clear the screen (incl. bottom
// safe area + the player window's extra PiP inset), and the video
// recenter offset that follows the drag — like the portrait
// layout's videoYOffset. At full travel the offset video matches
// the centered full-height layout exactly, so the state flips
// after the hide/reveal animations cause no visible jump.
let bottomInset = geometry.safeAreaInsets.bottom
let fullColumnHeight = height + bottomInset
// 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 ? shownVideoHeight : fullColumnHeight
let normalPanelHeight = max(1, fullColumnHeight - shownVideoHeight)
let panelHideTravel = normalPanelHeight
// One shared duration for the whole panel toggle — both panels
// and both video dimensions animate in lockstep so the video
// corner grows diagonally 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))
let videoDragProgress = showsPanelLayout
? min(1.0, max(0, belowPanelDragOffset) / panelHideTravel)
: 0
let videoDragYOffset = (fullColumnHeight - shownVideoHeight) / 2 * videoDragProgress
// 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).
let panelHeight: CGFloat = {
if isBelowPanelExpanded {
return max(normalPanelHeight, fullColumnHeight - belowPanelExpandOffset)
}
return min(fullColumnHeight, normalPanelHeight - belowPanelExpandOffset)
}()
let panelSpacerHeight: CGFloat = {
if isBelowPanelExpanded {
return min(videoHeight, belowPanelExpandOffset)
}
return max(0, videoHeight + belowPanelExpandOffset)
}()
ZStack {
Color.black.ignoresSafeArea(.all)
HStack(spacing: 0) {
VStack(spacing: 0) {
playerContent(
togglePanel,
isPanelVisible,
videoWidth,
videoHeight
)
.frame(width: videoWidth, height: videoHeight)
.clipped()
if showsBelowVideo, let video {
regionPanel(
region: .belowVideo,
selectedPage: $belowVideoPage,
video: video
ZStack(alignment: .top) {
// 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,
isPanelVisible,
max(1, videoProxy.size.width),
max(1, videoProxy.size.height)
)
.frame(width: videoWidth)
.frame(maxHeight: .infinity)
}
// Height animates with the below-panel timing, width
// with the side-column timing — the inner .animation
// scopes only the height frame. Keyed to gesture/
// visibility state, NOT to derived geometry — appear
// settling must not animate.
.frame(height: videoHeight)
.animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isPanelVisible)
.animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isBelowPanelVisible)
.frame(width: videoWidth)
.animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isPanelVisible)
.clipped()
.offset(y: videoDragYOffset)
.animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: videoDragYOffset)
// 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 panel follows the finger (like the portrait
// reveal) via belowPanelDragOffset counting down
// from full hide travel.
.simultaneousGesture(
(isPanelVisible && !isBelowPanelVisible)
? 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 {
// Snap back off-screen, then drop the
// reveal layout without animation
withAnimation(.spring(response: 0.3, dampingFraction: 1.0), completionCriteria: .logicallyComplete) {
belowPanelDragOffset = panelHideTravel
} completion: {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
isRevealDragActive = false
belowPanelDragOffset = 0
}
}
}
}
: nil
)
.frame(maxHeight: .infinity, alignment: .top)
if showsPanelLayout, let video {
VStack(spacing: 0) {
Spacer(minLength: 0)
.frame(height: panelSpacerHeight)
belowVideoInfoPanel(video: video, hideTravel: panelHideTravel)
.frame(width: videoWidth)
.frame(height: panelHeight)
.offset(y: belowPanelDragOffset)
}
.frame(maxHeight: .infinity, alignment: .top)
.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)
// Pure slide, no fade — with a fade the panel
// seems to vanish before the side column and the
// video resize, breaking the one-transition feel
.transition(.move(edge: .bottom).animation(.easeInOut(duration: panelToggleDuration)))
}
}
.frame(width: videoWidth)
.frame(maxHeight: .infinity, alignment: .top)
// 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)
if showsPrimary, let video {
if showsSideColumn, let video {
HStack(spacing: 0) {
PanelResizeGrabber(
panelWidth: $columnWidth,
@@ -138,19 +314,24 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
maxWidth: clampColumnWidth(.infinity, availableWidth: width),
panelSide: .right,
isPinned: true,
isDragActive: $isGrabberActive
isDragActive: $isGrabberActive,
hitWidth: grabberWidth
)
regionPanel(
region: .primary,
selectedPage: $primaryPage,
video: video
)
.frame(width: effectiveColumnWidth)
sideColumnPanel(video: video)
.frame(width: effectiveColumnWidth)
}
.background(panelBackgroundColor.ignoresSafeArea(edges: .vertical))
// Bottom only — the background must not run under
// the system status bar at the top
.background(panelBackgroundColor.ignoresSafeArea(edges: .bottom))
.transition(.move(edge: .trailing).animation(.easeInOut(duration: panelToggleDuration)))
}
}
// Container-level animation so panel insert/remove and the
// video-area resize play as one transition (the host sheet
// strips withAnimation transactions, so key it here)
.animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isPanelVisible)
.animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isBelowPanelVisible)
}
.onChange(of: width) { _, newWidth in
// Re-clamp when the window shrinks (still above the corner
@@ -159,9 +340,12 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
}
}
.onAppear {
isPanelVisible = settingsManager?.landscapeDetailsPanelVisible ?? true
if let savedWidth = settingsManager?.cornerPanelColumnWidth, savedWidth > 0 {
columnWidth = savedWidth
// 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
}
}
.onChange(of: columnWidth) { _, newWidth in
@@ -177,18 +361,136 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
}
}
// MARK: - Region Panel
// 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 regionPanel(
region: PanelRegion,
selectedPage: Binding<PanelPageType>,
video: Video
) -> some View {
private func belowVideoInfoPanel(video: Video, hideTravel: CGFloat) -> some View {
Group {
#if os(iOS)
PortraitDetailsPanel(
onChannelTap: onChannelTap,
playerControlsLayout: playerControlsLayout,
onFullscreen: onFullscreen,
pillsOverlayOpacity: 1,
hidesCommentsPill: true,
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
belowPanelExpandOffset = offset
belowPanelDragOffset = 0
} else {
// Dragging DOWN - dismiss tracking
belowPanelDragOffset = offset
belowPanelExpandOffset = 0
}
}
},
onDragEnded: { offset, predicted in
appEnvironment?.navigationCoordinator.isPanelDragging = false
if isBelowPanelExpanded {
if offset > 80 || predicted > 160 {
withAnimation(.spring(response: 0.3, dampingFraction: 1.0)) {
isBelowPanelExpanded = false
belowPanelExpandOffset = 0
}
} 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 {
// Slide the panel fully off (video recenters via the
// drag-progress offset), then flip the hidden state
// without animation — the offset layout at full
// travel matches the hidden layout exactly.
withAnimation(.spring(response: 0.3, dampingFraction: 1.0), completionCriteria: .logicallyComplete) {
belowPanelDragOffset = hideTravel
} completion: {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
isBelowPanelVisible = false
isBelowPanelExpanded = false
belowPanelDragOffset = 0
}
}
} else {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
belowPanelDragOffset = 0
belowPanelExpandOffset = 0
}
}
},
onDragCancelled: {
appEnvironment?.navigationCoordinator.isPanelDragging = false
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
belowPanelDragOffset = 0
belowPanelExpandOffset = 0
}
}
)
#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)
.background {
GeometryReader { proxy in
Color.clear
.onChange(of: proxy.frame(in: .global), 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 except info.
@ViewBuilder
private func sideColumnPanel(video: Video) -> some View {
PlayerPanelContentView(
panelSettings: panelSettings,
region: region,
selectedPage: selectedPage,
selectedPage: $primaryPage,
context: PanelPageContext(
appEnvironment: appEnvironment,
video: video,
@@ -199,9 +501,10 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
topInset: 4,
bottomInset: 16
),
pageBarTopPadding: 8
pageBarTopPadding: 8,
excludedPages: [.info]
)
.background(panelBackgroundColor.ignoresSafeArea(edges: .vertical))
.background(panelBackgroundColor.ignoresSafeArea(edges: .bottom))
}
// MARK: - Actions

View File

@@ -1317,8 +1317,18 @@ extension ExpandedPlayerSheet {
/// identical to the stacked layout — only the container size differs.
@ViewBuilder
func cornerContent(video: Video) -> some View {
// Seed the panel visibility before the first frame: once the
// session's default visibility is applied the setting is the truth;
// until then resolve the preset default (same policy the sheet's
// applyPanelDefaultVisibility writes moments later).
let initialPanelVisible = appEnvironment?.navigationCoordinator.hasAppliedPanelDefaultVisibility == true
? (appEnvironment?.settingsManager.landscapeDetailsPanelVisible ?? true)
: Self.initialPortraitPanelVisibility(settingsManager: appEnvironment?.settingsManager)
CornerPlayerLayout(
playerControlsLayout: playerControlsLayout,
initialPanelVisible: initialPanelVisible,
initialColumnWidth: appEnvironment?.settingsManager.cornerPanelColumnWidth,
playerContent: { onTogglePanel, isPanelVisible, width, height in
// isPanelPinned=true keeps the controls visible alongside the
// panel regions (corner regions behave like a pinned panel).
@@ -1336,7 +1346,12 @@ extension ExpandedPlayerSheet {
},
onChannelTap: video.author.hasRealChannelInfo ? {
navigationCoordinator?.navigateToChannel(for: video, collapsePlayer: true)
} : nil
} : nil,
onFullscreen: {
#if os(iOS)
self.toggleFullscreen()
#endif
}
)
}
@@ -1558,6 +1573,7 @@ extension ExpandedPlayerSheet {
isPanelVisible: isPanelVisible,
panelSide: panelSide,
isPanelPinned: isPanelPinned,
isCornerLayout: appEnvironment?.navigationCoordinator.isCornerLayoutActive == true,
layoutLeadingSafeArea: 0.01, // Non-zero indicates safe areas handled externally
layoutTrailingSafeArea: 0.01,
onToggleOrientationLock: { [weak appEnvironment] in

View File

@@ -74,7 +74,10 @@ struct ExpandedPlayerSheet: View {
/// 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
// Seeded from the warm cache so the first frames resolve the correct
// layout (corner mode) instead of flashing the default stacked layout;
// loadPlayerControlsLayout replaces it with the authoritative value.
@State var playerControlsLayout: PlayerControlsLayout = PlayerControlsLayout.cached
// MARK: - Autoplay Countdown State
@@ -470,8 +473,19 @@ struct ExpandedPlayerSheet: View {
)
#if os(iOS)
.toolbar(.hidden, for: .navigationBar)
.playerStatusBarHidden(isInWideScreenLayout || !isPortraitPanelVisible)
.persistentSystemOverlays((isInWideScreenLayout || !isPortraitPanelVisible) ? .hidden : .automatic)
// Corner layout with panels visible keeps the system status bar and
// home indicator; hiding the panels (fullscreen state) hides them so
// the video and controls reach the screen edges.
.playerStatusBarHidden(
navigationCoordinator?.isCornerLayoutActive == true
? !(appEnvironment?.settingsManager.landscapeDetailsPanelVisible ?? true)
: (isInWideScreenLayout || !isPortraitPanelVisible)
)
.persistentSystemOverlays(
(navigationCoordinator?.isCornerLayoutActive == true
? !(appEnvironment?.settingsManager.landscapeDetailsPanelVisible ?? true)
: (isInWideScreenLayout || !isPortraitPanelVisible)) ? .hidden : .automatic
)
#endif
}
@@ -944,6 +958,7 @@ private struct PlayerEventHandlersModifier: ViewModifier {
if let service = appEnvironment?.playerControlsLayoutService {
let layout = await service.activeLayout()
playerControlsLayout = layout
PlayerControlsLayout.cached = layout
GlobalLayoutSettings.cached = layout.globalSettings
MiniPlayerSettings.cached = layout.effectiveMiniPlayerSettings
PanelSettings.cached = layout.effectivePanelSettings

View File

@@ -144,6 +144,8 @@ private final class DragToDismissGestureHandler: NSObject, UIGestureRecognizerDe
var isPanelDragging: (() -> Bool)?
/// Returns the portrait panel frame in screen coordinates (for gesture conflict resolution)
var getPortraitPanelFrame: (() -> CGRect)?
/// Returns the corner layout below-video panel frame in screen coordinates (blocks dismiss pan)
var getCornerBelowPanelFrame: (() -> CGRect)?
/// Returns whether the portrait panel is currently visible (not hidden off-screen)
var isPortraitPanelVisible: (() -> Bool)?
/// Returns the progress bar frame in screen coordinates (for gesture conflict resolution)
@@ -366,6 +368,16 @@ private final class DragToDismissGestureHandler: NSObject, UIGestureRecognizerDe
return false
}
// Corner layout: don't begin dismiss gesture from inside the
// below-video panel — its drag handle hides the panel instead
let belowPanelFrame = getCornerBelowPanelFrame?() ?? .zero
if !belowPanelFrame.isEmpty {
let touchLocation = panGesture.location(in: nil)
if belowPanelFrame.contains(touchLocation) {
return false
}
}
// When portrait panel is visible, only allow dismiss from player area (above panel)
// This prevents race conditions where isPanelDragging isn't set yet when gesture begins
let panelFrame = getPortraitPanelFrame?() ?? .zero
@@ -845,6 +857,9 @@ final class ExpandedPlayerWindowManager {
handler.getPortraitPanelFrame = { [weak self] in
self?.appEnvironment?.navigationCoordinator.portraitPanelFrame ?? .zero
}
handler.getCornerBelowPanelFrame = { [weak self] in
self?.appEnvironment?.navigationCoordinator.cornerBelowPanelFrame ?? .zero
}
handler.isPortraitPanelVisible = { [weak self] in
self?.appEnvironment?.navigationCoordinator.isPortraitPanelVisible ?? true
}

View File

@@ -19,6 +19,15 @@ struct InfoPanelPage: View {
/// and related videos as inline sections below it.
var inlineSections: Bool = false
/// Collapse only while something actually renders below the description —
/// with no chapters and no related videos the description stays expanded.
/// While details are still loading, keep it collapsed to avoid an
/// expand-then-clamp flash when sections arrive.
private var usesCollapsedDescription: Bool {
inlineSections
&& (!chapters.isEmpty || !relatedVideos.isEmpty || videoDetailsState == .loading)
}
@State private var showFormattedDate = false
@State private var showOriginalTitle = false
@State private var isDescriptionExpanded = false
@@ -124,7 +133,7 @@ struct InfoPanelPage: View {
return VStack(alignment: .leading, spacing: 8) {
if !description.isEmpty {
if inlineSections {
if usesCollapsedDescription {
collapsibleDescription(description)
} else {
Text(DescriptionText.attributed(description, linkColor: context.accentColor))

View File

@@ -18,6 +18,9 @@ struct PanelResizeGrabber: View {
let isPinned: Bool
@Binding var isDragActive: Bool
/// Width of the grabber hit area (hosts can narrow it, e.g. corner layout)
var hitWidth: CGFloat = PanelResizeGrabber.grabberHitWidth
@State private var isDragging = false
@State private var dragStartWidth: CGFloat? = nil
@State private var dragStartLocation: CGFloat? = nil
@@ -26,29 +29,29 @@ struct PanelResizeGrabber: View {
#endif
/// Width of the grabber hit area
private static let grabberHitWidth: CGFloat = 20
static let grabberHitWidth: CGFloat = 20
/// Grabber fill color - needs to be visible against both light (pinned) and dark (floating) backgrounds
private var grabberColor: Color {
// When pinned (light background), use a darker gray for visibility
// When floating (dark background), use lighter color
let baseColor = isPinned ? Color.gray : Color.secondary
// Pinned matches the panel drag handle (secondary 0.4);
// floating (dark background) stays a bit lighter
let isActive = isDragging
return baseColor.opacity(isActive ? 1.0 : (isPinned ? 0.5 : 0.6))
return Color.secondary.opacity(isActive ? 1.0 : (isPinned ? 0.4 : 0.6))
}
var body: some View {
// Full-height container for expanded hit area
Rectangle()
.fill(Color.clear)
.frame(width: Self.grabberHitWidth)
.frame(width: hitWidth)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.overlay {
// Visual pill indicator (centered)
RoundedRectangle(cornerRadius: 3)
// Visual pill indicator (centered) — sized like the panel
// drag handle capsule (36x4), rotated vertical
Capsule()
.fill(grabberColor)
.frame(width: 6, height: 40)
.frame(width: 4, height: 36)
}
.gesture(dragGesture)
#if os(macOS)

View File

@@ -32,6 +32,10 @@ struct PlayerPanelContentView: View {
/// those pages are then dropped from the page list.
var inlineInfoSections: Bool = false
/// Pages the host renders elsewhere (e.g. corner layout keeps info below
/// the video), removed from this view's page list.
var excludedPages: Set<PanelPageType> = []
private var appEnvironment: AppEnvironment? { context.appEnvironment }
// MARK: - Page Visibility
@@ -73,12 +77,15 @@ struct PlayerPanelContentView: View {
}
private var visiblePages: [PanelPageConfiguration] {
let pages = Self.visiblePages(
var pages = Self.visiblePages(
panelSettings: panelSettings,
region: region,
video: context.video,
appEnvironment: appEnvironment
)
if !excludedPages.isEmpty {
pages = pages.filter { !excludedPages.contains($0.pageType) }
}
guard inlineInfoSections else { return pages }
return pages.filter { $0.pageType != .chapters && $0.pageType != .related }
}

View File

@@ -39,6 +39,10 @@ struct PlayerControlsActions {
#if os(iOS)
/// Whether the side panel is pinned (iPad landscape)
let isPanelPinned: Bool
/// Whether the corner layout is active: the fullscreen button then
/// toggles the panels (hide = fullscreen video, unhide on next press)
var isCornerLayout: Bool = false
#endif
/// Which side the panel is on
@@ -119,6 +123,10 @@ struct PlayerControlsActions {
/// Toggle details visibility (portrait fullscreen)
var onToggleDetailsVisibility: (() -> Void)?
/// Hide the controls overlay (corner layout hides them before the
/// panel toggle so the controls-resize never plays visibly)
var onHideControls: (() -> Void)?
#if os(iOS)
/// Toggle orientation lock
var onToggleOrientationLock: (() -> Void)?
@@ -263,6 +271,10 @@ struct PlayerControlsActions {
/// Uses rotation icons when tapping will cause device rotation,
/// otherwise uses standard fullscreen arrows.
var fullscreenIcon: String {
if isCornerLayout {
// Corner layout: reflects the panels state, not orientation
return isPanelVisible ? "arrow.up.left.and.arrow.down.right" : "arrow.down.right.and.arrow.up.left"
}
if willRotateOnFullscreenToggle {
if isFullscreen {
// Currently landscape fullscreen, will rotate to portrait
@@ -280,6 +292,14 @@ struct PlayerControlsActions {
/// Shared fullscreen-tap decision used by the fullscreen button and tap gestures.
/// Keep in sync with `willRotateOnFullscreenToggle` (icon mirror of this logic).
func performFullscreenTap() {
if isCornerLayout {
// Corner layout: hide the controls and toggle the panels —
// the controls fade out while the panel transition plays
onHideControls?()
onTogglePanel?()
return
}
let isActualWidescreenLayout = isWideScreenLayout && onTogglePanel != nil
if isIPad {
@@ -334,6 +354,11 @@ struct PlayerControlsActions {
/// Whether the fullscreen button should be shown
var shouldShowFullscreenButton: Bool {
// Corner layout: always shown, toggles the panels both ways
if isCornerLayout {
return onTogglePanel != nil
}
// iPad widescreen (true landscape): hidden (can't force rotation)
let isActualWidescreen = isWideScreenLayout && onTogglePanel != nil
let showFullscreenButton = !(isIPad && isActualWidescreen)

View File

@@ -43,6 +43,8 @@ struct PlayerControlsView: View {
var isPanelVisible: Bool = true
var panelSide: FloatingPanelSide = .right
var isPanelPinned: Bool = false
/// Corner layout: fullscreen button toggles the panels
var isCornerLayout: Bool = false
/// Safe area padding from layout (when panel is pinned)
var layoutLeadingSafeArea: CGFloat = 0
var layoutTrailingSafeArea: CGFloat = 0
@@ -230,6 +232,14 @@ struct PlayerControlsView: View {
cancelHideTimer()
}
}
.onChange(of: isPanelVisible) { oldValue, newValue in
// Corner layout: any panel toggle resizes the controls area —
// keep the controls hidden while that transition plays
if isCornerLayout, oldValue != newValue {
showControls = false
cancelHideTimer()
}
}
.onChange(of: playerState.retryState.exhausted) { _, exhausted in
// Hide controls when retries are exhausted (retry exhausted overlay takes over)
if exhausted {
@@ -389,6 +399,7 @@ struct PlayerControlsView: View {
isOrientationLocked: isOrientationLocked,
isPanelVisible: isPanelVisible,
isPanelPinned: isPanelPinned,
isCornerLayout: isCornerLayout,
panelSide: panelSide,
isIPad: isIPad,
showVolumeControls: showVolumeControls,
@@ -415,6 +426,10 @@ struct PlayerControlsView: View {
onTogglePiP: onTogglePiP,
onToggleFullscreen: onToggleFullscreen,
onToggleDetailsVisibility: onToggleDetailsVisibility,
onHideControls: { [self] in
showControls = false
cancelHideTimer()
},
onToggleOrientationLock: onToggleOrientationLock,
onTogglePanel: onTogglePanel,
onTogglePanscan: onTogglePanscan,
@@ -484,7 +499,11 @@ struct PlayerControlsView: View {
// 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
// Corner layout with panels visible: the controls frame ends at the
// video area mid-screen, so the window's bottom safe area (incl. the
// PiP inset) must not push the bottom bar up from the video edge
let cornerPanelsActive = isCornerLayout && isPanelVisible
let needsBottomSafeArea = (isWideScreenLayout || isFullscreen) && !floatingPanelActive && !cornerPanelsActive
let bottomSafeArea = isWideScreenLayout ? min(safeArea.bottom, 12) : safeArea.bottom
let bottomPadding: CGFloat = 2 + (needsBottomSafeArea ? bottomSafeArea : 0)

View File

@@ -18,6 +18,19 @@ struct PortraitDetailsPanel: View {
let onFullscreen: (() -> Void)?
let pillsOverlayOpacity: CGFloat
/// Corner layout host: comments live in the side column, so the pill is
/// suppressed entirely.
var hidesCommentsPill: Bool = false
/// Portrait inlines chapters/related into the info page; the corner
/// layout's below-video host turns this off because those live in the
/// side column tabs (the description stays collapsible either way).
var inlineInfoSections: Bool = true
/// Panel clip rounding — the corner layout's below-video host uses 0
/// for a plain rectangle.
var panelCornerRadius: CGFloat = 16
// Drag gesture callbacks
var onDragChanged: ((CGFloat) -> Void)?
var onDragEnded: ((CGFloat, CGFloat) -> Void)?
@@ -64,7 +77,7 @@ struct PortraitDetailsPanel: View {
playerPillSettings.effectiveCommentsPillMode
}
private var shouldShowCommentsPill: Bool {
playerPillSettings.shouldShowCommentsPill
!hidesCommentsPill && playerPillSettings.shouldShowCommentsPill
}
private var isCommentsPillAlwaysCollapsed: Bool {
playerPillSettings.isCommentsPillAlwaysCollapsed
@@ -165,7 +178,7 @@ struct PortraitDetailsPanel: View {
),
pageBarTopPadding: 14,
showPageBar: false,
inlineInfoSections: true
inlineInfoSections: inlineInfoSections
)
.padding(.top, 10)
.background(Color(.systemBackground))
@@ -220,7 +233,7 @@ struct PortraitDetailsPanel: View {
}
}
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.clipShape(RoundedRectangle(cornerRadius: panelCornerRadius))
// Pills overlay at bottom
.overlay {
if !isCommentsExpanded {