Route corner layout pages by region and harden page selection

The below-video panel now renders only when the info page is assigned
to that region; comments assigned below become the portrait panel's
pill (iOS), and every other below-video page falls back to the side
column instead of disappearing. Page selection snaps to the first
configured page when the current one is disabled or reordered away,
and the page bar highlight follows the actually rendered page. The
related tab stays visible with a spinner while video details load
instead of vanishing until they arrive.
This commit is contained in:
Arkadiusz Fal
2026-09-23 23:52:08 +02:00
parent 4a85f7355a
commit 22451804db
3 changed files with 92 additions and 19 deletions

View File

@@ -95,12 +95,42 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
playerControlsLayout.effectivePanelSettings
}
/// Whether the info page is assigned to (and enabled in) the below-video
/// region — the below panel only exists to host it.
private var hasBelowVideoInfo: Bool {
guard let info = panelSettings.configuration(for: .info) else { return false }
return info.isEnabled && info.region == .belowVideo
}
/// Comments assigned below the video render as the portrait panel's
/// comments pill (iOS only, and only while the info panel is below —
/// the pill lives inside it). Everything else falls back to the column.
private var hasBelowVideoComments: Bool {
#if os(iOS)
guard hasBelowVideoInfo,
let comments = panelSettings.configuration(for: .comments) else { return false }
return comments.isEnabled && comments.region == .belowVideo
#else
return false
#endif
}
/// Pages the below-video host actually renders. Enabled pages assigned
/// `.belowVideo` that it can't host (chapters/related/queue/tracks, or
/// comments without the info panel) fall back to the side column instead
/// of disappearing.
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.
/// Info always renders below the video; the side column holds the rest.
private var hasSideColumn: Bool {
panelSettings.enabledPages().contains { $0.pageType != .info }
panelSettings.enabledPages().contains { !belowVideoPageTypes.contains($0.pageType) }
}
/// The info page config drives the below-video panel's element settings.
@@ -132,7 +162,7 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
let height = max(1, geometry.size.height)
let showsSideColumn = isPanelVisible && hasSideColumn
let showsBelowVideo = isPanelVisible && isBelowPanelVisible
let showsBelowVideo = isPanelVisible && isBelowPanelVisible && hasBelowVideoInfo
let effectiveColumnWidth = clampColumnWidth(columnWidth, availableWidth: width)
// Grabber sits on the column's leading edge, inside the column
@@ -229,7 +259,7 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
// reveal) via belowPanelDragOffset counting down
// from full hide travel.
.simultaneousGesture(
(isPanelVisible && !isBelowPanelVisible)
(isPanelVisible && !isBelowPanelVisible && hasBelowVideoInfo)
? DragGesture()
.onChanged { value in
guard appEnvironment?.navigationCoordinator.isAdjustingPlayerSliders != true else { return }
@@ -347,6 +377,11 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
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
@@ -376,7 +411,7 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
playerControlsLayout: playerControlsLayout,
onFullscreen: onFullscreen,
pillsOverlayOpacity: 1,
hidesCommentsPill: true,
hidesCommentsPill: !hasBelowVideoComments,
inlineInfoSections: false,
panelCornerRadius: 0,
onDragChanged: { offset in
@@ -485,7 +520,8 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
}
}
/// Side column with tabs for every enabled page except info.
/// Side column with tabs for every enabled page the below-video host
/// doesn't render.
@ViewBuilder
private func sideColumnPanel(video: Video) -> some View {
PlayerPanelContentView(
@@ -502,7 +538,7 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
bottomInset: 16
),
pageBarTopPadding: 8,
excludedPages: [.info]
excludedPages: belowVideoPageTypes
)
.background(panelBackgroundColor.ignoresSafeArea(edges: .bottom))
}

View File

@@ -54,7 +54,11 @@ struct PlayerPanelContentView: View {
return !video.isFromMediaSource
&& playerState?.commentsState != .disabled
case .related:
return !(video.relatedVideos ?? []).isEmpty
if !(video.relatedVideos ?? []).isEmpty { return true }
// Keep the tab while details are still loading (spinner in the
// page); hide only once details resolved with no related videos.
let detailsState = playerState?.videoDetailsState ?? .idle
return detailsState == .idle || detailsState == .loading
case .chapters:
return !(playerState?.chapters ?? []).isEmpty
case .queue:
@@ -76,13 +80,11 @@ struct PlayerPanelContentView: View {
}
}
private var visiblePages: [PanelPageConfiguration] {
var pages = Self.visiblePages(
panelSettings: panelSettings,
region: region,
video: context.video,
appEnvironment: appEnvironment
)
/// Enabled pages for this host in configured order, before runtime
/// visibility filtering. The selection tracks this list so a page that
/// loads late (e.g. related) still wins once its content arrives.
private var configuredPages: [PanelPageConfiguration] {
var pages = panelSettings.enabledPages(in: region)
if !excludedPages.isEmpty {
pages = pages.filter { !excludedPages.contains($0.pageType) }
}
@@ -90,6 +92,12 @@ struct PlayerPanelContentView: View {
return pages.filter { $0.pageType != .chapters && $0.pageType != .related }
}
private var visiblePages: [PanelPageConfiguration] {
configuredPages.filter {
Self.isRuntimeVisible($0.pageType, video: context.video, appEnvironment: appEnvironment)
}
}
/// The page to render: the selection when visible, else the first page.
private var effectivePage: PanelPageConfiguration? {
visiblePages.first { $0.pageType == selectedPage } ?? visiblePages.first
@@ -102,9 +110,14 @@ struct PlayerPanelContentView: View {
VStack(spacing: 0) {
if showPageBar, pages.count >= 2 {
// Highlight follows the rendered page (the selection when
// visible, else the fallback); taps still set the selection.
PanelPageBar(
pages: pages,
selectedPage: $selectedPage,
selectedPage: Binding(
get: { effectivePage?.pageType ?? selectedPage },
set: { selectedPage = $0 }
),
accentColor: context.accentColor,
showLabels: panelSettings.effectiveShowPageBarLabels
)
@@ -118,14 +131,23 @@ struct PlayerPanelContentView: View {
Spacer()
}
}
.onChange(of: configuredPages.map(\.pageType), initial: true) { _, pageTypes in
// Snap a selection that isn't configured for this host (e.g. the
// host's default page was disabled or reordered away) to the
// first configured page.
if !pageTypes.contains(selectedPage), let first = pageTypes.first {
selectedPage = first
}
}
.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 {
// New video: reset to the first configured page (not the runtime
// list — related/chapters may not have loaded yet).
if let first = configuredPages.first?.pageType {
selectedPage = first
}
}

View File

@@ -16,10 +16,19 @@ struct RelatedPanelPage: View {
private var relatedVideos: [Video] { context.video.relatedVideos ?? [] }
private var isLoadingDetails: Bool {
let state = context.appEnvironment?.playerService.state.videoDetailsState ?? .idle
return state == .idle || state == .loading
}
var body: some View {
PanelPageScrollView(config: scrollConfig) {
if relatedVideos.isEmpty {
emptyState
if isLoadingDetails {
loadingState
} else {
emptyState
}
} else {
LazyVStack(spacing: 12) {
ForEach(Array(relatedVideos.enumerated()), id: \.element.id) { index, relatedVideo in
@@ -51,6 +60,12 @@ struct RelatedPanelPage: View {
}
}
private var loadingState: some View {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
}
private var emptyState: some View {
VStack(spacing: 8) {
Image(systemName: "rectangle.stack.badge.play")