From c289e7576eb3a2a5b2261b3c2bc46263d4dbecbb Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Tue, 22 Sep 2026 09:37:10 +0200 Subject: [PATCH] Add per-page element customization and panel settings polish Phase 5 of the player panels redesign: - Per-page element toggles now rendered and editable for every page: comments (author avatars), related (thumbnails), chapters (thumbnails), queue (previously played section), tracks (video/audio/subtitles/speed sections) - Page bar labels toggle (icons-only bar shows the selected page's name) - Layout mode schematic diagram in the details panel settings - NEW badge machinery for future page types (versionAdded on PanelPageType) - Typed per-page settings accessors on PanelPageConfiguration and VM update helpers for each page kind - Unit tests for per-page settings round-trip and accessor fallbacks The standalone quality selector sheet is unaffected: section gating defaults to everything visible and only the embedded tracks page passes preset settings through. --- Yattee/Localizable.xcstrings | 77 ++++++++ .../Models/PlayerControls/PanelSettings.swift | 51 ++++++ .../PlayerControlsSettingsViewModel.swift | 57 ++++++ Yattee/Views/Components/CommentView.swift | 10 +- .../Components/ExpandedCommentsView.swift | 3 +- Yattee/Views/Components/VideoRowView.swift | 8 +- Yattee/Views/Player/ChapterRow.swift | 54 +++--- .../Player/Panels/ChaptersPanelPage.swift | 6 +- .../Player/Panels/CommentsPanelPage.swift | 8 +- .../Panels/PlayerPanelContentView.swift | 10 +- .../Views/Player/Panels/QueueListView.swift | 5 +- .../Views/Player/Panels/QueuePanelPage.swift | 3 +- .../Player/Panels/RelatedPanelPage.swift | 11 +- .../Views/Player/Panels/TracksPanelPage.swift | 4 +- .../Player/QualitySelectorView+Sections.swift | 54 ++++-- Yattee/Views/Player/QualitySelectorView.swift | 24 ++- .../PlayerControls/PanelPageEditorView.swift | 169 +++++++++++++++++- .../PlayerControls/PanelSettingsView.swift | 93 ++++++++++ YatteeTests/PanelSettingsTests.swift | 39 ++++ 19 files changed, 618 insertions(+), 68 deletions(-) diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 0bcf9656..4ea3a39d 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -1494,6 +1494,17 @@ } } }, + "common.badge.new" : { + "comment" : "Badge shown next to recently added items", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NEW" + } + } + } + }, "common.cancel" : { "localizations" : { "en" : { @@ -7588,6 +7599,17 @@ } } }, + "panel.settings.comments.showAvatars" : { + "comment" : "Comments page element toggle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Author Avatars" + } + } + } + }, "panel.settings.elements.header" : { "comment" : "Details panel page editor section header", "localizations" : { @@ -7676,6 +7698,28 @@ } } }, + "panel.settings.pageBarLabels" : { + "comment" : "Details panel page bar labels toggle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Page Bar Labels" + } + } + } + }, + "panel.settings.pageBarLabels.footer" : { + "comment" : "Details panel page bar labels footer", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show a name next to each page icon. With labels off, only the selected page shows its name." + } + } + } + }, "panel.settings.page.enabled" : { "comment" : "Details panel page editor toggle", "localizations" : { @@ -7720,6 +7764,39 @@ } } }, + "panel.settings.queue.showHistory" : { + "comment" : "Queue page element toggle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Previously Played" + } + } + } + }, + "panel.settings.queue.showHistory.footer" : { + "comment" : "Queue page element toggle footer", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show videos played earlier in this session above the queue." + } + } + } + }, + "panel.settings.showThumbnails" : { + "comment" : "Details panel page element toggle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thumbnails" + } + } + } + }, "panel.settings.pages.header" : { "comment" : "Details panel settings section header", "localizations" : { diff --git a/Yattee/Models/PlayerControls/PanelSettings.swift b/Yattee/Models/PlayerControls/PanelSettings.swift index d960e5ff..2bf33dd0 100644 --- a/Yattee/Models/PlayerControls/PanelSettings.swift +++ b/Yattee/Models/PlayerControls/PanelSettings.swift @@ -37,6 +37,22 @@ enum PanelPageType: String, Codable, CaseIterable, Sendable { } } + /// The panel settings version when this page type was added. + /// Used for showing "NEW" badges on recently added pages. + var versionAdded: Int { + // All initial pages shipped together; bump for future page types. + return 1 + } + + /// Pages added after this version show a "NEW" badge in settings. + /// Raise it together with the release that introduces a new page type. + static let newBadgeThresholdVersion = 1 + + /// Whether this page should carry a "NEW" badge in settings. + var isRecentlyAdded: Bool { + versionAdded > Self.newBadgeThresholdVersion + } + /// SF Symbol used in the page bar and settings. var systemImage: String { switch self { @@ -165,6 +181,11 @@ struct TracksPageSettings: Codable, Hashable, Sendable { var showAudio: Bool? var showSubtitles: Bool? var showRate: Bool? + + var effectiveShowQuality: Bool { showQuality ?? true } + var effectiveShowAudio: Bool { showAudio ?? true } + var effectiveShowSubtitles: Bool { showSubtitles ?? true } + var effectiveShowRate: Bool { showRate ?? true } } /// Typed per-page settings payload. Uses the same discriminator pattern as @@ -273,6 +294,36 @@ struct PanelPageConfiguration: Codable, Hashable, Sendable, Identifiable { return InfoPageSettings() } + /// Comments page settings when set, defaults otherwise. + var commentsSettings: CommentsPageSettings { + if case .comments(let settings) = settings { return settings } + return CommentsPageSettings() + } + + /// Related page settings when set, defaults otherwise. + var relatedSettings: RelatedPageSettings { + if case .related(let settings) = settings { return settings } + return RelatedPageSettings() + } + + /// Chapters page settings when set, defaults otherwise. + var chaptersSettings: ChaptersPageSettings { + if case .chapters(let settings) = settings { return settings } + return ChaptersPageSettings() + } + + /// Queue page settings when set, defaults otherwise. + var queueSettings: QueuePageSettings { + if case .queue(let settings) = settings { return settings } + return QueuePageSettings() + } + + /// Tracks page settings when set, defaults otherwise. + var tracksSettings: TracksPageSettings { + if case .tracks(let settings) = settings { return settings } + return TracksPageSettings() + } + private enum CodingKeys: String, CodingKey { case pageType case isEnabled diff --git a/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift b/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift index 200e54c0..cbd6e1be 100644 --- a/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift +++ b/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift @@ -868,6 +868,63 @@ final class PlayerControlsSettingsViewModel { } } + /// Synchronously updates the comments page element toggles. + func updatePanelCommentsSettingsSync(_ mutation: (inout CommentsPageSettings) -> Void) { + updatePanelSettingsSync { settings in + var comments = settings.configuration(for: .comments)?.commentsSettings ?? CommentsPageSettings() + mutation(&comments) + settings.updateSettings(.comments(comments), for: .comments) + } + } + + /// Synchronously updates the related page element toggles. + func updatePanelRelatedSettingsSync(_ mutation: (inout RelatedPageSettings) -> Void) { + updatePanelSettingsSync { settings in + var related = settings.configuration(for: .related)?.relatedSettings ?? RelatedPageSettings() + mutation(&related) + settings.updateSettings(.related(related), for: .related) + } + } + + /// Synchronously updates the chapters page element toggles. + func updatePanelChaptersSettingsSync(_ mutation: (inout ChaptersPageSettings) -> Void) { + updatePanelSettingsSync { settings in + var chapters = settings.configuration(for: .chapters)?.chaptersSettings ?? ChaptersPageSettings() + mutation(&chapters) + settings.updateSettings(.chapters(chapters), for: .chapters) + } + } + + /// Synchronously updates the queue page element toggles. + func updatePanelQueueSettingsSync(_ mutation: (inout QueuePageSettings) -> Void) { + updatePanelSettingsSync { settings in + var queue = settings.configuration(for: .queue)?.queueSettings ?? QueuePageSettings() + mutation(&queue) + settings.updateSettings(.queue(queue), for: .queue) + } + } + + /// Synchronously updates the tracks page element toggles. + func updatePanelTracksSettingsSync(_ mutation: (inout TracksPageSettings) -> Void) { + updatePanelSettingsSync { settings in + var tracks = settings.configuration(for: .tracks)?.tracksSettings ?? TracksPageSettings() + mutation(&tracks) + settings.updateSettings(.tracks(tracks), for: .tracks) + } + } + + /// Whether the page bar shows labels next to icons. + var panelShowPageBarLabels: Bool { + panelSettings.effectiveShowPageBarLabels + } + + /// Synchronously updates the page bar labels setting. + func syncPanelShowPageBarLabels(_ showLabels: Bool) { + updatePanelSettingsSync { settings in + settings.showPageBarLabels = showLabels + } + } + /// Synchronously updates details panel settings with immediate UI feedback. /// Saves are debounced to avoid rapid disk writes. /// - Parameter mutation: A closure that mutates the panel settings. diff --git a/Yattee/Views/Components/CommentView.swift b/Yattee/Views/Components/CommentView.swift index b72e0d68..d8e73d3c 100644 --- a/Yattee/Views/Components/CommentView.swift +++ b/Yattee/Views/Components/CommentView.swift @@ -15,6 +15,7 @@ struct CommentView: View { let videoID: String? let source: ContentSource? let isReply: Bool + let showAvatar: Bool @State private var replies: [Comment] = [] @State private var isLoadingReplies = false @@ -26,18 +27,21 @@ struct CommentView: View { private var navigationCoordinator: NavigationCoordinator? { appEnvironment?.navigationCoordinator } private var accentColor: Color { appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor } - init(comment: Comment, videoID: String? = nil, source: ContentSource? = nil, isReply: Bool = false) { + init(comment: Comment, videoID: String? = nil, source: ContentSource? = nil, isReply: Bool = false, showAvatar: Bool = true) { self.comment = comment self.videoID = videoID self.source = source self.isReply = isReply + self.showAvatar = showAvatar } var body: some View { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top, spacing: 12) { // Author avatar - authorAvatar + if showAvatar { + authorAvatar + } VStack(alignment: .leading, spacing: 4) { // Author name and badges @@ -224,7 +228,7 @@ struct CommentView: View { if showReplies { VStack(alignment: .leading, spacing: 0) { ForEach(replies) { reply in - CommentView(comment: reply, source: source, isReply: true) + CommentView(comment: reply, source: source, isReply: true, showAvatar: showAvatar) .padding(.leading, 44) if reply.id != replies.last?.id { diff --git a/Yattee/Views/Components/ExpandedCommentsView.swift b/Yattee/Views/Components/ExpandedCommentsView.swift index 53d69621..3e90e8fc 100644 --- a/Yattee/Views/Components/ExpandedCommentsView.swift +++ b/Yattee/Views/Components/ExpandedCommentsView.swift @@ -255,6 +255,7 @@ struct CommentsListContent: View { @Environment(\.appEnvironment) private var appEnvironment let videoID: String + var showAvatars: Bool = true private var playerState: PlayerState? { appEnvironment?.playerService.state } private var contentService: ContentService? { appEnvironment?.contentService } @@ -318,7 +319,7 @@ struct CommentsListContent: View { } else { LazyVStack(alignment: .leading, spacing: 0) { ForEach(comments) { comment in - CommentView(comment: comment, videoID: videoID, source: videoSource) + CommentView(comment: comment, videoID: videoID, source: videoSource, showAvatar: showAvatars) .onAppear { if comment.id == comments.last?.id, playerState?.commentsContinuation != nil, diff --git a/Yattee/Views/Components/VideoRowView.swift b/Yattee/Views/Components/VideoRowView.swift index bf2a839a..67cae9e8 100644 --- a/Yattee/Views/Components/VideoRowView.swift +++ b/Yattee/Views/Components/VideoRowView.swift @@ -26,6 +26,8 @@ struct VideoRowView: View { var index: Int? = nil /// When true, disables internal tap handling so parent view can handle all taps. var disableInternalTapHandling: Bool = false + /// When false, hides the thumbnail (e.g. details panel related page setting). + var showThumbnail: Bool = true // Platform-specific fonts #if os(tvOS) @@ -95,8 +97,10 @@ struct VideoRowView: View { } // Thumbnail - thumbnailView - .zoomTransitionSource(id: video.id) + if showThumbnail { + thumbnailView + .zoomTransitionSource(id: video.id) + } // Info infoView diff --git a/Yattee/Views/Player/ChapterRow.swift b/Yattee/Views/Player/ChapterRow.swift index bdda5967..7cfcbc87 100644 --- a/Yattee/Views/Player/ChapterRow.swift +++ b/Yattee/Views/Player/ChapterRow.swift @@ -11,6 +11,7 @@ struct ChapterRow: View { let chapter: VideoChapter let isActive: Bool let storyboard: Storyboard? + var showThumbnail: Bool = true let onTap: () -> Void @State private var thumbnail: PlatformImage? @@ -19,29 +20,9 @@ struct ChapterRow: View { Button(action: onTap) { HStack(spacing: 12) { // Thumbnail from storyboard - Group { - if let thumbnail { - #if os(macOS) - Image(nsImage: thumbnail) - .resizable() - .aspectRatio(contentMode: .fill) - #else - Image(uiImage: thumbnail) - .resizable() - .aspectRatio(contentMode: .fill) - #endif - } else { - Rectangle() - .fill(.quaternary) - .overlay { - Image(systemName: "film") - .foregroundStyle(.secondary) - } - } + if showThumbnail { + thumbnailContent } - .frame(width: 80, height: 45) - .clipShape(RoundedRectangle(cornerRadius: 4)) - // Info VStack(alignment: .leading, spacing: 4) { Text(chapter.title) @@ -69,10 +50,37 @@ struct ChapterRow: View { .buttonStyle(.plain) .listRowBackground(isActive ? Color.accentColor.opacity(0.1) : nil) .task { - await loadThumbnail() + if showThumbnail { + await loadThumbnail() + } } } + private var thumbnailContent: some View { + Group { + if let thumbnail { + #if os(macOS) + Image(nsImage: thumbnail) + .resizable() + .aspectRatio(contentMode: .fill) + #else + Image(uiImage: thumbnail) + .resizable() + .aspectRatio(contentMode: .fill) + #endif + } else { + Rectangle() + .fill(.quaternary) + .overlay { + Image(systemName: "film") + .foregroundStyle(.secondary) + } + } + } + .frame(width: 80, height: 45) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + private func loadThumbnail() async { guard let storyboard else { return } diff --git a/Yattee/Views/Player/Panels/ChaptersPanelPage.swift b/Yattee/Views/Player/Panels/ChaptersPanelPage.swift index 553c3129..2100de75 100644 --- a/Yattee/Views/Player/Panels/ChaptersPanelPage.swift +++ b/Yattee/Views/Player/Panels/ChaptersPanelPage.swift @@ -11,6 +11,7 @@ import SwiftUI struct ChaptersPanelPage: View { let context: PanelPageContext + var settings: ChaptersPageSettings = ChaptersPageSettings() var scrollConfig: PanelScrollConfig = PanelScrollConfig() private var appEnvironment: AppEnvironment? { context.appEnvironment } @@ -29,6 +30,7 @@ struct ChaptersPanelPage: View { chapter: chapter, isActive: isChapterActive(chapter), storyboard: storyboard, + showThumbnail: settings.effectiveShowThumbnails, onTap: { Task { await playerService?.seek(to: chapter.startTime) @@ -41,7 +43,9 @@ struct ChaptersPanelPage: View { .padding(.top, 8) } .task(id: context.video.id) { - await preloadChapterSheets() + if settings.effectiveShowThumbnails { + await preloadChapterSheets() + } } } diff --git a/Yattee/Views/Player/Panels/CommentsPanelPage.swift b/Yattee/Views/Player/Panels/CommentsPanelPage.swift index 981e51e5..d5486c6d 100644 --- a/Yattee/Views/Player/Panels/CommentsPanelPage.swift +++ b/Yattee/Views/Player/Panels/CommentsPanelPage.swift @@ -11,6 +11,7 @@ import SwiftUI struct CommentsPanelPage: View { let context: PanelPageContext + var settings: CommentsPageSettings = CommentsPageSettings() var scrollConfig: PanelScrollConfig = PanelScrollConfig() private var appEnvironment: AppEnvironment? { context.appEnvironment } @@ -18,8 +19,11 @@ struct CommentsPanelPage: View { var body: some View { PanelPageScrollView(config: scrollConfig) { - CommentsListContent(videoID: context.video.id.videoID) - .padding(.horizontal) + CommentsListContent( + videoID: context.video.id.videoID, + showAvatars: settings.effectiveShowAvatars + ) + .padding(.horizontal) } .task(id: context.video.id) { // Opening the page is an explicit request; load even when the diff --git a/Yattee/Views/Player/Panels/PlayerPanelContentView.swift b/Yattee/Views/Player/Panels/PlayerPanelContentView.swift index 71ec83ea..11e1eb7f 100644 --- a/Yattee/Views/Player/Panels/PlayerPanelContentView.swift +++ b/Yattee/Views/Player/Panels/PlayerPanelContentView.swift @@ -107,19 +107,19 @@ struct PlayerPanelContentView: View { ) .id(PanelPageType.info) case .comments: - CommentsPanelPage(context: context, scrollConfig: scrollConfig) + CommentsPanelPage(context: context, settings: page.commentsSettings, scrollConfig: scrollConfig) .id(PanelPageType.comments) case .related: - RelatedPanelPage(context: context, scrollConfig: scrollConfig) + RelatedPanelPage(context: context, settings: page.relatedSettings, scrollConfig: scrollConfig) .id(PanelPageType.related) case .chapters: - ChaptersPanelPage(context: context, scrollConfig: scrollConfig) + ChaptersPanelPage(context: context, settings: page.chaptersSettings, scrollConfig: scrollConfig) .id(PanelPageType.chapters) case .queue: - QueuePanelPage(context: context, bottomInset: scrollConfig.bottomInset) + QueuePanelPage(context: context, settings: page.queueSettings, bottomInset: scrollConfig.bottomInset) .id(PanelPageType.queue) case .tracks: - TracksPanelPage(context: context, bottomInset: scrollConfig.bottomInset) + TracksPanelPage(context: context, settings: page.tracksSettings, bottomInset: scrollConfig.bottomInset) .id(PanelPageType.tracks) } } diff --git a/Yattee/Views/Player/Panels/QueueListView.swift b/Yattee/Views/Player/Panels/QueueListView.swift index a77fa169..162a4dc2 100644 --- a/Yattee/Views/Player/Panels/QueueListView.swift +++ b/Yattee/Views/Player/Panels/QueueListView.swift @@ -116,6 +116,9 @@ struct QueueListView: View { /// Called after a row tap starts playback (the host dismisses itself). var onPlay: (() -> Void)? = nil + /// When false, hides the previously-played section (queue page setting). + var showHistory: Bool = true + private var queueManager: QueueManager? { appEnvironment?.queueManager } private var playerState: PlayerState? { appEnvironment?.playerService.state } @@ -129,7 +132,7 @@ struct QueueListView: View { ScrollViewReader { proxy in List { // History section - show previously played videos - if !history.isEmpty { + if showHistory, !history.isEmpty { Section { ForEach(Array(history.enumerated()), id: \.element.id) { index, historyItem in QueueItemRow( diff --git a/Yattee/Views/Player/Panels/QueuePanelPage.swift b/Yattee/Views/Player/Panels/QueuePanelPage.swift index 1676e14e..8bbfda00 100644 --- a/Yattee/Views/Player/Panels/QueuePanelPage.swift +++ b/Yattee/Views/Player/Panels/QueuePanelPage.swift @@ -11,10 +11,11 @@ import SwiftUI struct QueuePanelPage: View { let context: PanelPageContext + var settings: QueuePageSettings = QueuePageSettings() var bottomInset: CGFloat = 0 var body: some View { - QueueListView() + QueueListView(showHistory: settings.effectiveShowHistory) .safeAreaPadding(.bottom, bottomInset) } } diff --git a/Yattee/Views/Player/Panels/RelatedPanelPage.swift b/Yattee/Views/Player/Panels/RelatedPanelPage.swift index aa6f2a22..062bf9d1 100644 --- a/Yattee/Views/Player/Panels/RelatedPanelPage.swift +++ b/Yattee/Views/Player/Panels/RelatedPanelPage.swift @@ -11,6 +11,7 @@ import SwiftUI struct RelatedPanelPage: View { let context: PanelPageContext + var settings: RelatedPageSettings = RelatedPageSettings() var scrollConfig: PanelScrollConfig = PanelScrollConfig() private var relatedVideos: [Video] { context.video.relatedVideos ?? [] } @@ -22,7 +23,11 @@ struct RelatedPanelPage: View { } else { LazyVStack(spacing: 12) { ForEach(Array(relatedVideos.enumerated()), id: \.element.id) { index, relatedVideo in - VideoRowView(video: relatedVideo, style: .regular) + VideoRowView( + video: relatedVideo, + style: .regular, + showThumbnail: settings.effectiveShowThumbnails + ) .tappableVideo( relatedVideo, queueSource: .manual, @@ -34,7 +39,9 @@ struct RelatedPanelPage: View { if index < relatedVideos.count - 1 { Divider() - .padding(.leading, VideoRowStyle.regular.thumbnailWidth + 12) + .padding(.leading, settings.effectiveShowThumbnails + ? VideoRowStyle.regular.thumbnailWidth + 12 + : 0) } } } diff --git a/Yattee/Views/Player/Panels/TracksPanelPage.swift b/Yattee/Views/Player/Panels/TracksPanelPage.swift index ae1b5ac8..b4f26df5 100644 --- a/Yattee/Views/Player/Panels/TracksPanelPage.swift +++ b/Yattee/Views/Player/Panels/TracksPanelPage.swift @@ -11,6 +11,7 @@ import SwiftUI struct TracksPanelPage: View { let context: PanelPageContext + var settings: TracksPageSettings = TracksPageSettings() var bottomInset: CGFloat = 0 private var appEnvironment: AppEnvironment? { context.appEnvironment } @@ -84,7 +85,8 @@ struct TracksPanelPage: View { await playerService.setAudioMode(enabled) } }, - isEmbedded: true + isEmbedded: true, + visibleSections: settings ) .safeAreaPadding(.bottom, bottomInset) } diff --git a/Yattee/Views/Player/QualitySelectorView+Sections.swift b/Yattee/Views/Player/QualitySelectorView+Sections.swift index 5f59f881..8d0ef3c8 100644 --- a/Yattee/Views/Player/QualitySelectorView+Sections.swift +++ b/Yattee/Views/Player/QualitySelectorView+Sections.swift @@ -78,7 +78,11 @@ extension QualitySelectorView { #endif } .onAppear { - selectedTab = initialTab + // Fall back to the first visible tab when the requested one is + // hidden by the tracks page section settings. + selectedTab = availableTabs.contains(initialTab) + ? initialTab + : (availableTabs.first ?? .video) } } @@ -90,13 +94,15 @@ extension QualitySelectorView { // tvOS: each row is its own rounded card with vertical spacing so the // system focus hover effect has clean bounds (no clipped dividers). VStack(spacing: 8) { - mediaSelectionRow( - destination: .video, - label: String(localized: "player.quality.video"), - systemImage: "film", - value: currentVideoDisplayValue - ) - .focused($inlinePanelInitialFocus) + if availableTabs.contains(.video) { + mediaSelectionRow( + destination: .video, + label: String(localized: "player.quality.video"), + systemImage: "film", + value: currentVideoDisplayValue + ) + .focused($inlinePanelInitialFocus) + } if availableTabs.contains(.audio) { mediaSelectionRow( destination: .audio, @@ -116,14 +122,18 @@ extension QualitySelectorView { } #else VStack(spacing: 0) { - mediaSelectionRow( - destination: .video, - label: String(localized: "player.quality.video"), - systemImage: "film", - value: currentVideoDisplayValue - ) + if availableTabs.contains(.video) { + mediaSelectionRow( + destination: .video, + label: String(localized: "player.quality.video"), + systemImage: "film", + value: currentVideoDisplayValue + ) + } if availableTabs.contains(.audio) { - Divider() + if availableTabs.contains(.video) { + Divider() + } mediaSelectionRow( destination: .audio, label: String(localized: "stream.audio"), @@ -132,7 +142,9 @@ extension QualitySelectorView { ) } if availableTabs.contains(.subtitles) { - Divider() + if availableTabs.contains(.video) || availableTabs.contains(.audio) { + Divider() + } mediaSelectionRow( destination: .subtitles, label: String(localized: "stream.subtitles"), @@ -334,15 +346,19 @@ extension QualitySelectorView { #if os(tvOS) // tvOS rows are styled individually to match the Settings rows. VStack(spacing: 8) { - playbackSpeedRow + if visibleSections.effectiveShowRate { + playbackSpeedRow + } audioModeRow } #else VStack(spacing: 0) { - playbackSpeedRow + if visibleSections.effectiveShowRate { + playbackSpeedRow - Divider() + Divider() + } audioModeRow diff --git a/Yattee/Views/Player/QualitySelectorView.swift b/Yattee/Views/Player/QualitySelectorView.swift index eba902da..74080848 100644 --- a/Yattee/Views/Player/QualitySelectorView.swift +++ b/Yattee/Views/Player/QualitySelectorView.swift @@ -68,6 +68,10 @@ struct QualitySelectorView: View { /// toolbar item, no sheet detents, and no minimum frame. var isEmbedded: Bool = false + /// Section visibility from the tracks page preset settings. Defaults show + /// everything, so the standalone sheet is unaffected. + var visibleSections: TracksPageSettings = TracksPageSettings() + #if os(tvOS) /// Bound to the first focusable row so we can programmatically pull focus /// into the panel on appear (the system doesn't auto-focus an inline @@ -115,16 +119,22 @@ struct QualitySelectorView: View { // MARK: - Computed Properties - /// Available tabs based on streams + /// Available tabs based on streams and the tracks page section settings var availableTabs: [QualitySelectorTab] { - var tabs: [QualitySelectorTab] = [.video] - if (hasVideoOnlyStreams && !audioStreams.isEmpty) || embeddedAudioTracks.count > 1 { + var tabs: [QualitySelectorTab] = [] + if visibleSections.effectiveShowQuality { + tabs.append(.video) + } + if visibleSections.effectiveShowAudio, + (hasVideoOnlyStreams && !audioStreams.isEmpty) || embeddedAudioTracks.count > 1 { tabs.append(.audio) } - if !captions.isEmpty || !embeddedSubtitleTracks.isEmpty || canLoadExternalSubtitles { + if visibleSections.effectiveShowSubtitles, + !captions.isEmpty || !embeddedSubtitleTracks.isEmpty || canLoadExternalSubtitles { tabs.append(.subtitles) } - return tabs + // The selector never renders with zero tabs; fall back to video. + return tabs.isEmpty ? [.video] : tabs } /// Whether the "Load subtitle from file…" row is available: media-source @@ -212,7 +222,8 @@ struct QualitySelectorView: View { onLockToggled: ((Bool) -> Void)? = nil, onAudioModeToggled: ((Bool) -> Void)? = nil, onDismiss: (() -> Void)? = nil, - isEmbedded: Bool = false + isEmbedded: Bool = false, + visibleSections: TracksPageSettings = TracksPageSettings() ) { self.streams = streams self.captions = captions @@ -244,6 +255,7 @@ struct QualitySelectorView: View { self.onAudioModeToggled = onAudioModeToggled self.onDismiss = onDismiss self.isEmbedded = isEmbedded + self.visibleSections = visibleSections } // MARK: - Body diff --git a/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift b/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift index e8ea4db9..08de087b 100644 --- a/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift +++ b/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift @@ -19,6 +19,14 @@ struct PanelPageEditorView: View { @State private var showDescription: Bool = true @State private var showStatsRow: Bool = true @State private var showChannelRow: Bool = true + @State private var showAvatars: Bool = true + @State private var showRelatedThumbnails: Bool = true + @State private var showChapterThumbnails: Bool = true + @State private var showHistory: Bool = true + @State private var showQuality: Bool = true + @State private var showAudio: Bool = true + @State private var showSubtitles: Bool = true + @State private var showRate: Bool = true private var configuration: PanelPageConfiguration? { viewModel.panelSettings.configuration(for: pageType) @@ -37,8 +45,19 @@ struct PanelPageEditorView: View { regionSection } - if pageType == .info { + switch pageType { + case .info: infoElementsSection + case .comments: + commentsElementsSection + case .related: + relatedElementsSection + case .chapters: + chaptersElementsSection + case .queue: + queueElementsSection + case .tracks: + tracksElementsSection } } .navigationTitle(pageType.displayName) @@ -136,12 +155,151 @@ struct PanelPageEditorView: View { .disabled(!viewModel.canEditActivePreset) } + // MARK: - Comments Elements Section + + private var commentsElementsSection: some View { + Section { + Toggle( + String(localized: "panel.settings.comments.showAvatars"), + isOn: $showAvatars + ) + .onChange(of: showAvatars) { _, newValue in + guard newValue != commentsSettings.effectiveShowAvatars else { return } + viewModel.updatePanelCommentsSettingsSync { $0.showAvatars = newValue } + } + } header: { + Text(String(localized: "panel.settings.elements.header")) + } + .disabled(!viewModel.canEditActivePreset) + } + + // MARK: - Related Elements Section + + private var relatedElementsSection: some View { + Section { + Toggle( + String(localized: "panel.settings.showThumbnails"), + isOn: $showRelatedThumbnails + ) + .onChange(of: showRelatedThumbnails) { _, newValue in + guard newValue != relatedSettings.effectiveShowThumbnails else { return } + viewModel.updatePanelRelatedSettingsSync { $0.showThumbnails = newValue } + } + } header: { + Text(String(localized: "panel.settings.elements.header")) + } + .disabled(!viewModel.canEditActivePreset) + } + + // MARK: - Chapters Elements Section + + private var chaptersElementsSection: some View { + Section { + Toggle( + String(localized: "panel.settings.showThumbnails"), + isOn: $showChapterThumbnails + ) + .onChange(of: showChapterThumbnails) { _, newValue in + guard newValue != chaptersSettings.effectiveShowThumbnails else { return } + viewModel.updatePanelChaptersSettingsSync { $0.showThumbnails = newValue } + } + } header: { + Text(String(localized: "panel.settings.elements.header")) + } + .disabled(!viewModel.canEditActivePreset) + } + + // MARK: - Queue Elements Section + + private var queueElementsSection: some View { + Section { + Toggle( + String(localized: "panel.settings.queue.showHistory"), + isOn: $showHistory + ) + .onChange(of: showHistory) { _, newValue in + guard newValue != queueSettings.effectiveShowHistory else { return } + viewModel.updatePanelQueueSettingsSync { $0.showHistory = newValue } + } + } header: { + Text(String(localized: "panel.settings.elements.header")) + } footer: { + Text(String(localized: "panel.settings.queue.showHistory.footer")) + } + .disabled(!viewModel.canEditActivePreset) + } + + // MARK: - Tracks Elements Section + + private var tracksElementsSection: some View { + Section { + Toggle( + String(localized: "player.quality.video"), + isOn: $showQuality + ) + .onChange(of: showQuality) { _, newValue in + guard newValue != tracksSettings.effectiveShowQuality else { return } + viewModel.updatePanelTracksSettingsSync { $0.showQuality = newValue } + } + + Toggle( + String(localized: "stream.audio"), + isOn: $showAudio + ) + .onChange(of: showAudio) { _, newValue in + guard newValue != tracksSettings.effectiveShowAudio else { return } + viewModel.updatePanelTracksSettingsSync { $0.showAudio = newValue } + } + + Toggle( + String(localized: "stream.subtitles"), + isOn: $showSubtitles + ) + .onChange(of: showSubtitles) { _, newValue in + guard newValue != tracksSettings.effectiveShowSubtitles else { return } + viewModel.updatePanelTracksSettingsSync { $0.showSubtitles = newValue } + } + + Toggle( + String(localized: "controls.button.speed"), + isOn: $showRate + ) + .onChange(of: showRate) { _, newValue in + guard newValue != tracksSettings.effectiveShowRate else { return } + viewModel.updatePanelTracksSettingsSync { $0.showRate = newValue } + } + } header: { + Text(String(localized: "panel.settings.elements.header")) + } + .disabled(!viewModel.canEditActivePreset) + } + // MARK: - Helpers private var infoSettings: InfoPageSettings { configuration?.infoSettings ?? InfoPageSettings() } + private var commentsSettings: CommentsPageSettings { + configuration?.commentsSettings ?? CommentsPageSettings() + } + + private var relatedSettings: RelatedPageSettings { + configuration?.relatedSettings ?? RelatedPageSettings() + } + + private var chaptersSettings: ChaptersPageSettings { + configuration?.chaptersSettings ?? ChaptersPageSettings() + } + + private var queueSettings: QueuePageSettings { + configuration?.queueSettings ?? QueuePageSettings() + } + + private var tracksSettings: TracksPageSettings { + configuration?.tracksSettings ?? TracksPageSettings() + } + private func syncLocalState() { isEnabled = configuration?.isEnabled ?? true region = configuration?.region ?? .primary @@ -149,6 +307,15 @@ struct PanelPageEditorView: View { showDescription = info.effectiveShowDescription showStatsRow = info.effectiveShowStatsRow showChannelRow = info.effectiveShowChannelRow + showAvatars = commentsSettings.effectiveShowAvatars + showRelatedThumbnails = relatedSettings.effectiveShowThumbnails + showChapterThumbnails = chaptersSettings.effectiveShowThumbnails + showHistory = queueSettings.effectiveShowHistory + let tracks = tracksSettings + showQuality = tracks.effectiveShowQuality + showAudio = tracks.effectiveShowAudio + showSubtitles = tracks.effectiveShowSubtitles + showRate = tracks.effectiveShowRate } } diff --git a/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift b/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift index 79776455..2ac14aa4 100644 --- a/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift +++ b/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift @@ -15,6 +15,7 @@ struct PanelSettingsView: View { // Local state for immediate UI updates @State private var defaultVisibility: PanelDefaultVisibility = .hidden @State private var layoutMode: PanelLayoutMode = .stacked + @State private var showPageBarLabels: Bool = true /// Corner layout exists on iPad and macOS only. static var supportsCornerLayout: Bool { @@ -31,6 +32,7 @@ struct PanelSettingsView: View { layoutSection } visibilitySection + pageBarSection pagesSection } .navigationTitle(String(localized: "settings.playerControls.detailsPanel")) @@ -57,6 +59,11 @@ struct PanelSettingsView: View { private var layoutSection: some View { Section { + PanelLayoutSchematic(mode: layoutMode) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + .listRowBackground(Color.clear) + Picker( String(localized: "panel.settings.layoutMode"), selection: $layoutMode @@ -97,6 +104,24 @@ struct PanelSettingsView: View { } } + // MARK: - Page Bar Section + + private var pageBarSection: some View { + Section { + Toggle( + String(localized: "panel.settings.pageBarLabels"), + isOn: $showPageBarLabels + ) + .disabled(!viewModel.canEditActivePreset) + .onChange(of: showPageBarLabels) { _, newValue in + guard newValue != viewModel.panelShowPageBarLabels else { return } + viewModel.syncPanelShowPageBarLabels(newValue) + } + } footer: { + Text(String(localized: "panel.settings.pageBarLabels.footer")) + } + } + // MARK: - Pages Section private var pagesSection: some View { @@ -126,6 +151,14 @@ struct PanelSettingsView: View { } label: { HStack { Label(page.pageType.displayName, systemImage: page.pageType.systemImage) + if page.pageType.isRecentlyAdded { + Text(String(localized: "common.badge.new")) + .font(.caption2.weight(.bold)) + .foregroundStyle(.white) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Capsule().fill(.tint)) + } Spacer() if page.isEnabled, layoutMode == .corner, Self.supportsCornerLayout { Text(page.region.displayName) @@ -145,6 +178,66 @@ struct PanelSettingsView: View { private func syncLocalState() { defaultVisibility = viewModel.panelDefaultVisibility layoutMode = viewModel.panelLayoutMode + showPageBarLabels = viewModel.panelShowPageBarLabels + } +} + +// MARK: - Layout Schematic + +/// Small diagram of the player window for the layout mode picker: the video +/// area plus where the details panel regions sit in each mode. +private struct PanelLayoutSchematic: View { + let mode: PanelLayoutMode + + private let cornerRadius: CGFloat = 4 + + var body: some View { + HStack(spacing: 3) { + if mode == .corner { + VStack(spacing: 3) { + videoBlock + panelBlock + .frame(height: 26) + } + } else { + videoBlock + } + + panelBlock + .frame(width: 44) + } + .padding(6) + .frame(width: 180, height: 110) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(.quaternary.opacity(0.4)) + } + .animation(.easeInOut(duration: 0.25), value: mode) + } + + private var videoBlock: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.secondary) + .overlay { + Image(systemName: "play.fill") + .font(.caption) + .foregroundStyle(.background) + } + } + + private var panelBlock: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.quaternary) + .overlay { + VStack(alignment: .leading, spacing: 3) { + ForEach(0..<3, id: \.self) { _ in + Capsule() + .fill(.secondary.opacity(0.5)) + .frame(height: 3) + } + } + .padding(6) + } } } diff --git a/YatteeTests/PanelSettingsTests.swift b/YatteeTests/PanelSettingsTests.swift index cb835acc..83aaacde 100644 --- a/YatteeTests/PanelSettingsTests.swift +++ b/YatteeTests/PanelSettingsTests.swift @@ -50,6 +50,45 @@ struct PanelSettingsTests { #expect(info?.effectiveShowChannelRow == true) } + @Test("Per-page element settings round-trip for every page type") + func perPageSettingsRoundTrip() throws { + var settings = PanelSettings.default + settings.updateSettings(.comments(CommentsPageSettings(showAvatars: false)), for: .comments) + settings.updateSettings(.related(RelatedPageSettings(showThumbnails: false)), for: .related) + settings.updateSettings(.chapters(ChaptersPageSettings(showThumbnails: false)), for: .chapters) + settings.updateSettings(.queue(QueuePageSettings(showHistory: false)), for: .queue) + settings.updateSettings( + .tracks(TracksPageSettings(showQuality: false, showSubtitles: false)), + for: .tracks + ) + + let decoded = try roundTrip(settings) + #expect(decoded == settings) + #expect(decoded.configuration(for: .comments)?.commentsSettings.effectiveShowAvatars == false) + #expect(decoded.configuration(for: .related)?.relatedSettings.effectiveShowThumbnails == false) + #expect(decoded.configuration(for: .chapters)?.chaptersSettings.effectiveShowThumbnails == false) + #expect(decoded.configuration(for: .queue)?.queueSettings.effectiveShowHistory == false) + + let tracks = try #require(decoded.configuration(for: .tracks)?.tracksSettings) + #expect(tracks.effectiveShowQuality == false) + #expect(tracks.effectiveShowSubtitles == false) + // Unset fields keep their effective defaults + #expect(tracks.effectiveShowAudio == true) + #expect(tracks.effectiveShowRate == true) + } + + @Test("Typed accessors fall back to defaults for mismatched payloads") + func typedAccessorFallbacks() { + // An info payload read through the comments accessor yields defaults. + let page = PanelPageConfiguration( + pageType: .comments, + settings: .info(InfoPageSettings(showStatsRow: false)) + ) + #expect(page.commentsSettings.effectiveShowAvatars == true) + #expect(page.queueSettings.effectiveShowHistory == true) + #expect(page.tracksSettings.effectiveShowQuality == true) + } + // MARK: - Backward Compatibility @Test("Layout without panelSettings decodes and falls back to default")