Files
yattee/Yattee/Views/Player/WideScreenPlayerLayout.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

384 lines
16 KiB
Swift

//
// WideScreenPlayerLayout.swift
// Yattee
//
// Widescreen layout with video player and floating details panel.
// Supports two modes:
// - Overlay: Panel floats over full-width player, appears with controls
// - Pinned: Player resizes to make room for panel
//
// iPhone uses the overlay mode only, always on the right. iPad/macOS default
// to pinned, can unpin with the pin button, and pick the side in the preset's
// panel settings.
//
import SwiftUI
#if os(iOS) || os(macOS)
struct WideScreenPlayerLayout<PlayerContent: View>: View {
@Environment(\.appEnvironment) private var appEnvironment
let playerControlsLayout: PlayerControlsLayout
@ViewBuilder let playerContent: (
_ onTogglePanel: @escaping () -> Void,
_ isPanelVisible: Bool,
_ isPanelPinned: Bool,
_ panelSide: FloatingPanelSide,
_ onHidePanel: @escaping () -> Void,
_ leadingSafeArea: CGFloat,
_ trailingSafeArea: CGFloat,
_ fullWidth: CGFloat,
_ fullHeight: CGFloat
) -> PlayerContent
// Callbacks for panel actions
let onChannelTap: (() -> Void)?
let onFullscreen: (() -> Void)?
@State private var controlsVisible = false
@State private var isPanelVisible = false // Local state synced with settingsManager
@State private var lastVideoId: String? // Track video ID to detect actual video changes
@State private var panelWidth: CGFloat = FloatingDetailsPanel.defaultPanelWidth
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
private var playerState: PlayerState? { appEnvironment?.playerService.state }
// Read video from playerState for reactive updates
private var video: Video? { playerState?.currentVideo }
private var panelSide: FloatingPanelSide {
if PanelSettings.isSectionsOnlyDevice { return .right }
return playerControlsLayout.effectivePanelSettings.effectiveWideLayoutSide
}
/// `landscapeDetailsPanelPinned` already resolves to `false` on iPhone.
private var isPanelPinned: Bool {
settingsManager?.landscapeDetailsPanelPinned ?? false
}
/// Base panel width including grabber and padding (without safe area)
private var basePanelWidth: CGFloat {
panelWidth + 20 + 12 // panelWidth + grabber (20pt) + outer edge padding (12pt)
}
/// Maximum panel width based on available width and pinned state
private func maxPanelWidth(availableWidth: CGFloat) -> CGFloat {
let minWidth = FloatingDetailsPanel.minPanelWidth
let minVideoWidth: CGFloat = 400
if isPanelPinned {
// Pinned: leave at least 300pt for video
return max(minWidth, availableWidth - minVideoWidth)
} else {
// Unpinned: 80% of available width, capped at 1000pt
return max(minWidth, min(availableWidth * 0.8, 1000))
}
}
/// Calculate safe area padding for panel outer edge
/// Always use full safe area - rounded corner side is already small
private func panelSafeAreaPadding(safeAreaLeft: CGFloat, safeAreaRight: CGFloat) -> CGFloat {
if panelSide == .left {
return safeAreaLeft
} else {
return safeAreaRight
}
}
/// Total panel width including safe area padding
/// Must include the same padding applied to the panel view
private func totalPanelWidth(safeAreaLeft: CGFloat, safeAreaRight: CGFloat) -> CGFloat {
basePanelWidth + panelSafeAreaPadding(safeAreaLeft: safeAreaLeft, safeAreaRight: safeAreaRight)
}
/// Whether to show the panel
private var shouldShowPanel: Bool {
isPanelVisible
}
/// Toggle panel visibility from controls
private func togglePanelFromControls() {
withAnimation(.easeInOut(duration: 0.3)) {
isPanelVisible.toggle()
settingsManager?.landscapeDetailsPanelVisible = isPanelVisible
settingsManager?.landscapeDetailsPanelLastVisible = isPanelVisible
}
}
/// Hide the panel completely
private func hidePanel() {
isPanelVisible = false
settingsManager?.landscapeDetailsPanelVisible = false
settingsManager?.landscapeDetailsPanelLastVisible = false
}
#if os(iOS)
/// The app's window, looked up from any foreground scene. Notification
/// Center and Control Center move the scene to `.foregroundInactive`, so
/// requiring `.foregroundActive` would drop the safe area to zero while
/// they are open and make the panel jump to the screen edge.
private var foregroundWindow: UIWindow? {
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let scene = scenes.first { $0.activationState == .foregroundActive }
?? scenes.first { $0.activationState == .foregroundInactive }
?? scenes.first
return scene?.windows.first { $0.isKeyWindow } ?? scene?.windows.first
}
#endif
/// Get safe area insets from window scene (geometry reader insets are 0 when .ignoresSafeArea is used)
private var windowSafeAreaInsets: EdgeInsets {
#if os(iOS)
guard let window = foregroundWindow else { return EdgeInsets() }
let insets = window.safeAreaInsets
return EdgeInsets(top: insets.top, leading: insets.left, bottom: insets.bottom, trailing: insets.right)
#else
return EdgeInsets()
#endif
}
/// Get full screen bounds from window scene (ignores safe area constraints from parent)
/// On macOS, this is not used - geometry reader size is used directly
private var windowSceneBounds: CGSize {
#if os(iOS)
guard let window = foregroundWindow else {
// Fallback to screen bounds
let screen = UIScreen.main.bounds
return CGSize(width: max(screen.width, screen.height), height: min(screen.width, screen.height))
}
// Window frame gives actual size including orientation
return window.frame.size
#else
return .zero // Not used on macOS - geometry reader size is used directly
#endif
}
var body: some View {
GeometryReader { geometry in
// On iOS: Use window bounds to get full size (avoids safe area constraint from parent)
// On macOS: Use geometry reader size directly (no safe area concerns, window is the source of truth)
#if os(iOS)
let windowBounds = windowSceneBounds
let availableWidth = windowBounds.width
let availableHeight = windowBounds.height
let safeAreaLeft = windowSafeAreaInsets.leading
let safeAreaRight = windowSafeAreaInsets.trailing
#else
let availableWidth = geometry.size.width
let availableHeight = geometry.size.height
let safeAreaLeft: CGFloat = 0
let safeAreaRight: CGFloat = 0
#endif
// Safe area strategy:
// - Left side (Dynamic Island): Always respect full safe area - content would be cut off
// - Right side (rounded corners): Can extend to edge - content still visible
// - Panel: Use half safe area on outer edge to get closer to edge while keeping content visible
// Calculate total panel width including safe area padding
let totalPanelW = totalPanelWidth(safeAreaLeft: safeAreaLeft, safeAreaRight: safeAreaRight)
// Detect which side has Dynamic Island based on interface orientation
// Note: Can't use safe area comparison because both sides have similar values (~62px each)
#if os(iOS)
let interfaceOrientation = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first?
.interfaceOrientation ?? .unknown
// landscapeLeft = home/gesture bar on left = Dynamic Island on RIGHT
// landscapeRight = home/gesture bar on right = Dynamic Island on LEFT
let isDynamicIslandOnLeft = interfaceOrientation == .landscapeRight
let isDynamicIslandOnRight = interfaceOrientation == .landscapeLeft
#else
let isDynamicIslandOnLeft = safeAreaLeft > safeAreaRight
let isDynamicIslandOnRight = safeAreaRight > safeAreaLeft
#endif
// Calculate Dynamic Island safe area on the side opposite to panel
// Video must not extend into Dynamic Island
let opposingDynamicIslandSafeArea: CGFloat = {
guard isPanelPinned && isPanelVisible else { return 0 }
if panelSide == .right && isDynamicIslandOnLeft {
return safeAreaLeft
} else if panelSide == .left && isDynamicIslandOnRight {
return safeAreaRight
}
return 0
}()
// Calculate player width based on pinned state
let _ = isPanelPinned && isPanelVisible
? availableWidth - totalPanelW - opposingDynamicIslandSafeArea
: availableWidth
ZStack {
// Black background - extends under status bar and home indicator
Color.black
.ignoresSafeArea(.all)
// Calculate safe areas to pass to player content
// When panel is pinned, the side with the panel needs space for it
// The opposite side needs space for Dynamic Island if present
let leadingSafeArea: CGFloat = {
guard isPanelPinned && isPanelVisible else { return 0 }
if panelSide == .left {
return totalPanelW
} else if isDynamicIslandOnLeft {
return safeAreaLeft
}
return 0
}()
let trailingSafeArea: CGFloat = {
guard isPanelPinned && isPanelVisible else { return 0 }
if panelSide == .right {
return totalPanelW
} else if isDynamicIslandOnRight {
return safeAreaRight
}
return 0
}()
// Player content - fills entire space, handles safe areas internally
// Pass full geometry so playerContent doesn't need its own GeometryReader
playerContent(
togglePanelFromControls,
isPanelVisible,
isPanelPinned,
panelSide,
{ withAnimation(.easeInOut(duration: 0.3)) { hidePanel() } },
leadingSafeArea,
trailingSafeArea,
availableWidth,
availableHeight
)
// Panel container
// Dynamic Island side needs full safe area, rounded corner side needs half
let panelOuterPadding = panelSafeAreaPadding(safeAreaLeft: safeAreaLeft, safeAreaRight: safeAreaRight)
// Panel - always in hierarchy, visibility controlled via opacity
panelContainer(
safeAreaPadding: panelOuterPadding,
availableWidth: availableWidth,
availableHeight: availableHeight
)
.opacity(shouldShowPanel ? 1 : 0)
.allowsHitTesting(shouldShowPanel)
}
.animation(.easeInOut(duration: 0.3), value: isPanelPinned)
.animation(.easeInOut(duration: 0.3), value: isPanelVisible)
.animation(.easeInOut(duration: 0.3), value: panelSide)
.animation(.easeInOut(duration: 0.3), value: controlsVisible)
}
.onChange(of: playerState?.controlsVisible) { _, newValue in
withAnimation(.easeInOut(duration: 0.2)) {
controlsVisible = newValue ?? false
}
}
.onChange(of: video?.id) { _, newId in
// Only reset panel state when video actually changes (not on view recreation)
guard let newId, lastVideoId != newId.videoID else { return }
lastVideoId = newId.videoID
// Reset comments state (stored in PlayerState)
playerState?.comments = []
playerState?.commentsState = .idle
playerState?.commentsContinuation = nil
// Panel state remains unchanged - don't modify visibility or expanded state
}
.onAppear {
controlsVisible = playerState?.controlsVisible ?? false
// Track initial video ID
if lastVideoId == nil {
lastVideoId = video?.id.videoID
}
// Initialize panel state from settings (respect user/system-set visibility)
isPanelVisible = settingsManager?.landscapeDetailsPanelVisible ?? false
// Don't override settingsManager values - they were already set by ExpandedPlayerSheet
// Load saved panel width from settings
if let savedWidth = settingsManager?.floatingDetailsPanelWidth, savedWidth > 0 {
panelWidth = savedWidth
}
}
.onChange(of: panelWidth) { _, newWidth in
// Persist panel width changes to settings
settingsManager?.floatingDetailsPanelWidth = newWidth
}
.onChange(of: settingsManager?.landscapeDetailsPanelVisible) { _, newValue in
// External writes (e.g. the preset's default visibility applied
// after the async layout load) must reach the local state too.
guard let newValue, newValue != isPanelVisible else { return }
withAnimation(.easeInOut(duration: 0.3)) {
isPanelVisible = newValue
}
}
.ignoresSafeArea(.all)
#if os(iOS)
.persistentSystemOverlays(.hidden)
.playerStatusBarHidden(true)
#endif
}
// MARK: - Panel Container
/// Panel container for the floating details panel
@ViewBuilder
private func panelContainer(safeAreaPadding: CGFloat, availableWidth: CGFloat, availableHeight: CGFloat) -> some View {
// Panel dimensions
let panelHeight = availableHeight - 24 // 12pt top + 12pt bottom padding
ZStack(alignment: panelSide == .right ? .topTrailing : .topLeading) {
detailsPanel(availableWidth: availableWidth)
.frame(height: panelHeight)
.padding(.top, 12)
.padding(.bottom, 12)
.padding(panelSide == .left ? .leading : .trailing, safeAreaPadding + 12)
.transition(.scale(scale: 0.9, anchor: panelSide == .right ? .topTrailing : .topLeading).combined(with: .opacity))
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: panelSide == .right ? .topTrailing : .topLeading)
}
// MARK: - Details Panel
@ViewBuilder
private func detailsPanel(availableWidth: CGFloat) -> some View {
FloatingDetailsPanel(
onPinToggle: {
if isPanelPinned {
// Unpinning - convert to floating mode (don't close)
withAnimation(.easeInOut(duration: 0.3)) {
settingsManager?.landscapeDetailsPanelPinned = false
}
} else {
// Pinning - ensure panel is visible first
isPanelVisible = true
settingsManager?.landscapeDetailsPanelVisible = true
settingsManager?.landscapeDetailsPanelLastVisible = true
// Animate panscan to zero and pin concurrently for smooth transition
appEnvironment?.navigationCoordinator.animatePanscanToZero(completion: nil)
withAnimation(.easeInOut(duration: 0.3)) {
settingsManager?.landscapeDetailsPanelPinned = true
}
}
},
isPinned: isPanelPinned,
panelSide: panelSide,
onChannelTap: onChannelTap,
onFullscreen: onFullscreen,
panelWidth: $panelWidth,
availableWidth: availableWidth,
maxPanelWidth: maxPanelWidth(availableWidth: availableWidth),
playerControlsLayout: playerControlsLayout
)
}
}
#endif