mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
The "Author Avatars" toggle only reached the iPad/macOS comments page. The expanded comments overlay now takes the same flag (hosts pass it in the next commit), and the expanded comments pill shows the comments icon instead of the author avatar when avatars are off. The replies block in a comment used a hardcoded 44pt leading inset for the avatar column regardless of the toggle, so "Show replies" was indented past the comment text when avatars were hidden. The toggle now aligns with the text (44pt with avatars, 0 without) and nested replies keep a smaller 16pt inset without avatars so the hierarchy still reads.
487 lines
18 KiB
Swift
487 lines
18 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
|
|
/// Whether comment rows show author avatars (comments page setting).
|
|
var showAvatars: Bool = true
|
|
|
|
@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 {
|
|
CommentsListContent(videoID: videoID, showAvatars: showAvatars)
|
|
}
|
|
|
|
@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)
|
|
}
|
|
}
|
|
|
|
// MARK: - Comments List Content
|
|
|
|
/// Comments list states and rows driven by `PlayerState`, shared between the
|
|
/// expanded comments overlay and the details panel comments page.
|
|
struct CommentsListContent: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
let videoID: String
|
|
var showAvatars: Bool = true
|
|
|
|
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 }
|
|
|
|
var body: 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, showAvatar: showAvatars)
|
|
.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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadComments() async {
|
|
guard let playerState, let contentService, let instancesManager else { return }
|
|
await playerState.loadComments(contentService: contentService, instancesManager: instancesManager)
|
|
}
|
|
|
|
private func loadMoreComments() async {
|
|
guard let playerState, let contentService, let instancesManager else { return }
|
|
await playerState.loadMoreComments(contentService: contentService, instancesManager: instancesManager)
|
|
}
|
|
}
|
|
|
|
// 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
|