mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 13:42:54 +00:00
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.
979 lines
34 KiB
Swift
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
|