Files
yattee/Yattee/Views/Player/tvOS/TVDetailsPanel.swift
Arkadiusz Fal f49dfd6246 Hide description header in tvOS video info right column
The right column is already dedicated to video info so the "Description"
label is redundant. Add an opt-out `showsHeader` parameter to
`TVScrollableDescription` (default true) and pass false from
`VideoInfoView`; the player overlay and channel view keep the header.
2026-04-18 20:38:01 +02:00

880 lines
30 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 initialTab: TVDetailsTab
let onDismiss: () -> Void
@Environment(\.appEnvironment) private var appEnvironment
/// Tab selection for Info / Comments.
@State private var selectedTab: TVDetailsTab
init(video: Video?, initialTab: TVDetailsTab = .info, onDismiss: @escaping () -> Void) {
self.video = video
self.initialTab = initialTab
self.onDismiss = onDismiss
_selectedTab = State(initialValue: initialTab)
}
/// 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) {
// Tab picker (hidden when description scroll is locked)
if !isDescriptionScrollLocked {
Picker("", selection: $selectedTab) {
Text("player.tab.info").tag(TVDetailsTab.info)
if video?.supportsComments == true {
Text("player.tab.comments").tag(TVDetailsTab.comments)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 120)
.padding(.top, 60)
.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)
.frame(maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea()
)
.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 {
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)
.fixedSize(horizontal: false, vertical: true)
.transition(.opacity.combined(with: .move(edge: .top)))
}
// Description - expands to fill when locked
if let description = video?.description, !description.isEmpty {
TVScrollableDescription(
description: description,
isScrollLocked: $isDescriptionScrollLocked
)
.padding(.top, isDescriptionScrollLocked ? 24 : 8)
}
}
.animation(.easeInOut(duration: 0.25), value: isDescriptionScrollLocked)
}
// MARK: - Channel Row
/// Author enriched with cached channel data (avatar, subscriber count) from local stores.
private var enrichedAuthor: Author? {
guard let video else { return nil }
guard let dataManager = appEnvironment?.dataManager else { return video.author }
return video.author.enriched(using: dataManager)
}
private var channelRow: some View {
HStack(spacing: 16) {
// Channel avatar
if let thumbnailURL = enrichedAuthor?.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(enrichedAuthor?.name ?? "")
.font(.headline)
.foregroundStyle(.white)
// Subscriber count
if let subscriberCount = enrichedAuthor?.subscriberCount {
Text("channel.subscriberCount \(CountFormatter.compact(subscriberCount))")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.6))
}
}
Spacer()
// Channel button
Button {
// Navigate to channel
if let video {
navigateToChannel(for: video)
}
} label: {
Text(String(localized: "channel.view"))
.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("player.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(String(localized: "comments.unavailable"))
.font(.body)
.foregroundStyle(.white.opacity(0.5))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 40)
}
}
}
// MARK: - Navigation
private func navigateToChannel(for video: Video) {
onDismiss()
appEnvironment?.navigationCoordinator.navigateToChannel(for: video, collapsePlayer: true)
}
}
// 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
@Binding var isScrollLocked: Bool
var showsHeader: Bool = true
@FocusState private var isFocused: Bool
@State private var scrollOffset: CGFloat = 0
private let scrollStep: CGFloat = 80
private let maxScroll: CGFloat = 5000
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($isFocused)
.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) {
if showsHeader {
HStack {
Text("player.description")
.font(.headline)
.foregroundStyle(.white.opacity(0.7))
Spacer()
if isFocused {
Text(isScrollLocked ? "player.description.scrollToClose" : "player.description.clickToExpand")
.font(.callout)
.foregroundStyle(.white.opacity(0.5))
}
}
}
// Clipped container for scrollable text. The Rectangle owns the
// layout size (fills available space) so the Text's intrinsic
// height from .fixedSize doesn't push parents.
Rectangle()
.fill(Color.clear)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .topLeading) {
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)
}
.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(String(localized: "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(String(localized: "comments.empty"))
.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("comments.replyCount \(comment.replyCount)")
.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(String(localized: "comments.loadMoreReplies"))
.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