mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
The add-to-playlist, download options and queue sheets were declared in every view that could open them: the root view (twice, mirrored from coordinator flags), the expanded player, its portrait and floating panels, the player controls and the video info view. AppSheet lists the shared sheets, and .appSheetHost() declares them once. The app root and the expanded player each host them, and every presented sheet hosts the next level. Views present through the presentAppSheet environment action, which reaches the nearest host; code without a view (and the expanded player itself, whose host sits in its own body) uses PresentationRouter.request, taken by the top-most host: the player while it is up, the root otherwise. The mini player's queue and playlist sheets now go through the host, replacing the isMiniPlayer*SheetPresented flags.
512 lines
19 KiB
Swift
512 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 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
|
|
@Environment(\.presentAppSheet) private var presentAppSheet
|
|
@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
|
|
}
|
|
|
|
/// iPhone renders the same single info page with inline sections as the
|
|
/// portrait panel: no page bar, comments stay on the pill/overlay.
|
|
private var usesInlineSections: Bool {
|
|
PanelSettings.isSectionsOnlyDevice
|
|
}
|
|
|
|
/// iPhone is floating-only: no pin button in the page bar.
|
|
private var pageBarPinAction: PanelPageBarPinAction? {
|
|
guard !PanelSettings.isSectionsOnlyDevice else { return nil }
|
|
return PanelPageBarPinAction(isPinned: isPinned, toggle: onPinToggle)
|
|
}
|
|
|
|
/// Whether the comments pill should route to the comments page instead of
|
|
/// the overlay.
|
|
private var isCommentsPageAvailable: Bool {
|
|
!usesInlineSections
|
|
&& 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 {
|
|
grabber
|
|
}
|
|
|
|
// Main panel content
|
|
panelContent(video: video)
|
|
|
|
// Resize grabber on right when panel is on left side
|
|
if panelSide == .left {
|
|
grabber
|
|
}
|
|
}
|
|
.environment(\.colorScheme, isPinned ? systemColorScheme : .dark)
|
|
.environment(\.presentationColorScheme, systemColorScheme)
|
|
.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
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var grabber: some View {
|
|
PanelResizeGrabber(
|
|
panelWidth: $panelWidth,
|
|
minWidth: Self.minPanelWidth,
|
|
maxWidth: maxPanelWidth,
|
|
panelSide: panelSide,
|
|
isPinned: isPinned,
|
|
isDragActive: $isDragHandleActive
|
|
)
|
|
.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,
|
|
showPageBar: !usesInlineSections,
|
|
pageBarPinAction: pageBarPinAction,
|
|
inlineInfoSections: usesInlineSections
|
|
)
|
|
}
|
|
.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, showAvatar: panelSettings.commentsShowAvatars, 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,
|
|
onQueueTap: { presentAppSheet(.queue()) },
|
|
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, showAvatar: panelSettings.commentsShowAvatars, 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,
|
|
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
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
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
|