mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
884 lines
29 KiB
Swift
884 lines
29 KiB
Swift
//
|
|
// TVDetailsPanel.swift
|
|
// Yattee
|
|
//
|
|
// Swipe-up details panel for tvOS player showing video info and comments.
|
|
//
|
|
|
|
#if os(tvOS)
|
|
import SwiftUI
|
|
import NukeUI
|
|
|
|
/// Details panel that slides up from the bottom showing video information.
|
|
struct TVDetailsPanel: View {
|
|
let video: Video?
|
|
let onDismiss: () -> Void
|
|
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
/// Tab selection for Info / Comments.
|
|
@State private var selectedTab: TVDetailsTab = .info
|
|
|
|
/// Focus state for interactive elements.
|
|
@FocusState private var focusedItem: TVDetailsFocusItem?
|
|
|
|
/// Whether description scroll is locked (prevents focus from leaving description).
|
|
@State private var isDescriptionScrollLocked = false
|
|
|
|
/// Comments state
|
|
@State private var comments: [Comment] = []
|
|
@State private var commentsState: CommentsLoadState = .idle
|
|
@State private var commentsContinuation: String?
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Drag indicator
|
|
Capsule()
|
|
.fill(.white.opacity(0.4))
|
|
.frame(width: 80, height: 6)
|
|
.padding(.top, 20)
|
|
|
|
// Tab picker (hidden when description scroll is locked)
|
|
if !isDescriptionScrollLocked {
|
|
Picker("", selection: $selectedTab) {
|
|
Text("Info").tag(TVDetailsTab.info)
|
|
if video?.supportsComments == true {
|
|
Text("Comments").tag(TVDetailsTab.comments)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.padding(.horizontal, 120)
|
|
.padding(.top, 24)
|
|
.padding(.bottom, 20)
|
|
.focused($focusedItem, equals: .tabPicker)
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
|
|
// Content based on selected tab
|
|
Group {
|
|
switch selectedTab {
|
|
case .info:
|
|
infoContent
|
|
case .comments:
|
|
commentsContent
|
|
}
|
|
}
|
|
.padding(.horizontal, 88)
|
|
|
|
Spacer()
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: UIScreen.main.bounds.height * 0.65)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 32, style: .continuous)
|
|
.fill(.ultraThinMaterial)
|
|
.ignoresSafeArea(edges: .bottom)
|
|
)
|
|
.frame(maxHeight: .infinity, alignment: .bottom)
|
|
.onExitCommand {
|
|
// If description scroll is locked, unlock it first
|
|
if isDescriptionScrollLocked {
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
|
isDescriptionScrollLocked = false
|
|
}
|
|
} else {
|
|
onDismiss()
|
|
}
|
|
}
|
|
.onAppear {
|
|
focusedItem = .tabPicker
|
|
}
|
|
.onChange(of: selectedTab) { _, newTab in
|
|
// Reset scroll lock when switching tabs
|
|
if isDescriptionScrollLocked {
|
|
isDescriptionScrollLocked = false
|
|
}
|
|
// Reset comments state when switching to comments tab
|
|
if newTab == .comments && commentsState == .idle {
|
|
// Comments will load via TVCommentsListView's .task
|
|
}
|
|
}
|
|
.onChange(of: video?.supportsComments) { _, supportsComments in
|
|
// If current video doesn't support comments, switch to info tab
|
|
if supportsComments == false && selectedTab == .comments {
|
|
selectedTab = .info
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Info Content
|
|
|
|
private var infoContent: some View {
|
|
VStack(spacing: 0) {
|
|
// Top section with title, channel, stats (hidden when description expanded)
|
|
if !isDescriptionScrollLocked {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 24) {
|
|
// Video title
|
|
Text(video?.title ?? "")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
.lineLimit(3)
|
|
.foregroundStyle(.white)
|
|
|
|
// Channel info row
|
|
channelRow
|
|
|
|
// Stats row
|
|
statsRow
|
|
}
|
|
.padding(.vertical, 16)
|
|
}
|
|
.frame(height: 180)
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
|
|
// Description - expands to fill when locked
|
|
if let description = video?.description, !description.isEmpty {
|
|
TVScrollableDescription(
|
|
description: description,
|
|
focusedItem: $focusedItem,
|
|
isScrollLocked: $isDescriptionScrollLocked
|
|
)
|
|
.padding(.top, isDescriptionScrollLocked ? 24 : 8)
|
|
}
|
|
|
|
if isDescriptionScrollLocked {
|
|
Spacer()
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.25), value: isDescriptionScrollLocked)
|
|
}
|
|
|
|
// MARK: - Channel Row
|
|
|
|
private var channelRow: some View {
|
|
HStack(spacing: 16) {
|
|
// Channel avatar
|
|
if let thumbnailURL = video?.author.thumbnailURL {
|
|
AsyncImage(url: thumbnailURL) { image in
|
|
image
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
} placeholder: {
|
|
Circle()
|
|
.fill(.white.opacity(0.2))
|
|
}
|
|
.frame(width: 64, height: 64)
|
|
.clipShape(Circle())
|
|
} else {
|
|
Circle()
|
|
.fill(.white.opacity(0.2))
|
|
.frame(width: 64, height: 64)
|
|
.overlay {
|
|
Image(systemName: "person.fill")
|
|
.font(.title2)
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
// Channel name
|
|
Text(video?.author.name ?? "")
|
|
.font(.headline)
|
|
.foregroundStyle(.white)
|
|
|
|
// Subscriber count
|
|
if let subscriberCount = video?.author.subscriberCount {
|
|
Text("\(CountFormatter.compact(subscriberCount)) subscribers")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.white.opacity(0.6))
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Channel button
|
|
Button {
|
|
// Navigate to channel
|
|
if let video {
|
|
navigateToChannel(video.author)
|
|
}
|
|
} label: {
|
|
Text("View Channel")
|
|
.font(.callout)
|
|
.fontWeight(.medium)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.focused($focusedItem, equals: .channelButton)
|
|
}
|
|
}
|
|
|
|
// MARK: - Stats Row
|
|
|
|
private var statsRow: some View {
|
|
HStack(spacing: 32) {
|
|
// Views
|
|
if let viewCount = video?.viewCount {
|
|
Label {
|
|
Text(CountFormatter.compact(viewCount))
|
|
} icon: {
|
|
Image(systemName: "eye")
|
|
}
|
|
}
|
|
|
|
// Likes
|
|
if let likeCount = video?.likeCount {
|
|
Label {
|
|
Text(CountFormatter.compact(likeCount))
|
|
} icon: {
|
|
Image(systemName: "hand.thumbsup")
|
|
}
|
|
}
|
|
|
|
// Published date
|
|
if let publishedText = video?.formattedPublishedDate {
|
|
Label {
|
|
Text(publishedText)
|
|
} icon: {
|
|
Image(systemName: "calendar")
|
|
}
|
|
}
|
|
|
|
// Duration
|
|
if let video, video.duration > 0 {
|
|
Label {
|
|
Text(video.formattedDuration)
|
|
} icon: {
|
|
Image(systemName: "clock")
|
|
}
|
|
}
|
|
|
|
// Live indicator
|
|
if video?.isLive == true {
|
|
Label("LIVE", systemImage: "dot.radiowaves.left.and.right")
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
.font(.subheadline)
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
}
|
|
|
|
// MARK: - Comments Content
|
|
|
|
private var commentsContent: some View {
|
|
ScrollView {
|
|
if let videoID = video?.id.videoID {
|
|
TVCommentsListView(
|
|
videoID: videoID,
|
|
comments: $comments,
|
|
commentsState: $commentsState,
|
|
commentsContinuation: $commentsContinuation
|
|
)
|
|
.padding(.vertical, 16)
|
|
} else {
|
|
Text("Comments unavailable")
|
|
.font(.body)
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.top, 40)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Navigation
|
|
|
|
private func navigateToChannel(_ author: Author) {
|
|
// Close the player and navigate to channel
|
|
onDismiss()
|
|
appEnvironment?.navigationCoordinator.isPlayerExpanded = false
|
|
|
|
// Navigate to channel view
|
|
// This would need to be implemented based on your navigation system
|
|
}
|
|
}
|
|
|
|
// MARK: - Supporting Types
|
|
|
|
/// Tabs for the details panel.
|
|
enum TVDetailsTab: String, CaseIterable {
|
|
case info
|
|
case comments
|
|
}
|
|
|
|
/// Focus items for the details panel.
|
|
enum TVDetailsFocusItem: Hashable {
|
|
case tabPicker
|
|
case channelButton
|
|
case description
|
|
}
|
|
|
|
/// Scrollable description view with click-to-lock scrolling.
|
|
/// When locked, expands to fill available space for easier reading.
|
|
struct TVScrollableDescription: View {
|
|
let description: String
|
|
@FocusState.Binding var focusedItem: TVDetailsFocusItem?
|
|
@Binding var isScrollLocked: Bool
|
|
|
|
@State private var scrollOffset: CGFloat = 0
|
|
private let scrollStep: CGFloat = 80
|
|
private let maxScroll: CGFloat = 5000
|
|
|
|
/// Height of description area - expands when locked
|
|
private var descriptionHeight: CGFloat {
|
|
isScrollLocked ? 500 : 200
|
|
}
|
|
|
|
private var isFocused: Bool {
|
|
focusedItem == .description
|
|
}
|
|
|
|
var body: some View {
|
|
Button {
|
|
// Toggle scroll lock on click/select
|
|
withAnimation(.easeInOut(duration: 0.25)) {
|
|
isScrollLocked.toggle()
|
|
if !isScrollLocked {
|
|
scrollOffset = 0
|
|
}
|
|
}
|
|
} label: {
|
|
descriptionContent
|
|
}
|
|
.buttonStyle(TVDescriptionButtonStyle(isFocused: isFocused, isLocked: isScrollLocked))
|
|
.focused($focusedItem, equals: .description)
|
|
.onMoveCommand { direction in
|
|
guard isScrollLocked else { return }
|
|
|
|
switch direction {
|
|
case .down:
|
|
withAnimation(.easeOut(duration: 0.15)) {
|
|
scrollOffset = min(scrollOffset + scrollStep, maxScroll)
|
|
}
|
|
case .up:
|
|
withAnimation(.easeOut(duration: 0.15)) {
|
|
scrollOffset = max(scrollOffset - scrollStep, 0)
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
.onChange(of: isFocused) { _, focused in
|
|
if !focused {
|
|
// Reset lock when losing focus
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
isScrollLocked = false
|
|
scrollOffset = 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var descriptionContent: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Text("Description")
|
|
.font(.headline)
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
|
|
Spacer()
|
|
|
|
if isFocused {
|
|
Text(isScrollLocked ? "↑↓ scroll • click to close" : "click to expand")
|
|
.font(.callout)
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
}
|
|
}
|
|
|
|
// Clipped container for scrollable text
|
|
Text(description)
|
|
.font(.body)
|
|
.foregroundStyle(.white.opacity(0.9))
|
|
.lineLimit(nil)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.multilineTextAlignment(.leading)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
.offset(y: -scrollOffset)
|
|
.frame(height: descriptionHeight, alignment: .top)
|
|
.clipped()
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.animation(.easeInOut(duration: 0.25), value: isScrollLocked)
|
|
}
|
|
}
|
|
|
|
/// Button style for description view - no default focus highlight.
|
|
struct TVDescriptionButtonStyle: ButtonStyle {
|
|
let isFocused: Bool
|
|
let isLocked: Bool
|
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.padding(16)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(isFocused ? (isLocked ? .white.opacity(0.2) : .white.opacity(0.1)) : .clear)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(isLocked ? .white.opacity(0.5) : .clear, lineWidth: 2)
|
|
)
|
|
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
|
|
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
|
|
}
|
|
}
|
|
|
|
/// tvOS comments list with focusable comment rows.
|
|
/// Each comment is focusable to allow one-by-one navigation.
|
|
struct TVCommentsListView: View {
|
|
let videoID: String
|
|
@Binding var comments: [Comment]
|
|
@Binding var commentsState: CommentsLoadState
|
|
@Binding var commentsContinuation: String?
|
|
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
private var contentService: ContentService? { appEnvironment?.contentService }
|
|
private var instancesManager: InstancesManager? { appEnvironment?.instancesManager }
|
|
|
|
private var canLoadMore: Bool {
|
|
commentsContinuation != nil && commentsState == .loaded
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
switch commentsState {
|
|
case .idle, .loading:
|
|
loadingView
|
|
case .disabled:
|
|
disabledView
|
|
case .error:
|
|
errorView
|
|
case .loaded, .loadingMore:
|
|
if comments.isEmpty {
|
|
emptyView
|
|
} else {
|
|
commentsList
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
await loadComments()
|
|
}
|
|
}
|
|
|
|
private var loadingView: some View {
|
|
HStack {
|
|
Spacer()
|
|
ProgressView()
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 32)
|
|
}
|
|
|
|
private var disabledView: some View {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "bubble.left.and.exclamationmark.bubble.right")
|
|
.font(.title2)
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
Text("Comments disabled")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 32)
|
|
}
|
|
|
|
private var errorView: some View {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.font(.title2)
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
Text(String(localized: "comments.failedToLoad"))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
Button(String(localized: "common.retry")) {
|
|
Task { await loadComments() }
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 32)
|
|
}
|
|
|
|
private var emptyView: some View {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "bubble.left.and.bubble.right")
|
|
.font(.title2)
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
Text("No comments")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 32)
|
|
}
|
|
|
|
private var commentsList: some View {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
ForEach(comments) { comment in
|
|
TVFocusableCommentView(comment: comment, videoID: videoID)
|
|
.onAppear {
|
|
// Load more when reaching near the end
|
|
if comment.id == comments.last?.id && canLoadMore {
|
|
Task { await loadMoreComments() }
|
|
}
|
|
}
|
|
|
|
if comment.id != comments.last?.id {
|
|
Divider()
|
|
.background(.white.opacity(0.2))
|
|
}
|
|
}
|
|
|
|
// Loading more indicator
|
|
if commentsState == .loadingMore {
|
|
HStack {
|
|
Spacer()
|
|
ProgressView()
|
|
.padding(.vertical, 16)
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadComments() async {
|
|
guard commentsState == .idle else { return }
|
|
guard let contentService, let instancesManager else { return }
|
|
guard let instance = instancesManager.enabledInstances.first(where: \.isYouTubeInstance) else { return }
|
|
|
|
commentsState = .loading
|
|
|
|
do {
|
|
let page = try await contentService.comments(videoID: videoID, instance: instance, continuation: nil)
|
|
comments = page.comments
|
|
commentsContinuation = page.continuation
|
|
commentsState = .loaded
|
|
} catch let error as APIError where error == .commentsDisabled {
|
|
commentsState = .disabled
|
|
} catch {
|
|
commentsState = .error
|
|
}
|
|
}
|
|
|
|
private func loadMoreComments() async {
|
|
guard canLoadMore else { return }
|
|
guard let contentService, let instancesManager else { return }
|
|
guard let instance = instancesManager.enabledInstances.first(where: \.isYouTubeInstance) else { return }
|
|
|
|
commentsState = .loadingMore
|
|
|
|
do {
|
|
let page = try await contentService.comments(videoID: videoID, instance: instance, continuation: commentsContinuation)
|
|
comments.append(contentsOf: page.comments)
|
|
commentsContinuation = page.continuation
|
|
commentsState = .loaded
|
|
} catch {
|
|
commentsState = .loaded
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Focusable comment view for tvOS.
|
|
/// Comments with replies show an expandable button, others have invisible focus placeholder.
|
|
struct TVFocusableCommentView: View {
|
|
let comment: Comment
|
|
let videoID: String
|
|
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
@State private var replies: [Comment] = []
|
|
@State private var isLoadingReplies = false
|
|
@State private var showReplies = false
|
|
@State private var repliesContinuation: String?
|
|
|
|
private var contentService: ContentService? { appEnvironment?.contentService }
|
|
private var instancesManager: InstancesManager? { appEnvironment?.instancesManager }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Main comment content - always focusable
|
|
Button {
|
|
// Toggle replies if available
|
|
if comment.replyCount > 0 {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
if showReplies {
|
|
showReplies = false
|
|
} else {
|
|
showReplies = true
|
|
if replies.isEmpty {
|
|
Task { await loadReplies() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
commentContent
|
|
}
|
|
.buttonStyle(TVCommentButtonStyle(hasReplies: comment.replyCount > 0))
|
|
|
|
// Replies section
|
|
if showReplies && comment.replyCount > 0 {
|
|
repliesSection
|
|
}
|
|
}
|
|
}
|
|
|
|
private var commentContent: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
// Author avatar
|
|
LazyImage(url: comment.author.thumbnailURL) { state in
|
|
if let image = state.image {
|
|
image
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
} else {
|
|
Circle()
|
|
.fill(.white.opacity(0.2))
|
|
.overlay {
|
|
Text(String(comment.author.name.prefix(1)))
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
}
|
|
}
|
|
}
|
|
.frame(width: 40, height: 40)
|
|
.clipShape(Circle())
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
// Author name and badges
|
|
HStack(spacing: 4) {
|
|
Text(comment.author.name)
|
|
.font(.subheadline)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(.white)
|
|
|
|
if comment.isCreatorComment {
|
|
Image(systemName: "checkmark.seal.fill")
|
|
.font(.caption)
|
|
.foregroundStyle(.blue)
|
|
}
|
|
|
|
if comment.isPinned {
|
|
Image(systemName: "pin.fill")
|
|
.font(.caption)
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Published time
|
|
if let publishedText = comment.formattedPublishedDate {
|
|
Text(publishedText)
|
|
.font(.caption)
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
}
|
|
}
|
|
|
|
// Comment content
|
|
Text(comment.content)
|
|
.font(.body)
|
|
.foregroundStyle(.white.opacity(0.9))
|
|
.lineLimit(nil)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.multilineTextAlignment(.leading)
|
|
|
|
// Metadata row
|
|
HStack(spacing: 16) {
|
|
if let likeCount = comment.formattedLikeCount {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "hand.thumbsup")
|
|
.font(.caption)
|
|
Text(likeCount)
|
|
.font(.caption)
|
|
}
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
}
|
|
|
|
if comment.hasCreatorHeart {
|
|
Image(systemName: "heart.fill")
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
|
|
// Replies indicator
|
|
if comment.replyCount > 0 {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: showReplies ? "chevron.up" : "chevron.down")
|
|
.font(.caption2)
|
|
Text("\(comment.replyCount) replies")
|
|
.font(.caption)
|
|
}
|
|
.foregroundStyle(.blue)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var repliesSection: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ForEach(replies) { reply in
|
|
TVReplyView(comment: reply)
|
|
.padding(.leading, 52) // Indent replies
|
|
|
|
if reply.id != replies.last?.id {
|
|
Divider()
|
|
.background(.white.opacity(0.15))
|
|
.padding(.leading, 52)
|
|
}
|
|
}
|
|
|
|
// Loading indicator
|
|
if isLoadingReplies {
|
|
HStack {
|
|
Spacer()
|
|
ProgressView()
|
|
.padding(.vertical, 8)
|
|
Spacer()
|
|
}
|
|
.padding(.leading, 52)
|
|
}
|
|
|
|
// Load more button
|
|
if !isLoadingReplies && repliesContinuation != nil {
|
|
Button {
|
|
Task { await loadReplies() }
|
|
} label: {
|
|
Text("Load more replies")
|
|
.font(.caption)
|
|
.foregroundStyle(.blue)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.leading, 52)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadReplies() async {
|
|
guard let contentService, let instancesManager else { return }
|
|
guard let instance = instancesManager.enabledInstances.first(where: \.isYouTubeInstance) else { return }
|
|
|
|
let continuation = replies.isEmpty ? comment.repliesContinuation : repliesContinuation
|
|
guard let continuation else { return }
|
|
|
|
isLoadingReplies = true
|
|
|
|
do {
|
|
let page = try await contentService.comments(videoID: videoID, instance: instance, continuation: continuation)
|
|
replies.append(contentsOf: page.comments)
|
|
repliesContinuation = page.continuation
|
|
} catch {
|
|
// Silently fail for replies
|
|
}
|
|
|
|
isLoadingReplies = false
|
|
}
|
|
}
|
|
|
|
/// Focusable reply view for tvOS.
|
|
struct TVReplyView: View {
|
|
let comment: Comment
|
|
|
|
var body: some View {
|
|
Button {
|
|
// No action for replies, just focusable for navigation
|
|
} label: {
|
|
replyContent
|
|
}
|
|
.buttonStyle(TVCommentButtonStyle(hasReplies: false))
|
|
}
|
|
|
|
private var replyContent: some View {
|
|
HStack(alignment: .top, spacing: 10) {
|
|
// Author avatar (smaller for replies)
|
|
LazyImage(url: comment.author.thumbnailURL) { state in
|
|
if let image = state.image {
|
|
image
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
} else {
|
|
Circle()
|
|
.fill(.white.opacity(0.2))
|
|
.overlay {
|
|
Text(String(comment.author.name.prefix(1)))
|
|
.font(.caption2)
|
|
.fontWeight(.medium)
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
}
|
|
}
|
|
}
|
|
.frame(width: 28, height: 28)
|
|
.clipShape(Circle())
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
// Author name
|
|
HStack(spacing: 4) {
|
|
Text(comment.author.name)
|
|
.font(.caption)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(.white)
|
|
|
|
if comment.isCreatorComment {
|
|
Image(systemName: "checkmark.seal.fill")
|
|
.font(.caption2)
|
|
.foregroundStyle(.blue)
|
|
}
|
|
|
|
if let publishedText = comment.formattedPublishedDate {
|
|
Text("• \(publishedText)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
}
|
|
}
|
|
|
|
// Reply content
|
|
Text(comment.content)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.white.opacity(0.85))
|
|
.lineLimit(nil)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.multilineTextAlignment(.leading)
|
|
|
|
// Likes
|
|
if let likeCount = comment.formattedLikeCount {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "hand.thumbsup")
|
|
.font(.caption2)
|
|
Text(likeCount)
|
|
.font(.caption2)
|
|
}
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
.padding(.top, 2)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
/// Button style for focusable comments.
|
|
struct TVCommentButtonStyle: ButtonStyle {
|
|
let hasReplies: Bool
|
|
@Environment(\.isFocused) private var isFocused
|
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(isFocused ? .white.opacity(0.1) : .clear)
|
|
)
|
|
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
|
|
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
|
|
.animation(.easeInOut(duration: 0.15), value: isFocused)
|
|
}
|
|
}
|
|
|
|
#endif
|