diff --git a/Yattee/Views/Player/CornerPlayerLayout.swift b/Yattee/Views/Player/CornerPlayerLayout.swift index 3baee828..809395f9 100644 --- a/Yattee/Views/Player/CornerPlayerLayout.swift +++ b/Yattee/Views/Player/CornerPlayerLayout.swift @@ -95,12 +95,42 @@ struct CornerPlayerLayout: 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 { + var types: Set = [] + 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: 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: 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: 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: View { playerControlsLayout: playerControlsLayout, onFullscreen: onFullscreen, pillsOverlayOpacity: 1, - hidesCommentsPill: true, + hidesCommentsPill: !hasBelowVideoComments, inlineInfoSections: false, panelCornerRadius: 0, onDragChanged: { offset in @@ -485,7 +520,8 @@ struct CornerPlayerLayout: 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: View { bottomInset: 16 ), pageBarTopPadding: 8, - excludedPages: [.info] + excludedPages: belowVideoPageTypes ) .background(panelBackgroundColor.ignoresSafeArea(edges: .bottom)) } diff --git a/Yattee/Views/Player/Panels/PlayerPanelContentView.swift b/Yattee/Views/Player/Panels/PlayerPanelContentView.swift index 7272e373..ec6899f2 100644 --- a/Yattee/Views/Player/Panels/PlayerPanelContentView.swift +++ b/Yattee/Views/Player/Panels/PlayerPanelContentView.swift @@ -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 } } diff --git a/Yattee/Views/Player/Panels/RelatedPanelPage.swift b/Yattee/Views/Player/Panels/RelatedPanelPage.swift index 062bf9d1..ea5dd55e 100644 --- a/Yattee/Views/Player/Panels/RelatedPanelPage.swift +++ b/Yattee/Views/Player/Panels/RelatedPanelPage.swift @@ -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")