From 8252dc0ea403ab868c78b5fb9d0c38f4f02cb934 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 2 Oct 2026 23:59:56 +0200 Subject: [PATCH] Return the tvOS details panel to the fixed Info/Comments tabs Drop the preset-driven page list on tvOS (related, chapters, queue pages and the Player Panels settings screen) and restore the segmented Info/Comments picker, removing the now-unused strings. --- Yattee/Localizable.xcstrings | 44 --- Yattee/Views/Player/tvOS/TVDetailsPanel.swift | 261 ++++++---------- .../Player/tvOS/TVDetailsPanelPages.swift | 168 ----------- Yattee/Views/Player/tvOS/TVPlayerView.swift | 10 +- .../TVPlayerPanelsSettingsView.swift | 282 ------------------ Yattee/Views/Settings/SettingsView.swift | 4 - 6 files changed, 90 insertions(+), 679 deletions(-) delete mode 100644 Yattee/Views/Player/tvOS/TVDetailsPanelPages.swift delete mode 100644 Yattee/Views/Settings/PlayerControls/TVPlayerPanelsSettingsView.swift diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index e022f58e..5994e54b 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -7373,17 +7373,6 @@ } } }, - "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" : { @@ -7483,28 +7472,6 @@ } } }, - "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" : { @@ -15132,17 +15099,6 @@ } } }, - "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/tvOS/TVDetailsPanel.swift b/Yattee/Views/Player/tvOS/TVDetailsPanel.swift index 072633c6..53cc83cd 100644 --- a/Yattee/Views/Player/tvOS/TVDetailsPanel.swift +++ b/Yattee/Views/Player/tvOS/TVDetailsPanel.swift @@ -10,26 +10,21 @@ 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 initialTab: TVDetailsTab let onDismiss: () -> Void @Environment(\.appEnvironment) private var appEnvironment - /// Currently selected page. - @State private var selectedPage: PanelPageType + /// Tab selection for Info / Comments. + @State private var selectedTab: TVDetailsTab - /// 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) { + init(video: Video?, initialTab: TVDetailsTab = .info, onDismiss: @escaping () -> Void) { self.video = video - self.initialPage = initialPage + self.initialTab = initialTab self.onDismiss = onDismiss - _selectedPage = State(initialValue: initialPage) + _selectedTab = State(initialValue: initialTab) } /// Focus state for interactive elements. @@ -38,65 +33,36 @@ struct TVDetailsPanel: View { /// 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 - } - } - - /// 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) - } + /// Comments state + @State private var comments: [Comment] = [] + @State private var commentsState: CommentsLoadState = .idle + @State private var commentsContinuation: String? 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))) + // 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))) } - // Content for the selected page + // Content based on selected tab Group { - switch effectivePage { + switch selectedTab { case .info: infoContent case .comments: commentsContent - case .related: - TVRelatedPanelList(video: video, onDidSelectVideo: onDismiss) - case .chapters: - TVChaptersPanelList() - case .queue: - TVQueueListView(horizontalPadding: 0) } } .padding(.horizontal, 40) @@ -121,88 +87,28 @@ struct TVDetailsPanel: View { .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 + .onChange(of: selectedTab) { _, newTab in + // Reset scroll lock when switching tabs 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 + // Reset comments state when switching to comments tab + if newTab == .comments && commentsState == .idle { + // Comments will load via TVCommentsListView's .task } } - } - - // 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) - } + .onChange(of: video?.supportsComments) { _, supportsComments in + // If current video doesn't support comments, switch to info tab + if supportsComments == false && selectedTab == .comments { + selectedTab = .info } - .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) { + VStack(spacing: 0) { // Top section with title, channel, stats (hidden when description expanded) if !isDescriptionScrollLocked { VStack(alignment: .leading, spacing: 24) { @@ -214,14 +120,10 @@ struct TVDetailsPanel: View { .foregroundStyle(.white) // Channel info row - if settings.effectiveShowChannelRow { - channelRow - } + channelRow // Stats row - if settings.effectiveShowStatsRow { - statsRow - } + statsRow } .padding(.vertical, 16) .fixedSize(horizontal: false, vertical: true) @@ -229,8 +131,7 @@ struct TVDetailsPanel: View { } // Description - expands to fill when locked - if settings.effectiveShowDescription, - let description = video?.description, !description.isEmpty { + if let description = video?.description, !description.isEmpty { TVScrollableDescription( description: description, isScrollLocked: $isDescriptionScrollLocked, @@ -364,8 +265,13 @@ struct TVDetailsPanel: View { private var commentsContent: some View { ScrollView { if let videoID = video?.id.videoID { - TVCommentsListView(videoID: videoID) - .padding(.vertical, 16) + TVCommentsListView( + videoID: videoID, + comments: $comments, + commentsState: $commentsState, + commentsContinuation: $commentsContinuation + ) + .padding(.vertical, 16) } else { Text(String(localized: "comments.unavailable")) .font(.body) @@ -386,37 +292,19 @@ 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 { @@ -542,22 +430,19 @@ 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 { - playerState?.commentsContinuation != nil && commentsState == .loaded + commentsContinuation != nil && commentsState == .loaded } var body: some View { @@ -659,15 +544,39 @@ struct TVCommentsListView: View { } 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) + guard commentsState == .idle else { return } + guard let contentService, let instancesManager else { return } + guard let instance = instancesManager.enabledInstances.first(where: \.isYouTubeInstance) else { return } + + commentsState = .loading + + do { + let page = try await contentService.comments(videoID: videoID, instance: instance, continuation: nil) + comments = page.comments + commentsContinuation = page.continuation + commentsState = .loaded + } catch let error as APIError where error == .commentsDisabled { + commentsState = .disabled + } catch { + commentsState = .error + } } private func loadMoreComments() async { guard canLoadMore else { return } - guard let playerState, let contentService, let instancesManager else { return } - await playerState.loadMoreComments(contentService: contentService, instancesManager: instancesManager) + 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 + } } } diff --git a/Yattee/Views/Player/tvOS/TVDetailsPanelPages.swift b/Yattee/Views/Player/tvOS/TVDetailsPanelPages.swift deleted file mode 100644 index c854fa60..00000000 --- a/Yattee/Views/Player/tvOS/TVDetailsPanelPages.swift +++ /dev/null @@ -1,168 +0,0 @@ -// -// 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 ?? [] } - - 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 { - // Observes only chapter transitions, not per-tick currentTime updates. - playerState?.activeChapterID == chapter.id - } -} - -/// 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 c066db5f..a3174335 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 page for the details panel when opened. - @State private var detailsPanelInitialPage: PanelPageType = .info + /// Initial tab for the details panel when opened. + @State private var detailsPanelInitialTab: TVDetailsTab = .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, - initialPage: detailsPanelInitialPage, + initialTab: detailsPanelInitialTab, onDismiss: { hideDetailsPanel() } ) .frame(width: geo.size.width / 2) @@ -730,9 +730,9 @@ struct TVPlayerView: View { // MARK: - Details Panel - private func showDetailsPanel(tab: PanelPageType = .info) { + private func showDetailsPanel(tab: TVDetailsTab = .info) { stopControlsTimer() - detailsPanelInitialPage = tab + detailsPanelInitialTab = tab withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { isDetailsPanelVisible = true controlsVisible = false diff --git a/Yattee/Views/Settings/PlayerControls/TVPlayerPanelsSettingsView.swift b/Yattee/Views/Settings/PlayerControls/TVPlayerPanelsSettingsView.swift deleted file mode 100644 index 66c178db..00000000 --- a/Yattee/Views/Settings/PlayerControls/TVPlayerPanelsSettingsView.swift +++ /dev/null @@ -1,282 +0,0 @@ -// -// 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. - private var tvOSPages: [PanelPageConfiguration] { - viewModel.panelPages - } - - 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) - } - - /// The pinned page (info) is always first and enabled: no toggle, no - /// reordering. - private var isPinnedPage: Bool { - pageType == PanelSettings.pinnedPageType - } - - /// Whether the displayed neighbor above is the pinned page (or there is - /// none), which blocks moving up. - private var isBelowPinnedPage: Bool { - guard let displayedIndex else { return true } - return displayedIndex <= 1 - } - - /// Pages as displayed in the tvOS list. - private var displayedPages: [PanelPageConfiguration] { - viewModel.panelPages - } - - private var displayedIndex: Int? { - displayedPages.firstIndex { $0.pageType == pageType } - } - - var body: some View { - List { - if !isPinnedPage { - Section { - Toggle( - String(localized: "panel.settings.page.enabled"), - isOn: $isEnabled - ) - .disabled(!viewModel.canEditActivePreset) - .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 || isBelowPinnedPage) - - Button { - move(up: false) - } label: { - Label(String(localized: "panel.settings.moveDown"), systemImage: "arrow.down") - } - .disabled(!viewModel.canEditActivePreset || displayedIndex == displayedPages.count - 1) - } footer: { - 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 b9c1aa6e..5e34a685 100644 --- a/Yattee/Views/Settings/SettingsView.swift +++ b/Yattee/Views/Settings/SettingsView.swift @@ -195,10 +195,6 @@ 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") }