mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
Yattee v2 rewrite
This commit is contained in:
522
Yattee/Views/Components/ExpandedCommentsView.swift
Normal file
522
Yattee/Views/Components/ExpandedCommentsView.swift
Normal file
@@ -0,0 +1,522 @@
|
||||
//
|
||||
// ExpandedCommentsView.swift
|
||||
// Yattee
|
||||
//
|
||||
// Full-screen comments overlay with close button.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ExpandedCommentsView: View {
|
||||
let videoID: String
|
||||
let onClose: () -> Void
|
||||
var onDismissOffsetChanged: ((CGFloat) -> Void)? = nil
|
||||
var onDismissGestureEnded: ((CGFloat) -> Void)? = nil
|
||||
var dismissThreshold: CGFloat = 30
|
||||
|
||||
// Panel resize drag callbacks
|
||||
var onDragChanged: ((CGFloat) -> Void)? = nil
|
||||
var onDragEnded: ((CGFloat, CGFloat) -> Void)? = nil
|
||||
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
@State private var showScrollButton = false
|
||||
@State private var scrollToTopTrigger: Int = 0
|
||||
@State private var scrollBounceOffset: CGFloat = 0
|
||||
@State private var isDismissThresholdReached = false
|
||||
|
||||
private var playerState: PlayerState? { appEnvironment?.playerService.state }
|
||||
private var contentService: ContentService? { appEnvironment?.contentService }
|
||||
private var instancesManager: InstancesManager? { appEnvironment?.instancesManager }
|
||||
|
||||
private var comments: [Comment] { playerState?.comments ?? [] }
|
||||
private var commentsState: CommentsLoadState { playerState?.commentsState ?? .idle }
|
||||
private var videoSource: ContentSource? { playerState?.currentVideo?.id.source }
|
||||
|
||||
private var backgroundColor: Color {
|
||||
#if os(iOS)
|
||||
Color(.systemBackground)
|
||||
#elseif os(macOS)
|
||||
Color(nsColor: .windowBackgroundColor)
|
||||
#else
|
||||
Color.black
|
||||
#endif
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// Whether drag handle should be shown (only when resize callbacks are provided)
|
||||
private var showDragHandle: Bool {
|
||||
onDragChanged != nil || onDragEnded != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Invisible anchor for scroll-to-top
|
||||
Color.clear
|
||||
.frame(height: 0)
|
||||
.id("commentsTop")
|
||||
|
||||
// Top padding so content isn't initially obscured by drag handle
|
||||
#if os(iOS)
|
||||
if showDragHandle {
|
||||
Color.clear
|
||||
.frame(height: 17)
|
||||
}
|
||||
#endif
|
||||
|
||||
// Header - height matches the floating X button (52pt)
|
||||
Text(String(localized: "player.comments"))
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.frame(height: 52, alignment: .center)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 16)
|
||||
|
||||
// Comments content
|
||||
commentsContent
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.modifier(CommentsScrollDetectionModifier(
|
||||
showScrollButton: $showScrollButton,
|
||||
scrollBounceOffset: $scrollBounceOffset,
|
||||
isDismissThresholdReached: $isDismissThresholdReached,
|
||||
dismissThreshold: dismissThreshold,
|
||||
onDismissOffsetChanged: onDismissOffsetChanged,
|
||||
onDismissGestureEnded: onDismissGestureEnded,
|
||||
onThresholdCrossed: {
|
||||
appEnvironment?.settingsManager.triggerHapticFeedback(for: .commentsDismiss)
|
||||
}
|
||||
))
|
||||
#endif
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
Color.clear
|
||||
.frame(height: 0)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: showScrollButton)
|
||||
.onChange(of: scrollToTopTrigger) { _, _ in
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
proxy.scrollTo("commentsTop", anchor: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drag handle overlay for panel resizing (only in portrait mode with callbacks)
|
||||
#if os(iOS)
|
||||
if showDragHandle {
|
||||
VStack(spacing: 0) {
|
||||
dragHandle
|
||||
Spacer()
|
||||
}
|
||||
.frame(height: 50)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(alignment: .top) {
|
||||
LinearGradient(
|
||||
colors: [backgroundColor, backgroundColor.opacity(0)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 50)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.highPriorityGesture(
|
||||
DragGesture(coordinateSpace: .global)
|
||||
.onChanged { value in
|
||||
onDragChanged?(value.translation.height)
|
||||
}
|
||||
.onEnded { value in
|
||||
onDragEnded?(value.translation.height, value.predictedEndTranslation.height)
|
||||
}
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.background {
|
||||
#if os(iOS)
|
||||
Color(.systemBackground)
|
||||
#elseif os(macOS)
|
||||
Color(nsColor: .windowBackgroundColor)
|
||||
#else
|
||||
Color.black
|
||||
#endif
|
||||
}
|
||||
// Top and bottom fade gradients (below buttons)
|
||||
.overlay {
|
||||
VStack(spacing: 0) {
|
||||
// Top fade (skip when drag handle is shown - it provides its own fade)
|
||||
#if os(iOS)
|
||||
if !showDragHandle {
|
||||
LinearGradient(
|
||||
colors: [backgroundColor, .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 40)
|
||||
}
|
||||
#else
|
||||
LinearGradient(
|
||||
colors: [backgroundColor, .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 40)
|
||||
#endif
|
||||
|
||||
Spacer()
|
||||
|
||||
// Bottom fade
|
||||
LinearGradient(
|
||||
colors: [.clear, backgroundColor],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 40)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
// Buttons on top of fade gradients
|
||||
.overlay(alignment: .topTrailing) {
|
||||
closeButton
|
||||
.padding(.trailing, 16)
|
||||
.padding(.top, 16)
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if showScrollButton {
|
||||
scrollToTopButton
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var scrollToTopButton: some View {
|
||||
Button {
|
||||
scrollToTopTrigger += 1
|
||||
} 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)
|
||||
.contentShape(Circle())
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 32)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var commentsContent: some View {
|
||||
switch commentsState {
|
||||
case .idle, .loading:
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 32)
|
||||
|
||||
case .disabled:
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "bubble.left.and.exclamationmark.bubble.right")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(String(localized: "comments.disabled"))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
|
||||
case .error:
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(String(localized: "comments.error"))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Button(String(localized: "common.retry")) {
|
||||
Task { await loadComments() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
|
||||
case .loaded, .loadingMore:
|
||||
if comments.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "bubble.left.and.bubble.right")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(String(localized: "comments.empty"))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
} else {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(comments) { comment in
|
||||
CommentView(comment: comment, videoID: videoID, source: videoSource)
|
||||
.onAppear {
|
||||
if comment.id == comments.last?.id,
|
||||
playerState?.commentsContinuation != nil,
|
||||
commentsState == .loaded {
|
||||
Task { await loadMoreComments() }
|
||||
}
|
||||
}
|
||||
|
||||
if comment.id != comments.last?.id {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
if commentsState == .loadingMore {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.padding(.vertical, 16)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom padding for safe area
|
||||
Spacer()
|
||||
.frame(height: 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var closeButton: some View {
|
||||
Button(action: onClose) {
|
||||
Image(systemName: isDismissThresholdReached ? "chevron.down" : "xmark")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(isDismissThresholdReached ? .white : .primary)
|
||||
.frame(width: 32, height: 32)
|
||||
.padding(10)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.glassBackground(
|
||||
isDismissThresholdReached ? .tinted(.blue) : .regular,
|
||||
in: .circle,
|
||||
fallback: isDismissThresholdReached ? .ultraThinMaterial : .thinMaterial
|
||||
)
|
||||
.contentShape(Circle())
|
||||
.animation(.easeInOut(duration: 0.15), value: isDismissThresholdReached)
|
||||
}
|
||||
|
||||
private func loadComments() async {
|
||||
guard let playerState, let contentService, let instancesManager else { return }
|
||||
|
||||
// Don't load comments for non-YouTube videos
|
||||
guard let video = playerState.currentVideo, video.supportsComments else {
|
||||
playerState.commentsState = .disabled
|
||||
return
|
||||
}
|
||||
|
||||
// Capture the video ID we're loading comments for
|
||||
let requestedVideoID = videoID
|
||||
|
||||
guard let instance = instancesManager.instance(for: video) else { return }
|
||||
|
||||
playerState.commentsState = .loading
|
||||
|
||||
do {
|
||||
let page = try await contentService.comments(videoID: videoID, instance: instance, continuation: nil)
|
||||
|
||||
// Validate player is still on the same video
|
||||
guard !Task.isCancelled,
|
||||
playerState.currentVideo?.id.videoID == requestedVideoID else { return }
|
||||
|
||||
playerState.comments = page.comments
|
||||
playerState.commentsContinuation = page.continuation
|
||||
playerState.commentsState = .loaded
|
||||
} catch let error as APIError where error == .commentsDisabled {
|
||||
guard !Task.isCancelled,
|
||||
playerState.currentVideo?.id.videoID == requestedVideoID else { return }
|
||||
playerState.commentsState = .disabled
|
||||
} catch {
|
||||
guard !Task.isCancelled,
|
||||
playerState.currentVideo?.id.videoID == requestedVideoID else { return }
|
||||
playerState.commentsState = .error
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMoreComments() async {
|
||||
guard let playerState, let contentService, let instancesManager else { return }
|
||||
guard let continuation = playerState.commentsContinuation else { return }
|
||||
|
||||
// Don't load more comments for non-YouTube videos
|
||||
guard let video = playerState.currentVideo, video.supportsComments else {
|
||||
return
|
||||
}
|
||||
|
||||
// Capture the video ID we're loading comments for
|
||||
let requestedVideoID = videoID
|
||||
|
||||
guard let instance = instancesManager.instance(for: video) else { return }
|
||||
|
||||
playerState.commentsState = .loadingMore
|
||||
|
||||
do {
|
||||
let page = try await contentService.comments(videoID: videoID, instance: instance, continuation: continuation)
|
||||
|
||||
// Validate player is still on the same video
|
||||
guard !Task.isCancelled,
|
||||
playerState.currentVideo?.id.videoID == requestedVideoID else { return }
|
||||
|
||||
playerState.comments.append(contentsOf: page.comments)
|
||||
playerState.commentsContinuation = page.continuation
|
||||
playerState.commentsState = .loaded
|
||||
} catch {
|
||||
guard !Task.isCancelled,
|
||||
playerState.currentVideo?.id.videoID == requestedVideoID else { return }
|
||||
// Don't change state to error on load more failure
|
||||
playerState.commentsState = .loaded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scroll Fade Overlay
|
||||
|
||||
/// A view modifier that applies a gradient fade overlay to the top and bottom edges of a scroll view.
|
||||
private struct ScrollFadeOverlayModifier: ViewModifier {
|
||||
let fadeHeight: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay(alignment: .top) {
|
||||
// Top gradient fade
|
||||
LinearGradient(
|
||||
colors: [backgroundColor, .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: fadeHeight)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
// Bottom gradient fade
|
||||
LinearGradient(
|
||||
colors: [.clear, backgroundColor],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: fadeHeight)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
#if os(iOS)
|
||||
Color(.systemBackground)
|
||||
#elseif os(macOS)
|
||||
Color(nsColor: .windowBackgroundColor)
|
||||
#else
|
||||
Color.black
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func scrollFadeMask(fadeHeight: CGFloat = 24) -> some View {
|
||||
modifier(ScrollFadeOverlayModifier(fadeHeight: fadeHeight))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scroll Detection
|
||||
|
||||
#if os(iOS)
|
||||
private struct CommentsScrollDetectionModifier: ViewModifier {
|
||||
@Binding var showScrollButton: Bool
|
||||
@Binding var scrollBounceOffset: CGFloat
|
||||
@Binding var isDismissThresholdReached: Bool
|
||||
var dismissThreshold: CGFloat
|
||||
var onDismissOffsetChanged: ((CGFloat) -> Void)?
|
||||
var onDismissGestureEnded: ((CGFloat) -> Void)?
|
||||
var onThresholdCrossed: (() -> Void)?
|
||||
|
||||
@State private var currentOverscroll: CGFloat = 0
|
||||
@State private var hasTriggeredHaptic = false
|
||||
@State private var isUserDragging = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 18, *) {
|
||||
content
|
||||
.onScrollGeometryChange(for: CGFloat.self) { geometry in
|
||||
geometry.contentOffset.y
|
||||
} action: { _, newValue in
|
||||
// Track scroll button visibility
|
||||
let shouldShow = newValue > 100
|
||||
if shouldShow != showScrollButton {
|
||||
showScrollButton = shouldShow
|
||||
}
|
||||
|
||||
// Track overscroll (negative contentOffset = pulling down at top)
|
||||
if newValue < 0 {
|
||||
currentOverscroll = -newValue
|
||||
scrollBounceOffset = currentOverscroll
|
||||
onDismissOffsetChanged?(currentOverscroll)
|
||||
|
||||
// Only show threshold feedback when user is actively dragging
|
||||
if isUserDragging {
|
||||
let thresholdReached = currentOverscroll >= dismissThreshold
|
||||
if thresholdReached != isDismissThresholdReached {
|
||||
isDismissThresholdReached = thresholdReached
|
||||
// Haptic feedback when crossing threshold
|
||||
if thresholdReached && !hasTriggeredHaptic {
|
||||
onThresholdCrossed?()
|
||||
hasTriggeredHaptic = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if currentOverscroll > 0 {
|
||||
currentOverscroll = 0
|
||||
scrollBounceOffset = 0
|
||||
onDismissOffsetChanged?(0)
|
||||
isDismissThresholdReached = false
|
||||
hasTriggeredHaptic = false
|
||||
}
|
||||
}
|
||||
.onScrollPhaseChange { oldPhase, newPhase, _ in
|
||||
// Track when user is actively dragging
|
||||
isUserDragging = (newPhase == .interacting)
|
||||
|
||||
// When user releases after overscrolling
|
||||
if oldPhase == .interacting && newPhase != .interacting {
|
||||
if currentOverscroll > 0 {
|
||||
onDismissGestureEnded?(currentOverscroll)
|
||||
}
|
||||
// Reset threshold state after gesture ends
|
||||
isDismissThresholdReached = false
|
||||
hasTriggeredHaptic = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user