mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
523 lines
19 KiB
Swift
523 lines
19 KiB
Swift
//
|
|
// ExpandedCommentsView.swift
|
|
// Yattee
|
|
//
|
|
// Full-screen comments overlay with close button.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct ExpandedCommentsView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
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
|
|
|
|
@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
|