Files
yattee/Yattee/Views/Player/tvOS/TVDetailsPanel.swift
2026-02-08 18:33:56 +01:00

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