Files
yattee/Yattee/Views/Player/PortraitDetailsPanel.swift
Arkadiusz Fal b47888fe04 Add custom accent color with system color picker
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
2026-07-04 23:57:59 +02:00

575 lines
24 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
// Drag gesture callbacks
var onDragChanged: ((CGFloat) -> Void)?
var onDragEnded: ((CGFloat, CGFloat) -> Void)?
var onDragCancelled: (() -> Void)?
@GestureState private var isDraggingHandle: Bool = false
@State private var isCommentsExpanded: Bool = false
@State private var showFormattedDate = false
@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 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 ?? [] }
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
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 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)
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, otherwise the original title.
private func displayTitle(for video: Video) -> String {
appEnvironment?.deArrowBrandingProvider.title(for: video) ?? video.title
}
// 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) {
// Scrollable content (extends to top, scrolls under drag handle)
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 0) {
// Anchor for scroll-to-top
Color.clear
.frame(height: 0)
.id("panelTop")
// Top padding so content isn't initially obscured by drag handle
Color.clear
.frame(height: 10)
videoInfo(video)
Divider()
.padding(.horizontal)
infoTabSection(video)
}
}
.background(Color(.systemBackground))
.background {
// UIKit gesture handler for smooth overscroll-to-collapse
OverscrollGestureView(
isEnabled: !isDraggingHandle,
onDragChanged: { offset in
onDragChanged?(offset)
},
onDragEnded: { offset, predicted in
onDragEnded?(offset, predicted)
}
)
}
.scrollDisabled(isDraggingHandle)
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y
} action: { _, newValue in
// Update scrollOffset for pills visibility
scrollOffset = max(0, newValue)
}
.onChange(of: scrollToTopTrigger) { _, _ in
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo("panelTop", anchor: .top)
}
}
}
.overlay(alignment: .bottom) {
bottomFadeOverlay
}
// Extended drag hit area overlay - visual handle at top, hit area extends down
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)
}
.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: 16))
// Pills overlay at bottom
.overlay {
if !isCommentsExpanded {
pillsOverlay
.opacity(pillsOverlayOpacity)
.allowsHitTesting(pillsOverlayOpacity > 0.01)
}
}
// Expanded comments overlay
.overlay {
let expanded = isCommentsExpanded
ExpandedCommentsView(
videoID: video.id.videoID,
onClose: collapseComments,
onDismissGestureEnded: handleCommentsDismissGestureEnded,
onDragChanged: onDragChanged,
onDragEnded: onDragEnded
)
.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
}
}
}
)
}
.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)
}
}
}
}
// 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,
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,
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,
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)
}
}
}
// MARK: - Video Info
private func videoInfo(_ video: Video) -> some View {
VStack(alignment: .leading, spacing: 8) {
// Title - full width (shows DeArrow title if available)
Text(displayTitle(for: video))
.font(.title3)
.fontWeight(.semibold)
.lineLimit(3)
// 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(.horizontal, 12)
.padding(.vertical, 8)
}
// MARK: - Info Tab Section
private func infoTabSection(_ video: Video) -> some View {
let hasDescription = !(video.description ?? "").isEmpty
return VStack(alignment: .leading, spacing: 12) {
descriptionContent(video.description ?? "")
// Reduced bottom spacer when no description
Spacer()
.frame(height: hasDescription ? 80 : 50)
}
.padding(.vertical)
}
private func expandComments() {
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()
}
}
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()
}
// No description text removed - let panel shrink instead
}
}
}
#endif