From 052cb76702f6ca8cac01b12fc5d52aa52f0e3fdd Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 21 Sep 2026 23:54:14 +0200 Subject: [PATCH] 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. --- Yattee/Localizable.xcstrings | 44 +++ .../Views/Player/QueueManagementSheet.swift | 152 +--------- Yattee/Views/Player/tvOS/TVDetailsPanel.swift | 268 +++++++++++------ .../Player/tvOS/TVDetailsPanelPages.swift | 170 +++++++++++ Yattee/Views/Player/tvOS/TVPlayerView.swift | 10 +- .../Views/Player/tvOS/TVQueueListView.swift | 180 ++++++++++++ .../TVPlayerPanelsSettingsView.swift | 274 ++++++++++++++++++ Yattee/Views/Settings/SettingsView.swift | 4 + 8 files changed, 864 insertions(+), 238 deletions(-) create mode 100644 Yattee/Views/Player/tvOS/TVDetailsPanelPages.swift create mode 100644 Yattee/Views/Player/tvOS/TVQueueListView.swift create mode 100644 Yattee/Views/Settings/PlayerControls/TVPlayerPanelsSettingsView.swift diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 44791fd5..0bcf9656 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -7577,6 +7577,17 @@ } } }, + "panel.settings.duplicatePreset" : { + "comment" : "tvOS button duplicating the active preset so it can be edited", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duplicate Preset to Edit" + } + } + } + }, "panel.settings.elements.header" : { "comment" : "Details panel page editor section header", "localizations" : { @@ -7643,6 +7654,28 @@ } } }, + "panel.settings.moveDown" : { + "comment" : "tvOS page editor button moving the page later in the order", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move Down" + } + } + } + }, + "panel.settings.moveUp" : { + "comment" : "tvOS page editor button moving the page earlier in the order", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move Up" + } + } + } + }, "panel.settings.page.enabled" : { "comment" : "Details panel page editor toggle", "localizations" : { @@ -15126,6 +15159,17 @@ } } }, + "settings.playerPanels.title" : { + "comment" : "tvOS settings row for the player details panel pages", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Player Panels" + } + } + } + }, "settings.privacy.history.account.footer %@" : { "comment" : "Footer telling the user watched videos are mirrored to the selected account's server history", "localizations" : { diff --git a/Yattee/Views/Player/QueueManagementSheet.swift b/Yattee/Views/Player/QueueManagementSheet.swift index 822332ab..c260b668 100644 --- a/Yattee/Views/Player/QueueManagementSheet.swift +++ b/Yattee/Views/Player/QueueManagementSheet.swift @@ -16,26 +16,6 @@ struct QueueManagementSheet: View { /// nil, falls back to `@Environment(\.dismiss)`. var onDismiss: (() -> Void)? - #if os(tvOS) - /// Identifies which row should receive focus when the inline panel appears. - /// Necessary because tvOS doesn't auto-focus an inline overlay the way it - /// does for `fullScreenCover`. - enum InlinePanelFocus: Hashable { - case history(String) - case nowPlaying - case upNext(String) - } - - @FocusState var inlinePanelFocus: InlinePanelFocus? - - private var inlinePanelInitialFocusTarget: InlinePanelFocus? { - if let first = history.first { return .history(first.id) } - if playerState?.currentVideo != nil { return .nowPlaying } - if let first = queue.first { return .upNext(first.id) } - return nil - } - #endif - private func performDismiss() { if let onDismiss { onDismiss() @@ -98,11 +78,6 @@ struct QueueManagementSheet: View { .ignoresSafeArea() ) .onExitCommand { performDismiss() } - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { - inlinePanelFocus = inlinePanelInitialFocusTarget - } - } } #endif @@ -199,113 +174,13 @@ struct QueueManagementSheet: View { #if os(tvOS) private var tvOSQueueListView: some View { - ScrollViewReader { proxy in - ScrollView { - VStack(alignment: .leading, spacing: 24) { - if !history.isEmpty { - sectionHeader( - String(localized: "queue.section.previously"), - count: history.count - ) - VStack(spacing: 20) { - ForEach(Array(history.enumerated()), id: \.element.id) { index, historyItem in - QueueItemRow( - queuedVideo: historyItem, - index: nil, - isCurrentlyPlaying: false, - onRemove: { }, - onTap: { playFromHistory(at: index) } - ) - .focused($inlinePanelFocus, equals: .history(historyItem.id)) - } - } - } - - if let currentVideo = playerState?.currentVideo { - sectionHeader(String(localized: "queue.section.nowPlaying")) - .id("now-playing") - - let nowPlayingItem = QueuedVideo( - video: currentVideo, - stream: playerState?.currentStream, - queueSource: nil - ) - QueueItemRow( - queuedVideo: nowPlayingItem, - index: nil, - isCurrentlyPlaying: true, - onRemove: { }, - onTap: { } - ) - .focused($inlinePanelFocus, equals: .nowPlaying) - } - - if !queue.isEmpty { - sectionHeader( - String(localized: "queue.section.upNext"), - count: queue.count - ) - VStack(spacing: 20) { - ForEach(Array(queue.enumerated()), id: \.element.id) { index, queuedVideo in - QueueItemRow( - queuedVideo: queuedVideo, - index: index + 1, - isCurrentlyPlaying: false, - onRemove: { - withAnimation { - queueManager?.removeFromQueue(id: queuedVideo.id) - } - }, - onTap: { playVideo(at: index) } - ) - .focused($inlinePanelFocus, equals: .upNext(queuedVideo.id)) - } - - if hasMoreItems { - HStack { - Spacer() - if isLoadingMore { - ProgressView().controlSize(.small) - } else { - Button { - Task { try? await queueManager?.loadMoreQueueItems() } - } label: { - Text(String(localized: "queue.sheet.loadMore")) - .font(.subheadline) - .foregroundStyle(.tint) - } - } - Spacer() - } - .padding(.top, 12) - } - } - } - } - .padding(.horizontal, 80) - .padding(.vertical, 24) + TVQueueListView( + autoFocusOnAppear: true, + onDidSelectVideo: { + navigationCoordinator?.isMiniPlayerQueueSheetPresented = false + performDismiss() } - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - proxy.scrollTo("now-playing", anchor: .center) - } - } - } - } - - @ViewBuilder - private func sectionHeader(_ title: String, count: Int? = nil) -> some View { - HStack { - Text(title.uppercased()) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - if let count { - Text(String(localized: "queue.section.count \(count)")) - .font(.caption) - .foregroundStyle(.secondary) - } - } + ) } #endif @@ -357,21 +232,6 @@ struct QueueManagementSheet: View { playerState.queueMode = modes[nextIndex] } - // MARK: - Actions - - #if os(tvOS) - private func playVideo(at index: Int) { - QueuePlaybackActions.playVideo(at: index, appEnvironment: appEnvironment) - navigationCoordinator?.isMiniPlayerQueueSheetPresented = false - performDismiss() - } - - private func playFromHistory(at index: Int) { - QueuePlaybackActions.playFromHistory(at: index, appEnvironment: appEnvironment) - navigationCoordinator?.isMiniPlayerQueueSheetPresented = false - performDismiss() - } - #endif } #Preview { diff --git a/Yattee/Views/Player/tvOS/TVDetailsPanel.swift b/Yattee/Views/Player/tvOS/TVDetailsPanel.swift index 53cc83cd..d60bb642 100644 --- a/Yattee/Views/Player/tvOS/TVDetailsPanel.swift +++ b/Yattee/Views/Player/tvOS/TVDetailsPanel.swift @@ -10,21 +10,26 @@ 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 initialTab: TVDetailsTab + let initialPage: PanelPageType let onDismiss: () -> Void @Environment(\.appEnvironment) private var appEnvironment - /// Tab selection for Info / Comments. - @State private var selectedTab: TVDetailsTab + /// Currently selected page. + @State private var selectedPage: PanelPageType - init(video: Video?, initialTab: TVDetailsTab = .info, onDismiss: @escaping () -> Void) { + /// 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.initialTab = initialTab + self.initialPage = initialPage self.onDismiss = onDismiss - _selectedTab = State(initialValue: initialTab) + _selectedPage = State(initialValue: initialPage) } /// Focus state for interactive elements. @@ -33,36 +38,68 @@ struct TVDetailsPanel: View { /// 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? + 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) { - // 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, 40) - .padding(.top, 40) - .padding(.bottom, 20) - .focused($focusedItem, equals: .tabPicker) - .transition(.opacity.combined(with: .move(edge: .top))) + // Page switcher (hidden when description scroll is locked) + if pages.count >= 2, !isDescriptionScrollLocked { + pageSwitcher(pages: pages) + .transition(.opacity.combined(with: .move(edge: .top))) } - // Content based on selected tab + // Content for the selected page Group { - switch selectedTab { - case .info: + 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) @@ -87,28 +124,88 @@ struct TVDetailsPanel: View { .onAppear { focusedItem = .tabPicker } - .onChange(of: selectedTab) { _, newTab in - // Reset scroll lock when switching tabs + .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 } - // 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 + .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 { - VStack(spacing: 0) { + let settings = infoSettings + + return VStack(spacing: 0) { // Top section with title, channel, stats (hidden when description expanded) if !isDescriptionScrollLocked { VStack(alignment: .leading, spacing: 24) { @@ -120,10 +217,14 @@ struct TVDetailsPanel: View { .foregroundStyle(.white) // Channel info row - channelRow + if settings.effectiveShowChannelRow { + channelRow + } // Stats row - statsRow + if settings.effectiveShowStatsRow { + statsRow + } } .padding(.vertical, 16) .fixedSize(horizontal: false, vertical: true) @@ -131,7 +232,8 @@ struct TVDetailsPanel: View { } // Description - expands to fill when locked - if let description = video?.description, !description.isEmpty { + if settings.effectiveShowDescription, + let description = video?.description, !description.isEmpty { TVScrollableDescription( description: description, isScrollLocked: $isDescriptionScrollLocked, @@ -265,13 +367,8 @@ struct TVDetailsPanel: View { private var commentsContent: some View { ScrollView { if let videoID = video?.id.videoID { - TVCommentsListView( - videoID: videoID, - comments: $comments, - commentsState: $commentsState, - commentsContinuation: $commentsContinuation - ) - .padding(.vertical, 16) + TVCommentsListView(videoID: videoID) + .padding(.vertical, 16) } else { Text(String(localized: "comments.unavailable")) .font(.body) @@ -292,19 +389,37 @@ struct TVDetailsPanel: View { // 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 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 { @@ -430,19 +545,22 @@ struct TVDescriptionButtonStyle: ButtonStyle { /// 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 - @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 playerState: PlayerState? { appEnvironment?.playerService.state } + + private var comments: [Comment] { playerState?.comments ?? [] } + private var commentsState: CommentsLoadState { playerState?.commentsState ?? .idle } private var canLoadMore: Bool { - commentsContinuation != nil && commentsState == .loaded + playerState?.commentsContinuation != nil && commentsState == .loaded } var body: some View { @@ -544,39 +662,15 @@ struct TVCommentsListView: View { } 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 - } + 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 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 - } + guard let playerState, let contentService, let instancesManager else { return } + await playerState.loadMoreComments(contentService: contentService, instancesManager: instancesManager) } } diff --git a/Yattee/Views/Player/tvOS/TVDetailsPanelPages.swift b/Yattee/Views/Player/tvOS/TVDetailsPanelPages.swift new file mode 100644 index 00000000..69055131 --- /dev/null +++ b/Yattee/Views/Player/tvOS/TVDetailsPanelPages.swift @@ -0,0 +1,170 @@ +// +// TVDetailsPanelPages.swift +// Yattee +// +// Related videos and chapters pages for the tvOS player details panel. +// + +#if os(tvOS) +import SwiftUI + +// MARK: - Related Videos + +/// Focus-based list of videos related to the current one. Selecting a video +/// plays it immediately and dismisses the panel. +struct TVRelatedPanelList: View { + let video: Video? + let onDidSelectVideo: () -> Void + + @Environment(\.appEnvironment) private var appEnvironment + + private var relatedVideos: [Video] { video?.relatedVideos ?? [] } + + var body: some View { + ScrollView { + LazyVStack(spacing: 20) { + ForEach(relatedVideos) { relatedVideo in + TVRelatedVideoRow(video: relatedVideo) { + playVideo(relatedVideo) + } + } + } + .padding(.vertical, 24) + } + } + + private func playVideo(_ video: Video) { + guard let playerService = appEnvironment?.playerService else { return } + Task { + await playerService.play(video: video) + } + onDidSelectVideo() + } +} + +/// Focusable row for a related video: thumbnail, title, channel. +private struct TVRelatedVideoRow: View { + let video: Video + let onTap: () -> Void + + @Environment(\.appEnvironment) private var appEnvironment + + var body: some View { + Button(action: onTap) { + HStack(spacing: 16) { + DeArrowVideoThumbnail( + video: video, + cornerRadius: 6, + duration: video.formattedDuration + ) + .frame(width: 160, height: 90) + + VStack(alignment: .leading, spacing: 4) { + Text(video.displayTitle(using: appEnvironment?.deArrowBrandingProvider)) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(2) + .foregroundStyle(.white) + .multilineTextAlignment(.leading) + + Text(video.author.name) + .font(.caption) + .foregroundStyle(.white.opacity(0.6)) + .lineLimit(1) + } + + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(TVCommentButtonStyle(hasReplies: false)) + } +} + +// MARK: - Chapters + +/// Focus-based chapter list; selecting a chapter seeks and keeps the panel +/// open so nearby chapters stay reachable. +struct TVChaptersPanelList: View { + @Environment(\.appEnvironment) private var appEnvironment + + private var playerService: PlayerService? { appEnvironment?.playerService } + private var playerState: PlayerState? { playerService?.state } + + private var chapters: [VideoChapter] { playerState?.chapters ?? [] } + private var currentTime: TimeInterval { playerState?.currentTime ?? 0 } + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 12) { + ForEach(chapters) { chapter in + TVChapterRow( + chapter: chapter, + isActive: isChapterActive(chapter), + onTap: { + Task { + await playerService?.seek(to: chapter.startTime) + } + } + ) + .id(chapter.id) + } + } + .padding(.vertical, 24) + } + .onAppear { + if let active = chapters.first(where: { isChapterActive($0) }) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + proxy.scrollTo(active.id, anchor: .center) + } + } + } + } + } + + private func isChapterActive(_ chapter: VideoChapter) -> Bool { + let nextChapter = chapters.first { $0.startTime > chapter.startTime } + let endTime = nextChapter?.startTime ?? .infinity + return currentTime >= chapter.startTime && currentTime < endTime + } +} + +/// Focusable row for a chapter: title, start time, and an active indicator. +private struct TVChapterRow: View { + let chapter: VideoChapter + let isActive: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 16) { + Text(chapter.formattedStartTime) + .font(.subheadline) + .monospacedDigit() + .foregroundStyle(isActive ? AnyShapeStyle(.tint) : AnyShapeStyle(.white.opacity(0.6))) + .frame(width: 110, alignment: .leading) + + Text(chapter.title) + .font(.subheadline) + .fontWeight(isActive ? .semibold : .regular) + .lineLimit(2) + .foregroundStyle(.white) + .multilineTextAlignment(.leading) + + Spacer() + + if isActive { + Image(systemName: "play.fill") + .font(.caption) + .foregroundStyle(.tint) + } + } + .padding(.vertical, 12) + .contentShape(Rectangle()) + } + .buttonStyle(TVCommentButtonStyle(hasReplies: false)) + } +} + +#endif diff --git a/Yattee/Views/Player/tvOS/TVPlayerView.swift b/Yattee/Views/Player/tvOS/TVPlayerView.swift index a3174335..c066db5f 100644 --- a/Yattee/Views/Player/tvOS/TVPlayerView.swift +++ b/Yattee/Views/Player/tvOS/TVPlayerView.swift @@ -45,8 +45,8 @@ struct TVPlayerView: View { /// Whether the details panel is shown. @State private var isDetailsPanelVisible = false - /// Initial tab for the details panel when opened. - @State private var detailsPanelInitialTab: TVDetailsTab = .info + /// Initial page for the details panel when opened. + @State private var detailsPanelInitialPage: PanelPageType = .info /// Whether user is scrubbing the progress bar. @State private var isScrubbing = false @@ -279,7 +279,7 @@ struct TVPlayerView: View { Spacer(minLength: 0) TVDetailsPanel( video: playerState?.currentVideo, - initialTab: detailsPanelInitialTab, + initialPage: detailsPanelInitialPage, onDismiss: { hideDetailsPanel() } ) .frame(width: geo.size.width / 2) @@ -730,9 +730,9 @@ struct TVPlayerView: View { // MARK: - Details Panel - private func showDetailsPanel(tab: TVDetailsTab = .info) { + private func showDetailsPanel(tab: PanelPageType = .info) { stopControlsTimer() - detailsPanelInitialTab = tab + detailsPanelInitialPage = tab withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { isDetailsPanelVisible = true controlsVisible = false diff --git a/Yattee/Views/Player/tvOS/TVQueueListView.swift b/Yattee/Views/Player/tvOS/TVQueueListView.swift new file mode 100644 index 00000000..3c431564 --- /dev/null +++ b/Yattee/Views/Player/tvOS/TVQueueListView.swift @@ -0,0 +1,180 @@ +// +// TVQueueListView.swift +// Yattee +// +// Focus-based queue list (history / now playing / up next) shared by the +// half-screen queue panel and the details panel queue page. +// + +#if os(tvOS) +import SwiftUI + +struct TVQueueListView: View { + /// Horizontal content padding. The standalone half-screen panel uses 80; + /// the details panel page passes its own smaller inset. + var horizontalPadding: CGFloat = 80 + + /// Whether the list should grab focus shortly after appearing. Needed for + /// the inline half-screen panel, which tvOS doesn't auto-focus the way it + /// does a `fullScreenCover`; the details panel page keeps focus on its + /// page switcher instead. + var autoFocusOnAppear = false + + /// Called after a row selection triggers playback. + var onDidSelectVideo: (() -> Void)? + + @Environment(\.appEnvironment) private var appEnvironment + + /// Identifies which row should receive focus when the list appears. + enum QueueListFocus: Hashable { + case history(String) + case nowPlaying + case upNext(String) + } + + @FocusState private var listFocus: QueueListFocus? + + private var initialFocusTarget: QueueListFocus? { + if let first = history.first { return .history(first.id) } + if playerState?.currentVideo != nil { return .nowPlaying } + if let first = queue.first { return .upNext(first.id) } + return nil + } + + private var queueManager: QueueManager? { appEnvironment?.queueManager } + private var playerState: PlayerState? { appEnvironment?.playerService.state } + + private var queue: [QueuedVideo] { playerState?.queue ?? [] } + private var history: [QueuedVideo] { playerState?.history ?? [] } + private var isLoadingMore: Bool { queueManager?.isLoadingMore ?? false } + private var hasMoreItems: Bool { queueManager?.hasMoreItems() ?? false } + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if !history.isEmpty { + sectionHeader( + String(localized: "queue.section.previously"), + count: history.count + ) + VStack(spacing: 20) { + ForEach(Array(history.enumerated()), id: \.element.id) { index, historyItem in + QueueItemRow( + queuedVideo: historyItem, + index: nil, + isCurrentlyPlaying: false, + onRemove: { }, + onTap: { playFromHistory(at: index) } + ) + .focused($listFocus, equals: .history(historyItem.id)) + } + } + } + + if let currentVideo = playerState?.currentVideo { + sectionHeader(String(localized: "queue.section.nowPlaying")) + .id("now-playing") + + let nowPlayingItem = QueuedVideo( + video: currentVideo, + stream: playerState?.currentStream, + queueSource: nil + ) + QueueItemRow( + queuedVideo: nowPlayingItem, + index: nil, + isCurrentlyPlaying: true, + onRemove: { }, + onTap: { } + ) + .focused($listFocus, equals: .nowPlaying) + } + + if !queue.isEmpty { + sectionHeader( + String(localized: "queue.section.upNext"), + count: queue.count + ) + VStack(spacing: 20) { + ForEach(Array(queue.enumerated()), id: \.element.id) { index, queuedVideo in + QueueItemRow( + queuedVideo: queuedVideo, + index: index + 1, + isCurrentlyPlaying: false, + onRemove: { + withAnimation { + queueManager?.removeFromQueue(id: queuedVideo.id) + } + }, + onTap: { playVideo(at: index) } + ) + .focused($listFocus, equals: .upNext(queuedVideo.id)) + } + + if hasMoreItems { + HStack { + Spacer() + if isLoadingMore { + ProgressView().controlSize(.small) + } else { + Button { + Task { try? await queueManager?.loadMoreQueueItems() } + } label: { + Text(String(localized: "queue.sheet.loadMore")) + .font(.subheadline) + .foregroundStyle(.tint) + } + } + Spacer() + } + .padding(.top, 12) + } + } + } + } + .padding(.horizontal, horizontalPadding) + .padding(.vertical, 24) + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + proxy.scrollTo("now-playing", anchor: .center) + } + if autoFocusOnAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { + listFocus = initialFocusTarget + } + } + } + } + } + + @ViewBuilder + private func sectionHeader(_ title: String, count: Int? = nil) -> some View { + HStack { + Text(title.uppercased()) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if let count { + Text(String(localized: "queue.section.count \(count)")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Actions + + private func playVideo(at index: Int) { + QueuePlaybackActions.playVideo(at: index, appEnvironment: appEnvironment) + onDidSelectVideo?() + } + + private func playFromHistory(at index: Int) { + QueuePlaybackActions.playFromHistory(at: index, appEnvironment: appEnvironment) + onDidSelectVideo?() + } +} + +#endif diff --git a/Yattee/Views/Settings/PlayerControls/TVPlayerPanelsSettingsView.swift b/Yattee/Views/Settings/PlayerControls/TVPlayerPanelsSettingsView.swift new file mode 100644 index 00000000..9afd83e7 --- /dev/null +++ b/Yattee/Views/Settings/PlayerControls/TVPlayerPanelsSettingsView.swift @@ -0,0 +1,274 @@ +// +// TVPlayerPanelsSettingsView.swift +// Yattee +// +// Minimal tvOS settings for the player details panel pages. Edits the +// active tvOS preset via the shared layout service; CloudKit preset sync is +// device-class filtered, so tvOS panels cannot be configured from iOS/macOS. +// + +#if os(tvOS) +import SwiftUI + +struct TVPlayerPanelsSettingsView: View { + @Environment(\.appEnvironment) private var appEnvironment + + @State private var viewModel: PlayerControlsSettingsViewModel? + + var body: some View { + Group { + if let viewModel { + TVPlayerPanelsContent(viewModel: viewModel) + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .task { + if viewModel == nil, let appEnvironment { + let vm = PlayerControlsSettingsViewModel( + layoutService: appEnvironment.playerControlsLayoutService, + settingsManager: appEnvironment.settingsManager + ) + viewModel = vm + await vm.loadPresets() + } + } + } +} + +// MARK: - Content + +private struct TVPlayerPanelsContent: View { + @Bindable var viewModel: PlayerControlsSettingsViewModel + + /// Pages offered on tvOS; tracks is covered by the quality panel. + private var tvOSPages: [PanelPageConfiguration] { + viewModel.panelPages.filter { $0.pageType != .tracks } + } + + var body: some View { + List { + presetSection + pagesSection + } + } + + // MARK: - Preset Section + + private var presetSection: some View { + Section { + LabeledContent( + String(localized: "settings.playerControls.activePreset"), + value: viewModel.activePreset?.name ?? "" + ) + + if !viewModel.canEditActivePreset { + Button { + Task { await duplicateActivePreset() } + } label: { + Label( + String(localized: "panel.settings.duplicatePreset"), + systemImage: "doc.on.doc" + ) + } + } + } footer: { + if !viewModel.canEditActivePreset { + Text(String(localized: "settings.playerControls.duplicateToEdit")) + } + } + } + + private func duplicateActivePreset() async { + guard let activePreset = viewModel.activePreset else { return } + + // Pick a unique name for the copy. + let existingNames = Set(viewModel.presets.map(\.name)) + let base = "\(activePreset.name) Copy" + var name = base + var counter = 2 + while existingNames.contains(name) { + name = "\(base) \(counter)" + counter += 1 + } + + await viewModel.createPreset(name: name, basedOn: activePreset) + } + + // MARK: - Pages Section + + private var pagesSection: some View { + Section { + ForEach(tvOSPages) { page in + NavigationLink { + TVPanelPageEditorView(pageType: page.pageType, viewModel: viewModel) + } label: { + HStack { + Label(page.pageType.displayName, systemImage: page.pageType.systemImage) + Spacer() + Text(page.isEnabled + ? String(localized: "common.on") + : String(localized: "common.off")) + .foregroundStyle(.secondary) + } + } + } + } header: { + Text(String(localized: "panel.settings.pages.header")) + } footer: { + Text(String(localized: "panel.settings.pages.footer")) + } + } +} + +// MARK: - Page Editor + +private struct TVPanelPageEditorView: View { + let pageType: PanelPageType + @Bindable var viewModel: PlayerControlsSettingsViewModel + + // Local state for immediate UI updates + @State private var isEnabled: Bool = true + @State private var showDescription: Bool = true + @State private var showStatsRow: Bool = true + @State private var showChannelRow: Bool = true + + private var configuration: PanelPageConfiguration? { + viewModel.panelSettings.configuration(for: pageType) + } + + /// Whether this is the only enabled page; the last one cannot be disabled. + private var isLastEnabledPage: Bool { + isEnabled && viewModel.enabledPanelPageCount <= 1 + } + + /// Pages as displayed in the tvOS list (tracks hidden). + private var displayedPages: [PanelPageConfiguration] { + viewModel.panelPages.filter { $0.pageType != .tracks } + } + + private var displayedIndex: Int? { + displayedPages.firstIndex { $0.pageType == pageType } + } + + var body: some View { + List { + Section { + Toggle( + String(localized: "panel.settings.page.enabled"), + isOn: $isEnabled + ) + .disabled(!viewModel.canEditActivePreset || isLastEnabledPage) + .onChange(of: isEnabled) { _, newValue in + guard newValue != configuration?.isEnabled else { return } + viewModel.setPanelPageEnabled(pageType, isEnabled: newValue) + } + + Button { + move(up: true) + } label: { + Label(String(localized: "panel.settings.moveUp"), systemImage: "arrow.up") + } + .disabled(!viewModel.canEditActivePreset || displayedIndex == 0) + + Button { + move(up: false) + } label: { + Label(String(localized: "panel.settings.moveDown"), systemImage: "arrow.down") + } + .disabled(!viewModel.canEditActivePreset || displayedIndex == displayedPages.count - 1) + } footer: { + if isLastEnabledPage { + Text(String(localized: "panel.settings.page.lastEnabled.footer")) + } else if !viewModel.canEditActivePreset { + Text(String(localized: "settings.playerControls.duplicateToEdit")) + } + } + + if pageType == .info { + infoElementsSection + } + } + .navigationTitle(pageType.displayName) + .onAppear { + syncLocalState() + } + .onChange(of: viewModel.activePreset?.id) { _, _ in + syncLocalState() + } + } + + // MARK: - Reordering + + /// Moves this page past its displayed neighbor. Indices are mapped back + /// to the full pages list, which may contain pages the tvOS UI hides. + private func move(up: Bool) { + let displayed = displayedPages + guard let index = displayed.firstIndex(where: { $0.pageType == pageType }) else { return } + let neighborIndex = up ? index - 1 : index + 1 + guard displayed.indices.contains(neighborIndex) else { return } + + let allPages = viewModel.panelPages + guard let source = allPages.firstIndex(where: { $0.pageType == pageType }), + let target = allPages.firstIndex(where: { $0.pageType == displayed[neighborIndex].pageType }) + else { return } + + viewModel.movePanelPages( + fromOffsets: IndexSet(integer: source), + toOffset: up ? target : target + 1 + ) + } + + // MARK: - Info Elements Section + + private var infoElementsSection: some View { + Section { + Toggle( + String(localized: "panel.settings.info.showStatsRow"), + isOn: $showStatsRow + ) + .onChange(of: showStatsRow) { _, newValue in + guard newValue != infoSettings.effectiveShowStatsRow else { return } + viewModel.updatePanelInfoSettingsSync { $0.showStatsRow = newValue } + } + + Toggle( + String(localized: "panel.settings.info.showChannelRow"), + isOn: $showChannelRow + ) + .onChange(of: showChannelRow) { _, newValue in + guard newValue != infoSettings.effectiveShowChannelRow else { return } + viewModel.updatePanelInfoSettingsSync { $0.showChannelRow = newValue } + } + + Toggle( + String(localized: "panel.settings.info.showDescription"), + isOn: $showDescription + ) + .onChange(of: showDescription) { _, newValue in + guard newValue != infoSettings.effectiveShowDescription else { return } + viewModel.updatePanelInfoSettingsSync { $0.showDescription = newValue } + } + } header: { + Text(String(localized: "panel.settings.elements.header")) + } + .disabled(!viewModel.canEditActivePreset) + } + + // MARK: - Helpers + + private var infoSettings: InfoPageSettings { + configuration?.infoSettings ?? InfoPageSettings() + } + + private func syncLocalState() { + isEnabled = configuration?.isEnabled ?? true + let info = infoSettings + showDescription = info.effectiveShowDescription + showStatsRow = info.effectiveShowStatsRow + showChannelRow = info.effectiveShowChannelRow + } +} + +#endif diff --git a/Yattee/Views/Settings/SettingsView.swift b/Yattee/Views/Settings/SettingsView.swift index 5e34a685..b9c1aa6e 100644 --- a/Yattee/Views/Settings/SettingsView.swift +++ b/Yattee/Views/Settings/SettingsView.swift @@ -195,6 +195,10 @@ struct SettingsView: View { Label(String(localized: "settings.playback.sectionTitle"), systemImage: "play.circle") } + NavigationLink { TVSidebarDetailContainer(systemImage: "list.bullet.rectangle", title: String(localized: "settings.playerPanels.title")) { TVPlayerPanelsSettingsView() } } label: { + Label(String(localized: "settings.playerPanels.title"), systemImage: "list.bullet.rectangle") + } + NavigationLink { TVSidebarDetailContainer(systemImage: "hand.raised", title: String(localized: "settings.privacy.title")) { PrivacySettingsView() } } label: { Label(String(localized: "settings.privacy.title"), systemImage: "hand.raised") }