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.
This commit is contained in:
Arkadiusz Fal
2026-09-22 09:37:10 +02:00
parent 052cb76702
commit c289e7576e
19 changed files with 618 additions and 68 deletions

View File

@@ -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 }

View File

@@ -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()
}
}
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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(

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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