mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 13:42:54 +00:00
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.
196 lines
6.7 KiB
Swift
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
|