Files
yattee/Yattee/Views/Player/Panels/PanelPageContext.swift
Arkadiusz Fal 1370f68c37 Rework details panel settings: pinned info, per-orientation visibility, section defaults
Pages list
- Info is pinned first and always enabled in every layout: normalized
  pages force it to the front and enabled (older presets self-correct),
  disabling it is a no-op, and any move that would displace it snaps
  back. The iOS/macOS list renders it outside the reorderable ForEach
  (no drag handle, nothing drops above it) and the editors hide its
  enabled toggle; tvOS also hides its move buttons. The "at least one
  page must stay enabled" guard and its strings are gone since Info
  always counts.
- iPhone never renders queue or comments as pages, so its Sections list
  hides them (PanelSettings.hiddenSettingsPageTypes). Reordering goes
  through moveVisiblePages, which keeps hidden entries in their stored
  slots. Comments get their own group on the Details Panel screen
  (pill mode + avatars) and the overlay hosts pass the avatars flag.

Default visibility
- "When Player Opens" is now configured per orientation
  (portraitDefaultVisibility / landscapeDefaultVisibility). The legacy
  single value is still decoded and seeds both until set individually;
  presets that never set it default to portrait Visible, landscape
  Hidden. macOS shows one picker (no portrait layout).
- "Remember Last" no longer leaks across orientations: separate
  remembered flags, written by the portrait toggles and by the wide and
  corner layouts respectively, with the old shared flag read once as a
  fallback. The corner layout seeded from the portrait resolution
  before; it now uses landscape.

Section options (iOS inline-sections hosts)
- Chapters and Related editors gain "Expanded by Default" (on by
  default). The Info editor gains a Description group with "Expanded by
  Default" (off) and a "Collapsed Lines" stepper (1–10, default 3).
  Applied when the panel appears and on each video change.
- Expanding a collapsed section scrolls its header to the top of the
  panel: PanelPageScrollView can hand its scroll proxy to page content
  and each inline section is wrapped in one anchored container.
- The portrait panel's bottom fade sized its touch blocker from a stale
  "expanded comments pill" answer that ignored collapse on scroll, so a
  collapsed section header (the last item) sat under it and could not
  be tapped. The predicate now tracks the pill's actual collapse state.
2026-09-26 23:50:30 +02:00

196 lines
6.7 KiB
Swift

