mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 22:02:10 +00:00
Add a Custom swatch to the appearance settings accent color grid, backed by a hex value stored in settings (synced via iCloud like the rest). iOS uses the native ColorPicker wheel; macOS uses a circular swatch that opens NSColorPanel, since the SwiftUI color well looks out of place among the circles. All accent consumers now read the resolved color through SettingsManager.resolvedAccentColor. The indigo preset is retired from the grid but kept in the enum, so users who selected it keep their color until they pick another one. Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
720 lines
26 KiB
Swift
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?.resolvedAccentColor ?? .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
|