mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
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
522 lines
26 KiB
Swift
522 lines
26 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
|
|
|
|
/// 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
|
|
|
|
/// Side column width bounds.
|
|
static var minColumnWidth: CGFloat { 280 }
|
|
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
|
|
/// 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 }
|
|
|
|
private var panelSettings: PanelSettings {
|
|
playerControlsLayout.effectivePanelSettings
|
|
}
|
|
|
|
/// 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.
|
|
/// Info always renders below the video; the side column holds the rest.
|
|
private var hasSideColumn: Bool {
|
|
panelSettings.enabledPages().contains { $0.pageType != .info }
|
|
}
|
|
|
|
/// 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 {
|
|
let maxWidth = max(
|
|
Self.minColumnWidth,
|
|
min(Self.maxColumnWidthCap, availableWidth * 0.45, availableWidth - Self.minVideoColumnWidth)
|
|
)
|
|
return min(max(width, Self.minColumnWidth), maxWidth)
|
|
}
|
|
|
|
// MARK: - Body
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let width = max(1, geometry.size.width)
|
|
let height = max(1, geometry.size.height)
|
|
|
|
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 — narrow strip matching the panel drag handle look.
|
|
let grabberWidth: CGFloat = 12
|
|
let totalColumnWidth = effectiveColumnWidth + grabberWidth
|
|
|
|
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) {
|
|
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)
|
|
)
|
|
}
|
|
// 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 showsSideColumn, let video {
|
|
HStack(spacing: 0) {
|
|
PanelResizeGrabber(
|
|
panelWidth: $columnWidth,
|
|
minWidth: Self.minColumnWidth,
|
|
maxWidth: clampColumnWidth(.infinity, availableWidth: width),
|
|
panelSide: .right,
|
|
isPinned: true,
|
|
isDragActive: $isGrabberActive,
|
|
hitWidth: grabberWidth
|
|
)
|
|
|
|
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))
|
|
.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
|
|
// threshold — below it the resolver switches layouts).
|
|
columnWidth = clampColumnWidth(columnWidth, availableWidth: newWidth)
|
|
}
|
|
}
|
|
.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
|
|
}
|
|
}
|
|
.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)) {
|
|
isPanelVisible = newValue
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) -> 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,
|
|
selectedPage: $primaryPage,
|
|
context: PanelPageContext(
|
|
appEnvironment: appEnvironment,
|
|
video: video,
|
|
accentColor: accentColor,
|
|
onChannelTap: onChannelTap
|
|
),
|
|
scrollConfig: PanelScrollConfig(
|
|
topInset: 4,
|
|
bottomInset: 16
|
|
),
|
|
pageBarTopPadding: 8,
|
|
excludedPages: [.info]
|
|
)
|
|
.background(panelBackgroundColor.ignoresSafeArea(edges: .bottom))
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func togglePanel() {
|
|
withAnimation(.easeInOut(duration: 0.25)) {
|
|
isPanelVisible.toggle()
|
|
settingsManager?.landscapeDetailsPanelVisible = isPanelVisible
|
|
settingsManager?.detailsPanelLastVisible = isPanelVisible
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|