Files
yattee/Yattee/Views/Player/Panels/PlayerPanelContentView.swift
Arkadiusz Fal 05a03723f6 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
2026-09-23 07:25:02 +02:00

167 lines
5.7 KiB
Swift

//
// PlayerPanelContentView.swift
// Yattee
//
// Unified details panel content: page bar plus the selected page. Hosted by
// the portrait panel, the floating panel, and (later) the corner layout
// regions.
//
import SwiftUI
#if os(iOS) || os(macOS)
struct PlayerPanelContentView: View {
let panelSettings: PanelSettings
/// Region filter for corner mode; `nil` shows pages from all regions.
var region: PanelRegion? = nil
@Binding var selectedPage: PanelPageType
let context: PanelPageContext
var scrollConfig: PanelScrollConfig = PanelScrollConfig()
/// Extra space above the page bar (e.g. below the portrait drag handle).
var pageBarTopPadding: CGFloat = 0
/// Hosts that provide their own page switcher (portrait bottom-row menu)
/// hide the top page bar.
var showPageBar: Bool = true
/// Prototype: the info page shows chapters and related as inline sections;
/// 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
/// Config enables a page; runtime hides pages with nothing to show.
static func isRuntimeVisible(
_ pageType: PanelPageType,
video: Video,
appEnvironment: AppEnvironment?
) -> Bool {
let playerState = appEnvironment?.playerService.state
switch pageType {
case .info:
return true
case .comments:
return !video.isFromMediaSource
&& playerState?.commentsState != .disabled
case .related:
return !(video.relatedVideos ?? []).isEmpty
case .chapters:
return !(playerState?.chapters ?? []).isEmpty
case .queue:
return appEnvironment?.settingsManager.queueEnabled ?? true
case .tracks:
return true
}
}
/// Enabled pages that currently have content, in configured order.
static func visiblePages(
panelSettings: PanelSettings,
region: PanelRegion?,
video: Video,
appEnvironment: AppEnvironment?
) -> [PanelPageConfiguration] {
panelSettings.enabledPages(in: region).filter {
isRuntimeVisible($0.pageType, video: video, appEnvironment: appEnvironment)
}
}
private var visiblePages: [PanelPageConfiguration] {
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 }
}
/// The page to render: the selection when visible, else the first page.
private var effectivePage: PanelPageConfiguration? {
visiblePages.first { $0.pageType == selectedPage } ?? visiblePages.first
}
// MARK: - Body
var body: some View {
let pages = visiblePages
VStack(spacing: 0) {
if showPageBar, pages.count >= 2 {
PanelPageBar(
pages: pages,
selectedPage: $selectedPage,
accentColor: context.accentColor,
showLabels: panelSettings.effectiveShowPageBarLabels
)
.padding(.top, pageBarTopPadding)
}
if let page = effectivePage {
pageContent(page)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
Spacer()
}
}
.onChange(of: selectedPage) { _, _ in
// Each page owns its scroll position; reset the host's offset so
// pills and the scroll-to-top button start from the top state.
scrollConfig.scrollOffsetChanged?(0)
}
.onChange(of: context.video.id) { _, _ in
// New video: fall back to the first configured page.
if let first = visiblePages.first?.pageType {
selectedPage = first
}
}
}
// MARK: - Pages
@ViewBuilder
private func pageContent(_ page: PanelPageConfiguration) -> some View {
switch page.pageType {
case .info:
InfoPanelPage(
context: context,
settings: page.infoSettings,
scrollConfig: scrollConfig,
inlineSections: inlineInfoSections
)
.id(PanelPageType.info)
case .comments:
CommentsPanelPage(context: context, settings: page.commentsSettings, scrollConfig: scrollConfig)
.id(PanelPageType.comments)
case .related:
RelatedPanelPage(context: context, settings: page.relatedSettings, scrollConfig: scrollConfig)
.id(PanelPageType.related)
case .chapters:
ChaptersPanelPage(context: context, settings: page.chaptersSettings, scrollConfig: scrollConfig)
.id(PanelPageType.chapters)
case .queue:
QueuePanelPage(context: context, settings: page.queueSettings, bottomInset: scrollConfig.bottomInset)
.id(PanelPageType.queue)
case .tracks:
TracksPanelPage(context: context, settings: page.tracksSettings, bottomInset: scrollConfig.bottomInset)
.id(PanelPageType.tracks)
}
}
}
#endif