mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +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.
384 lines
16 KiB
Swift
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
|