Files
yattee/Yattee/Views/Player/FloatingDetailsPanel.swift
Arkadiusz Fal 6c30e745d9 Fix player dismiss gesture stuck after panel dismiss with comments expanded
Reset isCommentsExpanded and commentsFrame on the NavigationCoordinator
directly when the portrait panel is dismissed, since PortraitDetailsPanel
owns its own @State that doesn't sync back through .onChange during dismiss.
Also track comments overlay frame via GeometryReader so the dismiss gesture
can allow swipes outside the comments area instead of blanket-blocking.
2026-02-12 04:42:32 +01:00

720 lines
26 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 showFormattedDate = false
@State private var showOriginalTitle = false
@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?.accentColor.color ?? .accentColor }
private var playerService: PlayerService? { appEnvironment?.playerService }
private var playerState: PlayerState? { playerService?.state }
// Read video and dislikeCount from playerState for reactive updates
private var video: Video? { playerState?.currentVideo }
private var dislikeCount: Int? { playerState?.dislikeCount }
// Comments helpers
private var comments: [Comment] { playerState?.comments ?? [] }
private var commentsState: CommentsLoadState { playerState?.commentsState ?? .idle }
// Video details helpers
private var videoDetailsState: VideoDetailsLoadState { playerState?.videoDetailsState ?? .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
private var commentsPillMode: CommentsPillMode {
playerPillSettings.effectiveCommentsPillMode
}
private var shouldShowCommentsPill: Bool {
playerPillSettings.shouldShowCommentsPill
}
private var isCommentsPillAlwaysCollapsed: Bool {
playerPillSettings.isCommentsPillAlwaysCollapsed
}
private var hasVideoDescription: Bool {
!(video?.description ?? "").isEmpty
}
/// 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
}
/// Returns the first enabled Yattee Server instance URL, if any.
private var yatteeServerURL: URL? {
appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url
}
/// Returns the DeArrow title if available and enabled.
private func deArrowTitle(for video: Video) -> String? {
appEnvironment?.deArrowBrandingProvider.title(for: video)
}
/// Returns the display title based on toggle state.
private func displayTitle(for video: Video) -> String {
if let deArrow = deArrowTitle(for: video) {
return showOriginalTitle ? video.title : deArrow
}
return video.title
}
/// Whether the title can be toggled (DeArrow title is available).
private func canToggleTitle(for video: Video) -> Bool {
deArrowTitle(for: video) != nil
}
/// 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
showOriginalTitle = false
isCommentsExpanded = false
}
.onChange(of: isCommentsExpanded) { _, newValue in
// Sync with NavigationCoordinator to block sheet dismiss gesture when comments expanded
appEnvironment?.navigationCoordinator.isCommentsExpanded = newValue
}
.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) {
// Scrollable content
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 0) {
// Scroll offset tracker at top
Color.clear
.frame(height: 0)
.id("panelTop")
.background(
GeometryReader { geo in
Color.clear
.onChange(of: geo.frame(in: .named("panelScroll")).origin.y) { _, newValue in
scrollOffset = -newValue
}
}
)
videoInfo(video)
Divider()
.padding(.horizontal)
infoTabSection(video)
}
}
.coordinateSpace(name: "panelScroll")
.onChange(of: scrollToTopTrigger) { _, _ in
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo("panelTop", anchor: .top)
}
}
}
}
.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 { 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
}
}
}
)
}
// MARK: - Video Info
private func videoInfo(_ video: Video) -> some View {
VStack(alignment: .leading, spacing: 8) {
// Title - full width
Text(displayTitle(for: video))
.font(.title3)
.fontWeight(.semibold)
.lineLimit(3)
.onTapGesture {
if canToggleTitle(for: video) {
showOriginalTitle.toggle()
}
}
// Stats row - only show for non-media-source videos
if !video.isFromMediaSource {
VideoStatsRow(
playerState: playerState,
showFormattedDate: $showFormattedDate,
returnYouTubeDislikeEnabled: settingsManager?.returnYouTubeDislikeEnabled ?? false
)
}
// Channel row with context menu
VideoChannelRow(
author: video.author,
source: video.id.source,
yatteeServerURL: yatteeServerURL,
onChannelTap: onChannelTap,
video: video,
accentColor: accentColor,
showSubscriberCount: !video.isFromMediaSource,
isLoadingDetails: playerState?.videoDetailsState == .loading
)
}
.padding()
}
// MARK: - Info Tab Section
private func infoTabSection(_ video: Video) -> some View {
VStack(alignment: .leading, spacing: 12) {
// Description only (no picker)
descriptionContent(video.description ?? "")
// Extra space at bottom so content can scroll above the comments pill
Spacer()
.frame(height: 80)
}
.padding(.vertical)
}
private func expandComments() {
// 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
}
private func descriptionContent(_ description: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
if !description.isEmpty {
Text(DescriptionText.attributed(description, linkColor: accentColor))
.font(.subheadline)
.foregroundStyle(.secondary)
.tint(accentColor)
.padding(.horizontal)
.handleTimestampLinks(using: playerService)
} else if videoDetailsState == .loading {
HStack {
Spacer()
ProgressView()
Spacer()
}
.padding()
} else {
Text(String(localized: "player.noDescription"))
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.horizontal)
}
}
}
}
// MARK: - Panel Resize Grabber
/// A draggable handle for resizing the panel width.
private struct PanelResizeGrabber: View {
@Binding var panelWidth: CGFloat
let minWidth: CGFloat
let maxWidth: CGFloat
let panelSide: FloatingPanelSide
let isPinned: Bool
@Binding var isDragActive: Bool
@State private var isDragging = false
@State private var dragStartWidth: CGFloat? = nil
@State private var dragStartLocation: CGFloat? = nil
#if os(macOS)
@State private var isHovering = false
#endif
/// Width of the grabber hit area
private static let grabberHitWidth: CGFloat = 20
/// Grabber fill color - needs to be visible against both light (pinned) and dark (floating) backgrounds
private var grabberColor: Color {
// When pinned (light background), use a darker gray for visibility
// When floating (dark background), use lighter color
let baseColor = isPinned ? Color.gray : Color.secondary
let isActive = isDragging
return baseColor.opacity(isActive ? 1.0 : (isPinned ? 0.5 : 0.6))
}
var body: some View {
// Full-height container for expanded hit area
Rectangle()
.fill(Color.clear)
.frame(width: Self.grabberHitWidth)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.overlay {
// Visual pill indicator (centered)
RoundedRectangle(cornerRadius: 3)
.fill(grabberColor)
.frame(width: 6, height: 40)
}
.gesture(dragGesture)
#if os(macOS)
.onHover { hovering in
isHovering = hovering
isDragActive = hovering || isDragging
if hovering {
NSCursor.resizeLeftRight.push()
} else {
NSCursor.pop()
}
}
#endif
.animation(.easeInOut(duration: 0.15), value: isDragging)
}
private var dragGesture: some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { value in
// Capture initial state on first drag event
if dragStartWidth == nil {
dragStartWidth = panelWidth
dragStartLocation = value.location.x
}
isDragging = true
isDragActive = true
guard let startWidth = dragStartWidth,
let startLocation = dragStartLocation else { return }
// Calculate delta from absolute finger position (not translation)
let locationDelta = value.location.x - startLocation
// Apply delta based on panel side
let delta: CGFloat
if panelSide == .right {
delta = -locationDelta
} else {
delta = locationDelta
}
let newWidth = startWidth + delta
panelWidth = min(max(newWidth, minWidth), maxWidth)
}
.onEnded { _ in
dragStartWidth = nil
dragStartLocation = nil
isDragging = false
#if os(macOS)
isDragActive = isHovering
#else
isDragActive = false
#endif
}
}
}
#endif