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

527 lines
23 KiB
Swift

//
// PortraitDetailsPanel.swift
// Yattee
//
// Details panel for portrait mode with no header bar.
// Shows video info, description, comments pill, and queue pill.
//
import SwiftUI
#if os(iOS)
struct PortraitDetailsPanel: View {
@Environment(\.appEnvironment) private var appEnvironment
let onChannelTap: (() -> Void)?
let playerControlsLayout: PlayerControlsLayout
let onFullscreen: (() -> Void)?
let pillsOverlayOpacity: CGFloat
/// Corner layout host: comments live in the side column, so the pill is
/// suppressed entirely.
var hidesCommentsPill: Bool = false
/// Portrait inlines chapters/related into the info page; the corner
/// layout's below-video host turns this off because those live in the
/// side column tabs (the description stays collapsible either way).
var inlineInfoSections: Bool = true
/// Panel clip rounding — the corner layout's below-video host uses 0
/// for a plain rectangle.
var panelCornerRadius: CGFloat = 16
// Drag gesture callbacks
var onDragChanged: ((CGFloat) -> Void)?
var onDragEnded: ((CGFloat, CGFloat) -> Void)?
var onDragCancelled: (() -> Void)?
/// Asks the host to expand the panel (comments pill → comments page).
var onRequestExpand: (() -> Void)?
@GestureState private var isDraggingHandle: Bool = false
@State private var isCommentsExpanded: Bool = false
@State private var selectedPage: PanelPageType = .info
@State private var scrollOffset: CGFloat = 0
@State private var scrollToTopTrigger: Bool = false
@State private var showQueueSheet: Bool = false
@State private var showPlaylistSheet: Bool = false
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
private var accentColor: Color { settingsManager?.resolvedAccentColor ?? .accentColor }
private var playerService: PlayerService? { appEnvironment?.playerService }
private var playerState: PlayerState? { playerService?.state }
private var isPanelDragging: Bool { appEnvironment?.navigationCoordinator.isPanelDragging ?? false }
// Read video from playerState for reactive updates
private var video: Video? { playerState?.currentVideo }
// Comments helpers
private var comments: [Comment] { playerState?.comments ?? [] }
private var commentsState: CommentsLoadState { playerState?.commentsState ?? .idle }
// Queue helpers
private var queue: [QueuedVideo] { playerState?.queue ?? [] }
private var history: [QueuedVideo] { playerState?.history ?? [] }
private var isQueueEnabled: Bool { settingsManager?.queueEnabled ?? true }
// Player pill helpers
private var playerPillSettings: PlayerPillSettings {
playerControlsLayout.effectivePlayerPillSettings
}
private var shouldShowPlayerPill: Bool {
playerPillSettings.visibility.isVisible(isWideLayout: false) &&
!playerPillSettings.buttons.isEmpty
}
// Comments pill helpers (mode lives on the comments page settings)
private var commentsPillMode: CommentsPillMode {
panelSettings.commentsPillMode
}
private var shouldShowCommentsPill: Bool {
!hidesCommentsPill && commentsPillMode != .disabled
}
private var isCommentsPillAlwaysCollapsed: Bool {
commentsPillMode.alwaysCollapsed
}
private var hasVideoDescription: Bool {
!(video?.description ?? "").isEmpty
}
// Panel pages configuration
private var panelSettings: PanelSettings {
playerControlsLayout.effectivePanelSettings
}
/// Prototype: portrait has a single info page with inline sections, so the
/// comments pill always opens the comments overlay, never a page.
private var isCommentsPageAvailable: Bool {
false
}
/// Whether any pills are currently visible in the overlay
private var hasPillsVisible: Bool {
let hasCommentsPill = commentsState == .loaded && !comments.isEmpty && shouldShowCommentsPill
return shouldShowPlayerPill || hasCommentsPill
}
/// Whether comments pill is shown expanded on its own row (above player pill)
/// Whether the comments pill currently occupies its own row above the
/// player pill. Must track the pill's actual collapse state (including
/// collapse on scroll): the bottom fade sizes its touch blocker from
/// this, and a stale "expanded" answer swallows taps on the last rows
/// of content, e.g. a collapsed section header.
private var hasExpandedCommentsPill: Bool {
let hasCommentsPill = commentsState == .loaded && !comments.isEmpty && shouldShowCommentsPill
let isPillCollapsed = scrollOffset > 20 || isCommentsPillAlwaysCollapsed || !hasVideoDescription
return hasCommentsPill && shouldShowPlayerPill && !isPillCollapsed
}
// MARK: - Drag Handle
private var dragHandle: some View {
Capsule()
.fill(Color.secondary.opacity(0.4))
.frame(width: 36, height: 4)
.padding(.top, 5)
.padding(.bottom, 2)
}
// MARK: - Bottom Fade Overlay
@ViewBuilder
private var bottomFadeOverlay: some View {
// Fade height: tall when comments expanded on own row, medium for single-row pills, short when no pills
// When expanded: 24pt padding + 52pt player pill + 12pt spacing + ~26pt (half comments pill) ≈ 115pt
let fadeHeight: CGFloat = hasExpandedCommentsPill ? 115 : (hasPillsVisible ? 70 : 25)
ZStack {
// Touch-blocking layer (invisible but intercepts touches)
Color.white.opacity(0.001)
.frame(height: fadeHeight)
.contentShape(Rectangle())
// Visual gradient
LinearGradient(
colors: [.clear, Color(.systemBackground)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: fadeHeight)
.allowsHitTesting(false)
}
.frame(height: fadeHeight)
.animation(.easeInOut(duration: 0.2), value: hasExpandedCommentsPill)
.animation(.easeInOut(duration: 0.2), value: hasPillsVisible)
}
var body: some View {
if let video {
ZStack(alignment: .top) {
// Unified page content (page bar + selected page)
PlayerPanelContentView(
panelSettings: panelSettings,
selectedPage: $selectedPage,
context: PanelPageContext(
appEnvironment: appEnvironment,
video: video,
accentColor: accentColor,
onChannelTap: onChannelTap
),
scrollConfig: PanelScrollConfig(
topInset: 10,
bottomInset: hasVideoDescription ? 80 : 50,
scrollOffsetChanged: { offset in
scrollOffset = offset
},
scrollToTopTrigger: scrollToTopTrigger,
isScrollDisabled: isDraggingHandle,
onOverscrollDragChanged: { offset in
onDragChanged?(offset)
},
onOverscrollDragEnded: { offset, predicted in
onDragEnded?(offset, predicted)
}
),
pageBarTopPadding: 14,
showPageBar: false,
inlineInfoSections: inlineInfoSections
)
.background(Color(.systemBackground))
.overlay(alignment: .bottom) {
bottomFadeOverlay
}
// Extended drag hit area overlay - visual handle at top, hit area
// extends down over the title (the portrait panel has no page bar)
VStack(spacing: 0) {
dragHandle
Spacer()
}
.frame(height: 50)
.frame(maxWidth: .infinity)
.background(alignment: .top) {
LinearGradient(
colors: [Color(.systemBackground), Color(.systemBackground).opacity(0)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 25)
.allowsHitTesting(false)
}
.contentShape(Rectangle())
.highPriorityGesture(
DragGesture(coordinateSpace: .global)
.updating($isDraggingHandle) { _, state, _ in
state = true
}
.onChanged { value in
// Allow both upward (negative) and downward (positive) drag
let offset = value.translation.height
onDragChanged?(offset)
}
.onEnded { value in
onDragEnded?(value.translation.height, value.predictedEndTranslation.height)
}
)
.onChange(of: isDraggingHandle) { oldValue, newValue in
// When gesture state resets (true -> false) but onEnded wasn't called,
// the gesture was cancelled (app backgrounded, control center, etc.)
if oldValue && !newValue {
// Small delay to let onEnded fire first if it will
DispatchQueue.main.async {
// If still dragging according to parent, it was a cancellation
if appEnvironment?.navigationCoordinator.isPanelDragging == true {
onDragCancelled?()
}
}
}
}
}
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: panelCornerRadius))
// Pills overlay at bottom
.overlay {
if !isCommentsExpanded {
pillsOverlay
.opacity(pillsOverlayOpacity)
.allowsHitTesting(pillsOverlayOpacity > 0.01)
}
}
// Expanded comments overlay (only when the comments page is not
// available; the pill then falls back to the overlay path)
.overlay {
if !isCommentsPageAvailable {
expandedCommentsOverlay(video: video)
}
}
.animation(.smooth(duration: 0.3), value: isCommentsExpanded)
.onChange(of: video.id) { _, _ in
isCommentsExpanded = false
}
.onChange(of: isCommentsExpanded) { _, newValue in
appEnvironment?.navigationCoordinator.isCommentsExpanded = newValue
}
.sheet(isPresented: $showQueueSheet) {
QueueManagementSheet()
}
.sheet(isPresented: $showPlaylistSheet) {
if let currentVideo = playerState?.currentVideo {
PlaylistSelectorSheet(video: currentVideo)
}
}
}
}
@ViewBuilder
private func expandedCommentsOverlay(video: Video) -> some View {
let expanded = isCommentsExpanded
ExpandedCommentsView(
videoID: video.id.videoID,
onClose: collapseComments,
onDismissGestureEnded: handleCommentsDismissGestureEnded,
onDragChanged: onDragChanged,
onDragEnded: onDragEnded,
showAvatars: panelSettings.commentsShowAvatars
)
.clipShape(RoundedRectangle(cornerRadius: 16))
.visualEffect { content, proxy in
content.offset(y: expanded ? 0 : proxy.size.height)
}
.opacity(expanded ? 1 : 0)
.allowsHitTesting(expanded)
.background(
GeometryReader { commentsGeometry in
Color.clear
.onChange(of: commentsGeometry.frame(in: .global)) { _, newFrame in
if isCommentsExpanded {
appEnvironment?.navigationCoordinator.commentsFrame = newFrame
}
}
.onChange(of: isCommentsExpanded) { _, expanded in
if expanded {
appEnvironment?.navigationCoordinator.commentsFrame = commentsGeometry.frame(in: .global)
} else {
appEnvironment?.navigationCoordinator.commentsFrame = .zero
}
}
}
)
}
// MARK: - Pills Overlay
@ViewBuilder
private var pillsOverlay: some View {
GeometryReader { geometry in
let isScrolled = scrollOffset > 20
let hasCommentsPill = commentsState == .loaded && !comments.isEmpty && shouldShowCommentsPill
let isPlaying = playerState?.playbackState == .playing
let hasNext = playerState?.hasNext ?? false
// On narrow devices, use smaller side buttons so the player pill gets more horizontal space
let isCompactPillRow = geometry.size.width <= 390
let collapsedCommentWidth: CGFloat = isCompactPillRow ? 40 : 52
let scrollButtonWidth: CGFloat = isCompactPillRow ? 40 : 52
let pillRowSpacing: CGFloat = isCompactPillRow ? 8 : 12
VStack {
Spacer()
VStack(spacing: 12) {
// Comments pill - on its own row (only when player pill exists AND not collapsed)
let isPillCollapsed = isScrolled || isCommentsPillAlwaysCollapsed || !hasVideoDescription
if hasCommentsPill, let firstComment = comments.first, shouldShowPlayerPill, !isPillCollapsed {
CommentsPillView(
comment: firstComment,
isCollapsed: false,
showAvatar: panelSettings.commentsShowAvatars,
onTap: expandComments
)
}
// Bottom row
bottomPillsRow(
hasCommentsPill: hasCommentsPill,
collapsedCommentWidth: collapsedCommentWidth,
pillRowSpacing: pillRowSpacing,
isCompact: isCompactPillRow,
isPlaying: isPlaying,
hasNext: hasNext,
scrollButtonWidth: scrollButtonWidth,
availableWidth: geometry.size.width,
isScrolled: isScrolled
)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 16)
.padding(.bottom, 24)
}
// Only animate when comments load or player pill changes, not on panel size changes
// Suppress animations during panel drag to prevent independent animation on release
.animation(isPanelDragging ? nil : .spring(response: 0.35, dampingFraction: 0.8), value: hasCommentsPill)
.animation(isPanelDragging ? nil : .spring(response: 0.3, dampingFraction: 0.7), value: shouldShowPlayerPill)
.animation(isPanelDragging ? nil : .easeInOut(duration: 0.2), value: isScrolled)
}
}
/// Bottom row of pills (comments, player pill, scroll button) - extracted to help compiler
@ViewBuilder
private func bottomPillsRow(
hasCommentsPill: Bool,
collapsedCommentWidth: CGFloat,
pillRowSpacing: CGFloat,
isCompact: Bool,
isPlaying: Bool,
hasNext: Bool,
scrollButtonWidth: CGFloat,
availableWidth: CGFloat,
isScrolled: Bool
) -> some View {
// Calculate max pill width: available width minus placeholders and spacing
// Layout: [16 padding] [left] [spacing] [PILL] [spacing] [right] [16 padding]
let edgeElementsWidth: CGFloat = 32 + collapsedCommentWidth + scrollButtonWidth + (pillRowSpacing * 2)
let maxPillWidth = max(availableWidth - edgeElementsWidth, 150)
HStack(spacing: pillRowSpacing) {
// Left side: comments pill or placeholder
let isPillCollapsed = isScrolled || isCommentsPillAlwaysCollapsed || !hasVideoDescription
if hasCommentsPill, let firstComment = comments.first {
if !shouldShowPlayerPill {
// No player pill: show comments in bottom row (respect collapse mode)
CommentsPillView(
comment: firstComment,
isCollapsed: isPillCollapsed,
fillWidth: !isPillCollapsed,
compact: isCompact && isPillCollapsed,
showAvatar: panelSettings.commentsShowAvatars,
onTap: expandComments
)
.frame(maxWidth: isPillCollapsed ? nil : 400)
.frame(maxWidth: .infinity, alignment: .leading)
} else if isPillCollapsed {
// Player pill exists AND collapsed: show comments button on left
CommentsPillView(
comment: firstComment,
isCollapsed: true,
compact: isCompact,
showAvatar: panelSettings.commentsShowAvatars,
onTap: expandComments
)
} else {
// Placeholder (expanded comments on its own row)
Color.clear.frame(width: collapsedCommentWidth, height: collapsedCommentWidth)
}
} else if shouldShowPlayerPill {
Color.clear.frame(width: collapsedCommentWidth, height: collapsedCommentWidth)
}
// Player pill
if shouldShowPlayerPill {
PlayerPillView(
settings: playerPillSettings,
maxWidth: maxPillWidth,
isWideLayout: false,
isPlaying: isPlaying,
hasNext: hasNext,
queueCount: queue.count,
queueModeIcon: playerState?.queueMode.icon ?? "list.bullet",
isPlayPauseDisabled: playerState?.playbackState == .loading,
isOrientationLocked: appEnvironment?.settingsManager.inAppOrientationLock ?? false,
video: video,
playbackRate: playerState?.rate ?? .x1,
showingPlaylistSheet: $showPlaylistSheet,
onQueueTap: { showQueueSheet = true },
onPrevious: { Task { await playerService?.playPrevious() } },
onPlayPause: { playerService?.togglePlayPause() },
onNext: { Task { await playerService?.playNext() } },
onSeek: { signedSeconds in
if signedSeconds >= 0 {
playerService?.seekForward(by: signedSeconds)
} else {
playerService?.seekBackward(by: -signedSeconds)
}
},
onClose: {
appEnvironment?.queueManager.clearQueue()
playerService?.stop()
appEnvironment?.navigationCoordinator.isPlayerExpanded = false
},
onAirPlay: { /* AirPlay - handled by system */ },
onPiP: {
#if os(iOS)
if let mpvBackend = playerService?.currentBackend as? MPVBackend {
mpvBackend.togglePiP()
}
#endif
},
onOrientationLock: {
#if os(iOS)
appEnvironment?.settingsManager.inAppOrientationLock.toggle()
#endif
},
onFullscreen: { onFullscreen?() },
onRateChanged: { rate in
playerState?.rate = rate
playerService?.currentBackend?.rate = Float(rate.rawValue)
}
)
}
// Spacer to push scroll button trailing when no player pill
if !shouldShowPlayerPill {
Spacer()
}
// Scroll button or placeholder
if scrollOffset > 20 {
Button {
scrollToTopTrigger.toggle()
} label: {
Image(systemName: "arrow.up")
.font(.system(size: isCompact ? 16 : 18, weight: .semibold))
.foregroundStyle(.primary)
.frame(width: isCompact ? 28 : 32, height: isCompact ? 28 : 32)
.padding(isCompact ? 6 : 10)
.contentShape(Circle())
}
.buttonStyle(.plain)
.glassBackground(.regular, in: .circle, fallback: .thinMaterial)
.shadow(color: .black.opacity(0.1), radius: 8, y: 2)
.contentShape(Circle())
.transition(.scale.combined(with: .opacity))
} else if shouldShowPlayerPill {
Color.clear.frame(width: scrollButtonWidth, height: scrollButtonWidth)
}
}
}
private func expandComments() {
if isCommentsPageAvailable {
// Route to the comments page and ask the host to expand the panel
withAnimation(.smooth(duration: 0.3)) {
selectedPage = .comments
}
onRequestExpand?()
return
}
withAnimation(.smooth(duration: 0.3)) {
isCommentsExpanded = true
}
}
private func collapseComments() {
appEnvironment?.navigationCoordinator.commentsFrame = .zero
withAnimation(.smooth(duration: 0.3)) {
isCommentsExpanded = false
}
}
private func handleCommentsDismissGestureEnded(_ finalOffset: CGFloat) {
let dismissThreshold: CGFloat = 30
if finalOffset >= dismissThreshold {
collapseComments()
}
}
}
#endif