Files
yattee/Yattee/Views/Player/Panels/PanelPageContext.swift
Arkadiusz Fal 5af052b428 Jump panel pages to the top and clear stale chapters when the video changes
Tapping a related video kept the panel scrolled where it was, so the new
title was out of view, and the chapters section kept showing the previous
video's chapters until the new ones resolved.

The scroll config now carries the video identity and the shared page
scroll view scrolls to the top (without animation, resetting the reported
offset) whenever it changes. PlayerState.setCurrentVideo clears chapters
on a video id change, alongside comments and storyboards.
2026-09-25 21:44:43 +02:00

184 lines
6.2 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
@ViewBuilder let 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
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