Files
yattee/Yattee/Views/Player/FloatingDetailsPanel.swift
Arkadiusz Fal 9387bdf42c Present video action sheets in the system color scheme from the floating panel
The unpinned floating details panel forces a dark color scheme on its
content so it reads over the video. The play now / play next and resume
sheets opened from a related video inherited that scheme, giving white
text on the light system sheet background.

The panel now publishes the real system scheme via a
presentationColorScheme environment value and the sheets reapply it.
2026-09-25 23:53:31 +02:00

524 lines
19 KiB
Swift

//
// FloatingDetailsPanel.swift
// Yattee
//
// Floating panel showing video details in widescreen layout.
//
import SwiftUI
#if os(iOS) || os(macOS)
// MARK: - Panel Height Preference Key
private struct PanelHeightKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct FloatingDetailsPanel: View {
@Environment(\.appEnvironment) private var appEnvironment
@Environment(\.colorScheme) private var systemColorScheme
let onPinToggle: () -> Void
let onAlignmentToggle: () -> Void
let isPinned: Bool
let panelSide: FloatingPanelSide
let onChannelTap: (() -> Void)?
let onFullscreen: (() -> Void)?
// Resizable width parameters
@Binding var panelWidth: CGFloat
let availableWidth: CGFloat
let maxPanelWidth: CGFloat
// Player controls layout for pill settings
let playerControlsLayout: PlayerControlsLayout
@State private var isCommentsExpanded: Bool = false
@State private var selectedPage: PanelPageType = .info
@State private var scrollOffset: CGFloat = 0
@State private var panelHeight: CGFloat = 0
@State private var scrollToTopTrigger: Bool = false
@State private var showQueueSheet: Bool = false
@State private var showPlaylistSheet: Bool = false
@State private var isDragHandleActive: 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 }
// 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 ?? [] }
// Player pill helpers
private var playerPillSettings: PlayerPillSettings {
playerControlsLayout.effectivePlayerPillSettings
}
private var shouldShowPlayerPill: Bool {
playerPillSettings.visibility.isVisible(isWideLayout: true) &&
!playerPillSettings.buttons.isEmpty
}
// Comments pill helpers (mode lives on the comments page settings)
private var commentsPillMode: CommentsPillMode {
panelSettings.commentsPillMode
}
private var shouldShowCommentsPill: Bool {
commentsPillMode != .disabled
}
private var isCommentsPillAlwaysCollapsed: Bool {
commentsPillMode.alwaysCollapsed
}
private var hasVideoDescription: Bool {
!(video?.description ?? "").isEmpty
}
// Panel pages configuration
private var panelSettings: PanelSettings {
playerControlsLayout.effectivePanelSettings
}
/// Whether the comments pill should route to the comments page instead of
/// the overlay.
private var isCommentsPageAvailable: Bool {
panelSettings.isPageEnabled(.comments) && !(video?.isFromMediaSource ?? true)
}
/// Whether any pills are currently visible
private var hasPillsVisible: Bool {
let hasCommentsPill = commentsState == .loaded && !comments.isEmpty && shouldShowCommentsPill
return shouldShowPlayerPill || hasCommentsPill
}
/// Whether the expanded comments pill is shown on its own row above the player pill
private var hasExpandedCommentsPill: Bool {
let hasCommentsPill = commentsState == .loaded && !comments.isEmpty && shouldShowCommentsPill
return hasCommentsPill && shouldShowPlayerPill && !isCommentsPillAlwaysCollapsed && hasVideoDescription
}
/// Minimum panel width
static let minPanelWidth: CGFloat = 400
/// Default panel width
static let defaultPanelWidth: CGFloat = 400
/// Clamp width within valid bounds
private func clampWidth(_ width: CGFloat) -> CGFloat {
min(max(width, Self.minPanelWidth), maxPanelWidth)
}
var body: some View {
if let video {
mainPanelLayout(video: video)
}
}
// MARK: - Panel Layout Components
@ViewBuilder
private func mainPanelLayout(video: Video) -> some View {
HStack(spacing: 0) {
// Resize grabber on left when panel is on right side
if panelSide == .right {
grabberWithPinButton
}
// Main panel content
panelContent(video: video)
// Resize grabber on right when panel is on left side
if panelSide == .left {
grabberWithPinButton
}
}
.environment(\.colorScheme, isPinned ? systemColorScheme : .dark)
.animation(.easeInOut(duration: 0.25), value: isPinned)
.onChange(of: video.id) { _, _ in
// Comments state is managed by parent - just reset local state
isCommentsExpanded = false
}
.onChange(of: isCommentsExpanded) { _, newValue in
// Sync with NavigationCoordinator to block sheet dismiss gesture when comments expanded
appEnvironment?.navigationCoordinator.isCommentsExpanded = newValue
.environment(\.presentationColorScheme, systemColorScheme)
}
.onChange(of: maxPanelWidth) { _, _ in
// Re-clamp width when max width changes (e.g., pinned vs floating)
panelWidth = clampWidth(panelWidth)
}
.onChange(of: availableWidth) { _, _ in
// Re-clamp width when available width changes (e.g., rotation)
panelWidth = clampWidth(panelWidth)
}
.sheet(isPresented: $showQueueSheet) {
QueueManagementSheet()
}
.sheet(isPresented: $showPlaylistSheet) {
if let currentVideo = playerState?.currentVideo {
PlaylistSelectorSheet(video: currentVideo)
}
}
}
@ViewBuilder
private var grabberWithPinButton: some View {
PanelResizeGrabber(
panelWidth: $panelWidth,
minWidth: Self.minPanelWidth,
maxWidth: maxPanelWidth,
panelSide: panelSide,
isPinned: isPinned,
isDragActive: $isDragHandleActive
)
.overlay(alignment: .center) {
PanelPinButton(
isPinned: isPinned,
panelSide: panelSide,
onPinToggle: onPinToggle,
isDragHandleActive: $isDragHandleActive
)
.offset(y: -65)
.zIndex(10) // Ensure pin button appears above panel content
}
.overlay(alignment: .center) {
PanelAlignmentButton(
panelSide: panelSide,
onAlignmentToggle: onAlignmentToggle,
isDragHandleActive: $isDragHandleActive
)
.offset(y: 65)
.zIndex(10)
}
.zIndex(10) // Ensure grabber overlay appears above panel
}
@ViewBuilder
private func panelContent(video: Video) -> some View {
VStack(spacing: 0) {
// 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: 8,
bottomInset: 80,
scrollOffsetChanged: { offset in
scrollOffset = offset
},
scrollToTopTrigger: scrollToTopTrigger
),
pageBarTopPadding: 12
)
}
.frame(width: clampWidth(panelWidth))
.background { panelBackground }
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(alignment: .top) {
topFadeOverlay
}
.overlay(alignment: .bottom) {
bottomFadeOverlay
}
.background {
GeometryReader { geometry in
Color.clear
.preference(key: PanelHeightKey.self, value: geometry.size.height)
}
}
.onPreferenceChange(PanelHeightKey.self) { height in
panelHeight = height
}
.overlay(alignment: .bottom) {
bottomOverlay(video: video)
}
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: commentsState)
.overlay {
if !isCommentsPageAvailable {
expandedCommentsOverlay(video: video)
}
}
.animation(.smooth(duration: 0.3), value: isCommentsExpanded)
}
@ViewBuilder
private var panelBackground: some View {
if isPinned {
#if os(iOS)
Color(uiColor: .systemBackground)
#else
Color(nsColor: .windowBackgroundColor)
#endif
} else {
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
}
}
/// Background color for fades - adapts to pinned/floating state
private var fadeBackgroundColor: Color {
if isPinned {
#if os(iOS)
return Color(.systemBackground)
#else
return Color(nsColor: .windowBackgroundColor)
#endif
} else {
// Use subtle dark color that blends with material background
return Color.black.opacity(0.4)
}
}
/// Top fade for wide layout (like portrait drag handle style)
@ViewBuilder
private var topFadeOverlay: some View {
LinearGradient(
colors: [fadeBackgroundColor, fadeBackgroundColor.opacity(0)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 25)
.clipShape(UnevenRoundedRectangle(topLeadingRadius: 16, bottomLeadingRadius: 0, bottomTrailingRadius: 0, topTrailingRadius: 16))
.allowsHitTesting(false)
}
/// Bottom fade with touch blocking
@ViewBuilder
private var bottomFadeOverlay: some View {
// Wide layout: comments pill is always in the same row (collapsed when player pill exists)
// 12pt padding + 52pt pill + ~26pt (half pill) ≈ 90pt when pills visible
let fadeHeight: CGFloat = hasExpandedCommentsPill ? 115 : (hasPillsVisible ? 90 : 25)
ZStack {
// Touch-blocking layer
Color.white.opacity(0.001)
.frame(height: fadeHeight)
.contentShape(Rectangle())
// Visual gradient
LinearGradient(
colors: [.clear, fadeBackgroundColor],
startPoint: .top,
endPoint: .bottom
)
.frame(height: fadeHeight)
.allowsHitTesting(false)
}
.frame(height: fadeHeight)
.clipShape(UnevenRoundedRectangle(topLeadingRadius: 0, bottomLeadingRadius: 16, bottomTrailingRadius: 16, topTrailingRadius: 0))
.animation(.easeInOut(duration: 0.2), value: hasPillsVisible)
.animation(.easeInOut(duration: 0.2), value: hasExpandedCommentsPill)
}
@ViewBuilder
private func bottomOverlay(video: Video) -> some View {
if !isCommentsExpanded {
let availablePanelHeight = panelHeight
let hasEnoughSpace = availablePanelHeight == 0 || availablePanelHeight >= 400
let isPillCollapsed = scrollOffset > 20 || !hasEnoughSpace || isCommentsPillAlwaysCollapsed || !hasVideoDescription
let showScrollButton = scrollOffset > 20
let hasCommentsPill = commentsState == .loaded && !comments.isEmpty && shouldShowCommentsPill
let isPlaying = playerState?.playbackState == .playing
let hasNext = playerState?.hasNext ?? false
VStack(spacing: 12) {
// Expanded comments pill on its own row (when player pill exists and pill not collapsed)
if hasCommentsPill, let firstComment = comments.first, shouldShowPlayerPill, !isPillCollapsed {
CommentsPillView(comment: firstComment, isCollapsed: false, onTap: expandComments)
}
ZStack {
if shouldShowPlayerPill {
playerPillContent(video: video, isPlaying: isPlaying, hasNext: hasNext)
}
edgeButtonsContent(hasCommentsPill: hasCommentsPill, isPillCollapsed: isPillCollapsed, showScrollButton: showScrollButton)
}
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isPillCollapsed)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: shouldShowPlayerPill)
.animation(.easeInOut(duration: 0.2), value: showScrollButton)
}
}
@ViewBuilder
private func playerPillContent(video: Video, isPlaying: Bool, hasNext: Bool) -> some View {
let edgeButtonsWidth: CGFloat = 130
let maxPillWidth = max(clampWidth(panelWidth) - edgeButtonsWidth, 200)
HStack {
Spacer()
PlayerPillView(
settings: playerPillSettings,
maxWidth: maxPillWidth,
isWideLayout: true,
isPlaying: isPlaying,
hasNext: hasNext,
queueCount: queue.count,
queueModeIcon: playerState?.queueMode.icon ?? "list.bullet",
isPlayPauseDisabled: playerState?.playbackState == .loading,
isOrientationLocked: {
#if os(iOS)
return appEnvironment?.settingsManager.inAppOrientationLock ?? false
#else
return false
#endif
}(),
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()
}
}
@ViewBuilder
private func edgeButtonsContent(hasCommentsPill: Bool, isPillCollapsed: Bool, showScrollButton: Bool) -> some View {
HStack {
if hasCommentsPill, let firstComment = comments.first {
if !shouldShowPlayerPill {
CommentsPillView(comment: firstComment, isCollapsed: isPillCollapsed, onTap: expandComments)
} else if isPillCollapsed {
CommentsPillView(comment: firstComment, isCollapsed: true, onTap: expandComments)
}
// else: expanded pill is on its own row above — nothing here
}
Spacer()
if showScrollButton {
Button {
scrollToTopTrigger.toggle()
} label: {
Image(systemName: "arrow.up")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.primary)
.frame(width: 32, height: 32)
.padding(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))
}
}
}
@ViewBuilder
private func expandedCommentsOverlay(video: Video) -> some View {
let expanded = isCommentsExpanded
ExpandedCommentsView(
videoID: video.id.videoID,
onClose: collapseComments,
onDismissGestureEnded: handleCommentsDismissGestureEnded
)
.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
}
}
}
)
}
private func expandComments() {
if isCommentsPageAvailable {
// Route to the comments page instead of the overlay
withAnimation(.smooth(duration: 0.3)) {
selectedPage = .comments
}
return
}
// Use same animation as player sheet expand (0.3s, no bounce)
withAnimation(.smooth(duration: 0.3)) {
isCommentsExpanded = true
}
}
private func collapseComments() {
appEnvironment?.navigationCoordinator.commentsFrame = .zero
// Use same animation as player sheet dismiss (0.3s, no bounce)
withAnimation(.smooth(duration: 0.3)) {
isCommentsExpanded = false
}
}
private func handleCommentsDismissGestureEnded(_ finalOffset: CGFloat) {
let dismissThreshold: CGFloat = 30
if finalOffset >= dismissThreshold {
collapseComments()
}
// Below threshold - scroll view will rubber-band back naturally
}
}
#endif