mirror of
https://github.com/yattee/yattee.git
synced 2026-10-31 08:57:17 +00:00
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:
@@ -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 }
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user