//
// PanelPageContext.swift
// Yattee
//
// Shared context and scroll plumbing for the details panel pages.
//
import SwiftUI
#if os(iOS) || os(macOS)
// MARK: - Page Context
/// Lightweight context handed to every panel page. Pages reach services via
/// `appEnvironment`; only host-specific callbacks travel explicitly.
struct PanelPageContext {
let appEnvironment: AppEnvironment?
let video: Video
let accentColor: Color
var onChannelTap: (() -> Void)?
}
// MARK: - Scroll Config
/// Scroll behavior the host wants applied to scrollable pages: content
/// insets, scroll-offset reporting (pills collapse / scroll-to-top button),
/// and the portrait overscroll-to-collapse gesture.
struct PanelScrollConfig {
/// Extra space above the content (e.g. below the drag handle).
var topInset: CGFloat = 10
/// Extra space below the content so it can scroll above pills/fades.
var bottomInset: CGFloat = 80
/// Reports the current scroll offset (0 at top, positive when scrolled).
var scrollOffsetChanged: ((CGFloat) -> Void)?
/// Toggling this scrolls the page back to the top.
var scrollToTopTrigger: Bool = false
/// Identity of the content being shown (the video). When it changes the
/// page jumps back to the top without animation so the new title is
/// visible. Set by `PlayerPanelContentView`.
var contentID: AnyHashable?
/// Disables scrolling (while the portrait drag handle is active).
var isScrollDisabled: Bool = false
/// Portrait overscroll-to-collapse drag callbacks (iOS only).
var onOverscrollDragChanged: ((CGFloat) -> Void)?
var onOverscrollDragEnded: ((CGFloat, CGFloat) -> Void)?
}
// MARK: - Horizontal Insets
/// Horizontal content insets a host wants on the panel pages. Hosts whose
/// panel already carries leading chrome (the corner side column's resize
/// grabber strip) narrow the leading inset so content sits the same distance
/// from both edges.
struct PanelHorizontalInsets: Equatable {
static let standard: CGFloat = 16
var leading: CGFloat = PanelHorizontalInsets.standard
var trailing: CGFloat = PanelHorizontalInsets.standard
/// Leading delta relative to the standard inset; List-backed pages
/// (queue, tracks) apply it as a scroll content margin since their
/// section insets can't be restyled per host.
var leadingListAdjustment: CGFloat { leading - Self.standard }
}
private struct PanelHorizontalInsetsKey: EnvironmentKey {
static let defaultValue = PanelHorizontalInsets()
}
extension EnvironmentValues {
var panelHorizontalInsets: PanelHorizontalInsets {
get { self[PanelHorizontalInsetsKey.self] }
set { self[PanelHorizontalInsetsKey.self] = newValue }
}
}
private struct PanelHorizontalPadding: ViewModifier {
@Environment(\.panelHorizontalInsets) private var insets
func body(content: Content) -> some View {
content
.padding(.leading, insets.leading)
.padding(.trailing, insets.trailing)
}
}
private struct PanelListLeadingMargin: ViewModifier {
@Environment(\.panelHorizontalInsets) private var insets
func body(content: Content) -> some View {
content
.contentMargins(.leading, insets.leadingListAdjustment, for: .scrollContent)
}
}
extension View {
/// Host-configurable replacement for `.padding(.horizontal)` on panel
/// page content.
func panelHorizontalPadding() -> some View {
modifier(PanelHorizontalPadding())
}
/// Shifts List-backed page content by the host's leading inset delta.
func panelListLeadingMargin() -> some View {
modifier(PanelListLeadingMargin())
}
}
// MARK: - Page Scroll View
/// Shared scroll container used by the scrollable panel pages. Applies the
/// host's insets, reports scroll offset, handles scroll-to-top, and attaches
/// the portrait overscroll gesture when the host provides callbacks.
struct PanelPageScrollView<Content: View>: View {
let config: PanelScrollConfig
private let content: (ScrollViewProxy) -> Content
init(config: PanelScrollConfig, @ViewBuilder content: @escaping () -> Content) {
self.config = config
self.content = { _ in content() }
}
/// Content that needs to scroll programmatically (e.g. to a section it
/// just expanded) receives the scroll proxy.
init(config: PanelScrollConfig, @ViewBuilder content: @escaping (ScrollViewProxy) -> Content) {
self.config = config
self.content = content
}
var body: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Color.clear
.frame(height: 0)
.id("panelPageTop")
.background(
GeometryReader { geo in
Color.clear
.onChange(of: geo.frame(in: .named("panelPageScroll")).origin.y) { _, newValue in
config.scrollOffsetChanged?(max(0, -newValue))
}
}
)
Color.clear
.frame(height: config.topInset)
content(proxy)
Spacer()
.frame(height: config.bottomInset)
}
}
.coordinateSpace(name: "panelPageScroll")
#if os(iOS)
.background {
if config.onOverscrollDragChanged != nil || config.onOverscrollDragEnded != nil {
OverscrollGestureView(
isEnabled: !config.isScrollDisabled,
onDragChanged: { offset in
config.onOverscrollDragChanged?(offset)
},
onDragEnded: { offset, predicted in
config.onOverscrollDragEnded?(offset, predicted)
}
)
}
}
#endif
.scrollDisabled(config.isScrollDisabled)
.onChange(of: config.scrollToTopTrigger) { _, _ in
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo("panelPageTop", anchor: .top)
}
}
.onChange(of: config.contentID) { _, _ in
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
proxy.scrollTo("panelPageTop", anchor: .top)
}
config.scrollOffsetChanged?(0)
}
}
}
}
#endif