Files
yattee/Yattee/Views/Player/tvOS/TVDetailsPanel.swift
Arkadiusz Fal 052cb76702 Add preset-driven details panel pages on tvOS
TVDetailsPanel now renders the active tvOS preset's panel pages (info,
comments, related, chapters, queue; tracks stays with the quality panel)
with runtime hiding of empty pages, a segmented picker for up to four
pages and a focusable capsule row above that, and Info element toggles
honored. Comments unify on the PlayerState loader; the tvOS queue list
is extracted from QueueManagementSheet into TVQueueListView and reused
as the queue page. New related/chapters focus-based pages: selecting a
related video plays it and dismisses the panel, chapters seek and keep
the panel open.

New minimal Settings > Player Panels screen edits the active tvOS
preset via the existing service: duplicate-to-edit, page toggles with
last-page guard, Move Up/Down reordering, and Info element toggles.
2026-09-21 23:54:14 +02:00

979 lines
34 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.
/// Pages come from the active tvOS preset's panel settings; the tracks page
/// is not offered on tvOS (the quality panel covers it).
struct TVDetailsPanel: View {
let video: Video?
let initialPage: PanelPageType
let onDismiss: () -> Void
@Environment(\.appEnvironment) private var appEnvironment
/// Currently selected page.
@State private var selectedPage: PanelPageType
/// Active preset's panel settings; refreshed from the layout service.
@State private var panelSettings: PanelSettings = PanelSettings.cached
init(video: Video?, initialPage: PanelPageType = .info, onDismiss: @escaping () -> Void) {
self.video = video
self.initialPage = initialPage
self.onDismiss = onDismiss
_selectedPage = State(initialValue: initialPage)
}
/// 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
private var playerState: PlayerState? { appEnvironment?.playerService.state }
// MARK: - Page Visibility
/// Config enables a page; runtime hides pages with nothing to show.
private func isRuntimeVisible(_ pageType: PanelPageType) -> Bool {
switch pageType {
case .info:
return true
case .comments:
return video?.supportsComments == true
&& playerState?.commentsState != .disabled
case .related:
return !(video?.relatedVideos ?? []).isEmpty
case .chapters:
return !(playerState?.chapters ?? []).isEmpty
case .queue:
return appEnvironment?.settingsManager.queueEnabled ?? true
case .tracks:
// The tvOS quality panel covers tracks; never offered here.
return false
}
}
/// Enabled pages that currently have content, in configured order.
/// Falls back to Info so the panel is never empty.
private var visiblePages: [PanelPageType] {
let pages = panelSettings.enabledPages()
.map(\.pageType)
.filter { isRuntimeVisible($0) }
return pages.isEmpty ? [.info] : pages
}
/// The page to render: the selection when visible, else the first page.
private var effectivePage: PanelPageType {
let pages = visiblePages
return pages.contains(selectedPage) ? selectedPage : (pages.first ?? .info)
}
var body: some View {
let pages = visiblePages
VStack(spacing: 0) {
// Page switcher (hidden when description scroll is locked)
if pages.count >= 2, !isDescriptionScrollLocked {
pageSwitcher(pages: pages)
.transition(.opacity.combined(with: .move(edge: .top)))
}
// Content for the selected page
Group {
switch effectivePage {
case .info, .tracks:
infoContent
case .comments:
commentsContent
case .related:
TVRelatedPanelList(video: video, onDidSelectVideo: onDismiss)
case .chapters:
TVChaptersPanelList()
case .queue:
TVQueueListView(horizontalPadding: 0)
}
}
.padding(.horizontal, 40)
.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
}
.task {
await reloadPanelSettings()
}
.onReceive(NotificationCenter.default.publisher(for: .playerControlsActivePresetDidChange)) { _ in
Task { await reloadPanelSettings() }
}
.onReceive(NotificationCenter.default.publisher(for: .playerControlsPresetsDidChange)) { _ in
Task { await reloadPanelSettings() }
}
.onChange(of: selectedPage) { _, _ in
// Reset scroll lock when switching pages
if isDescriptionScrollLocked {
isDescriptionScrollLocked = false
}
}
.onChange(of: video?.id) { _, _ in
// New video: fall back to the first configured page.
if let first = visiblePages.first {
selectedPage = first
}
}
}
// MARK: - Page Switcher
@ViewBuilder
private func pageSwitcher(pages: [PanelPageType]) -> some View {
// Segmented control fits up to 4 pages in the half-screen panel;
// above that a focusable button row keeps labels legible.
if pages.count <= 4 {
Picker("", selection: $selectedPage) {
ForEach(pages, id: \.self) { page in
Text(page.displayName).tag(page)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 40)
.padding(.top, 40)
.padding(.bottom, 20)
.focused($focusedItem, equals: .tabPicker)
} else {
HStack(spacing: 12) {
ForEach(pages, id: \.self) { page in
Button {
selectedPage = page
} label: {
Label(page.displayName, systemImage: page.systemImage)
.font(.callout)
.labelStyle(.titleOnly)
}
.buttonStyle(TVPageSwitcherButtonStyle(isSelected: effectivePage == page))
.focused($focusedItem, equals: .pageButton(page))
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 40)
.padding(.top, 40)
.padding(.bottom, 20)
.focusSection()
}
}
// MARK: - Panel Settings
private func reloadPanelSettings() async {
guard let service = appEnvironment?.playerControlsLayoutService else { return }
let layout = await service.activeLayout()
let settings = layout.effectivePanelSettings
panelSettings = settings
PanelSettings.cached = settings
}
// MARK: - Info Content
private var infoSettings: InfoPageSettings {
panelSettings.configuration(for: .info)?.infoSettings ?? InfoPageSettings()
}
private var infoContent: some View {
let settings = infoSettings
return 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
if settings.effectiveShowChannelRow {
channelRow
}
// Stats row
if settings.effectiveShowStatsRow {
statsRow
}
}
.padding(.vertical, 16)
.fixedSize(horizontal: false, vertical: true)
.transition(.opacity.combined(with: .move(edge: .top)))
}
// Description - expands to fill when locked
if settings.effectiveShowDescription,
let description = video?.description, !description.isEmpty {
TVScrollableDescription(
description: description,
isScrollLocked: $isDescriptionScrollLocked,
overlaysDarkBackground: true
)
.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)
.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
/// Focus items for the details panel.
enum TVDetailsFocusItem: Hashable {
case tabPicker
case pageButton(PanelPageType)
case channelButton
case description
}
/// Button style for the page switcher row used when more than four pages are
/// enabled. Solid focus fill (materials are invisible over video on Apple TV
/// HD); the selected page keeps a subtle persistent fill.
struct TVPageSwitcherButtonStyle: ButtonStyle {
let isSelected: Bool
@Environment(\.isFocused) private var isFocused
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(isFocused ? Color.black : (isSelected ? .white : .white.opacity(0.6)))
.lineLimit(1)
.padding(.horizontal, 18)
.padding(.vertical, 10)
.background(
Capsule()
.fill(isFocused ? Color.white.opacity(0.92) : (isSelected ? Color.white.opacity(0.2) : .clear))
)
.scaleEffect(configuration.isPressed ? 0.95 : (isFocused ? 1.05 : 1.0))
.animation(.easeInOut(duration: 0.15), value: isFocused)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
/// 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
/// The player details panel always sits over a dark backdrop, so it keeps
/// white text; regular pages use adaptive colors for the light scheme.
var overlaysDarkBackground: Bool = false
@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, overlaysDarkBackground: overlaysDarkBackground))
.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(overlaysDarkBackground ? AnyShapeStyle(.white.opacity(0.7)) : AnyShapeStyle(.secondary))
Spacer()
if isFocused {
Text(isScrollLocked ? "player.description.scrollToClose" : "player.description.clickToExpand")
.font(.callout)
.foregroundStyle(overlaysDarkBackground ? AnyShapeStyle(.white.opacity(0.5)) : AnyShapeStyle(.tertiary))
}
}
}
// 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(overlaysDarkBackground ? AnyShapeStyle(.white.opacity(0.9)) : AnyShapeStyle(.primary))
.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
var overlaysDarkBackground: Bool = false
private var highlightColor: Color {
overlaysDarkBackground ? .white : .primary
}
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(16)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isFocused ? (isLocked ? highlightColor.opacity(0.2) : highlightColor.opacity(0.1)) : .clear)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isLocked ? highlightColor.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.
/// Backed by the unified `PlayerState` comments loader shared with iOS/macOS,
/// so a page opened here reuses (and feeds) the same fetched comments.
struct TVCommentsListView: View {
let videoID: String
@Environment(\.appEnvironment) private var appEnvironment
private var contentService: ContentService? { appEnvironment?.contentService }
private var instancesManager: InstancesManager? { appEnvironment?.instancesManager }
private var playerState: PlayerState? { appEnvironment?.playerService.state }
private var comments: [Comment] { playerState?.comments ?? [] }
private var commentsState: CommentsLoadState { playerState?.commentsState ?? .idle }
private var canLoadMore: Bool {
playerState?.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: 8) {
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() }
}
}
}
// Loading more indicator
if commentsState == .loadingMore {
HStack {
Spacer()
ProgressView()
.padding(.vertical, 16)
Spacer()
}
}
}
}
private func loadComments() async {
guard commentsState == .idle || commentsState == .error else { return }
guard let playerState, let contentService, let instancesManager else { return }
await playerState.loadComments(contentService: contentService, instancesManager: instancesManager)
}
private func loadMoreComments() async {
guard canLoadMore else { return }
guard let playerState, let contentService, let instancesManager else { return }
await playerState.loadMoreComments(contentService: contentService, instancesManager: instancesManager)
}
}
/// 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(.primary)
}
}
}
}
.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(.primary)
}
.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
.padding(.horizontal, 16)
.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