mirror of
https://github.com/yattee/yattee.git
synced 2026-11-02 09:57:20 +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:
@@ -1494,6 +1494,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"common.badge.new" : {
|
||||||
|
"comment" : "Badge shown next to recently added items",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "NEW"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"common.cancel" : {
|
"common.cancel" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"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" : {
|
"panel.settings.elements.header" : {
|
||||||
"comment" : "Details panel page editor section header",
|
"comment" : "Details panel page editor section header",
|
||||||
"localizations" : {
|
"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" : {
|
"panel.settings.page.enabled" : {
|
||||||
"comment" : "Details panel page editor toggle",
|
"comment" : "Details panel page editor toggle",
|
||||||
"localizations" : {
|
"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" : {
|
"panel.settings.pages.header" : {
|
||||||
"comment" : "Details panel settings section header",
|
"comment" : "Details panel settings section header",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -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.
|
/// SF Symbol used in the page bar and settings.
|
||||||
var systemImage: String {
|
var systemImage: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -165,6 +181,11 @@ struct TracksPageSettings: Codable, Hashable, Sendable {
|
|||||||
var showAudio: Bool?
|
var showAudio: Bool?
|
||||||
var showSubtitles: Bool?
|
var showSubtitles: Bool?
|
||||||
var showRate: 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
|
/// Typed per-page settings payload. Uses the same discriminator pattern as
|
||||||
@@ -273,6 +294,36 @@ struct PanelPageConfiguration: Codable, Hashable, Sendable, Identifiable {
|
|||||||
return InfoPageSettings()
|
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 {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case pageType
|
case pageType
|
||||||
case isEnabled
|
case isEnabled
|
||||||
|
|||||||
@@ -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.
|
/// Synchronously updates details panel settings with immediate UI feedback.
|
||||||
/// Saves are debounced to avoid rapid disk writes.
|
/// Saves are debounced to avoid rapid disk writes.
|
||||||
/// - Parameter mutation: A closure that mutates the panel settings.
|
/// - Parameter mutation: A closure that mutates the panel settings.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ struct CommentView: View {
|
|||||||
let videoID: String?
|
let videoID: String?
|
||||||
let source: ContentSource?
|
let source: ContentSource?
|
||||||
let isReply: Bool
|
let isReply: Bool
|
||||||
|
let showAvatar: Bool
|
||||||
|
|
||||||
@State private var replies: [Comment] = []
|
@State private var replies: [Comment] = []
|
||||||
@State private var isLoadingReplies = false
|
@State private var isLoadingReplies = false
|
||||||
@@ -26,18 +27,21 @@ struct CommentView: View {
|
|||||||
private var navigationCoordinator: NavigationCoordinator? { appEnvironment?.navigationCoordinator }
|
private var navigationCoordinator: NavigationCoordinator? { appEnvironment?.navigationCoordinator }
|
||||||
private var accentColor: Color { appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor }
|
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.comment = comment
|
||||||
self.videoID = videoID
|
self.videoID = videoID
|
||||||
self.source = source
|
self.source = source
|
||||||
self.isReply = isReply
|
self.isReply = isReply
|
||||||
|
self.showAvatar = showAvatar
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
// Author avatar
|
// Author avatar
|
||||||
authorAvatar
|
if showAvatar {
|
||||||
|
authorAvatar
|
||||||
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
// Author name and badges
|
// Author name and badges
|
||||||
@@ -224,7 +228,7 @@ struct CommentView: View {
|
|||||||
if showReplies {
|
if showReplies {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
ForEach(replies) { reply in
|
ForEach(replies) { reply in
|
||||||
CommentView(comment: reply, source: source, isReply: true)
|
CommentView(comment: reply, source: source, isReply: true, showAvatar: showAvatar)
|
||||||
.padding(.leading, 44)
|
.padding(.leading, 44)
|
||||||
|
|
||||||
if reply.id != replies.last?.id {
|
if reply.id != replies.last?.id {
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ struct CommentsListContent: View {
|
|||||||
@Environment(\.appEnvironment) private var appEnvironment
|
@Environment(\.appEnvironment) private var appEnvironment
|
||||||
|
|
||||||
let videoID: String
|
let videoID: String
|
||||||
|
var showAvatars: Bool = true
|
||||||
|
|
||||||
private var playerState: PlayerState? { appEnvironment?.playerService.state }
|
private var playerState: PlayerState? { appEnvironment?.playerService.state }
|
||||||
private var contentService: ContentService? { appEnvironment?.contentService }
|
private var contentService: ContentService? { appEnvironment?.contentService }
|
||||||
@@ -318,7 +319,7 @@ struct CommentsListContent: View {
|
|||||||
} else {
|
} else {
|
||||||
LazyVStack(alignment: .leading, spacing: 0) {
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
ForEach(comments) { comment in
|
ForEach(comments) { comment in
|
||||||
CommentView(comment: comment, videoID: videoID, source: videoSource)
|
CommentView(comment: comment, videoID: videoID, source: videoSource, showAvatar: showAvatars)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if comment.id == comments.last?.id,
|
if comment.id == comments.last?.id,
|
||||||
playerState?.commentsContinuation != nil,
|
playerState?.commentsContinuation != nil,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ struct VideoRowView: View {
|
|||||||
var index: Int? = nil
|
var index: Int? = nil
|
||||||
/// When true, disables internal tap handling so parent view can handle all taps.
|
/// When true, disables internal tap handling so parent view can handle all taps.
|
||||||
var disableInternalTapHandling: Bool = false
|
var disableInternalTapHandling: Bool = false
|
||||||
|
/// When false, hides the thumbnail (e.g. details panel related page setting).
|
||||||
|
var showThumbnail: Bool = true
|
||||||
|
|
||||||
// Platform-specific fonts
|
// Platform-specific fonts
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
@@ -95,8 +97,10 @@ struct VideoRowView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Thumbnail
|
// Thumbnail
|
||||||
thumbnailView
|
if showThumbnail {
|
||||||
.zoomTransitionSource(id: video.id)
|
thumbnailView
|
||||||
|
.zoomTransitionSource(id: video.id)
|
||||||
|
}
|
||||||
|
|
||||||
// Info
|
// Info
|
||||||
infoView
|
infoView
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct ChapterRow: View {
|
|||||||
let chapter: VideoChapter
|
let chapter: VideoChapter
|
||||||
let isActive: Bool
|
let isActive: Bool
|
||||||
let storyboard: Storyboard?
|
let storyboard: Storyboard?
|
||||||
|
var showThumbnail: Bool = true
|
||||||
let onTap: () -> Void
|
let onTap: () -> Void
|
||||||
|
|
||||||
@State private var thumbnail: PlatformImage?
|
@State private var thumbnail: PlatformImage?
|
||||||
@@ -19,29 +20,9 @@ struct ChapterRow: View {
|
|||||||
Button(action: onTap) {
|
Button(action: onTap) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
// Thumbnail from storyboard
|
// Thumbnail from storyboard
|
||||||
Group {
|
if showThumbnail {
|
||||||
if let thumbnail {
|
thumbnailContent
|
||||||
#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))
|
|
||||||
|
|
||||||
// Info
|
// Info
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(chapter.title)
|
Text(chapter.title)
|
||||||
@@ -69,10 +50,37 @@ struct ChapterRow: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.listRowBackground(isActive ? Color.accentColor.opacity(0.1) : nil)
|
.listRowBackground(isActive ? Color.accentColor.opacity(0.1) : nil)
|
||||||
.task {
|
.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 {
|
private func loadThumbnail() async {
|
||||||
guard let storyboard else { return }
|
guard let storyboard else { return }
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ChaptersPanelPage: View {
|
struct ChaptersPanelPage: View {
|
||||||
let context: PanelPageContext
|
let context: PanelPageContext
|
||||||
|
var settings: ChaptersPageSettings = ChaptersPageSettings()
|
||||||
var scrollConfig: PanelScrollConfig = PanelScrollConfig()
|
var scrollConfig: PanelScrollConfig = PanelScrollConfig()
|
||||||
|
|
||||||
private var appEnvironment: AppEnvironment? { context.appEnvironment }
|
private var appEnvironment: AppEnvironment? { context.appEnvironment }
|
||||||
@@ -29,6 +30,7 @@ struct ChaptersPanelPage: View {
|
|||||||
chapter: chapter,
|
chapter: chapter,
|
||||||
isActive: isChapterActive(chapter),
|
isActive: isChapterActive(chapter),
|
||||||
storyboard: storyboard,
|
storyboard: storyboard,
|
||||||
|
showThumbnail: settings.effectiveShowThumbnails,
|
||||||
onTap: {
|
onTap: {
|
||||||
Task {
|
Task {
|
||||||
await playerService?.seek(to: chapter.startTime)
|
await playerService?.seek(to: chapter.startTime)
|
||||||
@@ -41,7 +43,9 @@ struct ChaptersPanelPage: View {
|
|||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.task(id: context.video.id) {
|
.task(id: context.video.id) {
|
||||||
await preloadChapterSheets()
|
if settings.effectiveShowThumbnails {
|
||||||
|
await preloadChapterSheets()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct CommentsPanelPage: View {
|
struct CommentsPanelPage: View {
|
||||||
let context: PanelPageContext
|
let context: PanelPageContext
|
||||||
|
var settings: CommentsPageSettings = CommentsPageSettings()
|
||||||
var scrollConfig: PanelScrollConfig = PanelScrollConfig()
|
var scrollConfig: PanelScrollConfig = PanelScrollConfig()
|
||||||
|
|
||||||
private var appEnvironment: AppEnvironment? { context.appEnvironment }
|
private var appEnvironment: AppEnvironment? { context.appEnvironment }
|
||||||
@@ -18,8 +19,11 @@ struct CommentsPanelPage: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
PanelPageScrollView(config: scrollConfig) {
|
PanelPageScrollView(config: scrollConfig) {
|
||||||
CommentsListContent(videoID: context.video.id.videoID)
|
CommentsListContent(
|
||||||
.padding(.horizontal)
|
videoID: context.video.id.videoID,
|
||||||
|
showAvatars: settings.effectiveShowAvatars
|
||||||
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
.task(id: context.video.id) {
|
.task(id: context.video.id) {
|
||||||
// Opening the page is an explicit request; load even when the
|
// Opening the page is an explicit request; load even when the
|
||||||
|
|||||||
@@ -107,19 +107,19 @@ struct PlayerPanelContentView: View {
|
|||||||
)
|
)
|
||||||
.id(PanelPageType.info)
|
.id(PanelPageType.info)
|
||||||
case .comments:
|
case .comments:
|
||||||
CommentsPanelPage(context: context, scrollConfig: scrollConfig)
|
CommentsPanelPage(context: context, settings: page.commentsSettings, scrollConfig: scrollConfig)
|
||||||
.id(PanelPageType.comments)
|
.id(PanelPageType.comments)
|
||||||
case .related:
|
case .related:
|
||||||
RelatedPanelPage(context: context, scrollConfig: scrollConfig)
|
RelatedPanelPage(context: context, settings: page.relatedSettings, scrollConfig: scrollConfig)
|
||||||
.id(PanelPageType.related)
|
.id(PanelPageType.related)
|
||||||
case .chapters:
|
case .chapters:
|
||||||
ChaptersPanelPage(context: context, scrollConfig: scrollConfig)
|
ChaptersPanelPage(context: context, settings: page.chaptersSettings, scrollConfig: scrollConfig)
|
||||||
.id(PanelPageType.chapters)
|
.id(PanelPageType.chapters)
|
||||||
case .queue:
|
case .queue:
|
||||||
QueuePanelPage(context: context, bottomInset: scrollConfig.bottomInset)
|
QueuePanelPage(context: context, settings: page.queueSettings, bottomInset: scrollConfig.bottomInset)
|
||||||
.id(PanelPageType.queue)
|
.id(PanelPageType.queue)
|
||||||
case .tracks:
|
case .tracks:
|
||||||
TracksPanelPage(context: context, bottomInset: scrollConfig.bottomInset)
|
TracksPanelPage(context: context, settings: page.tracksSettings, bottomInset: scrollConfig.bottomInset)
|
||||||
.id(PanelPageType.tracks)
|
.id(PanelPageType.tracks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ struct QueueListView: View {
|
|||||||
/// Called after a row tap starts playback (the host dismisses itself).
|
/// Called after a row tap starts playback (the host dismisses itself).
|
||||||
var onPlay: (() -> Void)? = nil
|
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 queueManager: QueueManager? { appEnvironment?.queueManager }
|
||||||
private var playerState: PlayerState? { appEnvironment?.playerService.state }
|
private var playerState: PlayerState? { appEnvironment?.playerService.state }
|
||||||
|
|
||||||
@@ -129,7 +132,7 @@ struct QueueListView: View {
|
|||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
List {
|
List {
|
||||||
// History section - show previously played videos
|
// History section - show previously played videos
|
||||||
if !history.isEmpty {
|
if showHistory, !history.isEmpty {
|
||||||
Section {
|
Section {
|
||||||
ForEach(Array(history.enumerated()), id: \.element.id) { index, historyItem in
|
ForEach(Array(history.enumerated()), id: \.element.id) { index, historyItem in
|
||||||
QueueItemRow(
|
QueueItemRow(
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import SwiftUI
|
|||||||
|
|
||||||
struct QueuePanelPage: View {
|
struct QueuePanelPage: View {
|
||||||
let context: PanelPageContext
|
let context: PanelPageContext
|
||||||
|
var settings: QueuePageSettings = QueuePageSettings()
|
||||||
var bottomInset: CGFloat = 0
|
var bottomInset: CGFloat = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
QueueListView()
|
QueueListView(showHistory: settings.effectiveShowHistory)
|
||||||
.safeAreaPadding(.bottom, bottomInset)
|
.safeAreaPadding(.bottom, bottomInset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct RelatedPanelPage: View {
|
struct RelatedPanelPage: View {
|
||||||
let context: PanelPageContext
|
let context: PanelPageContext
|
||||||
|
var settings: RelatedPageSettings = RelatedPageSettings()
|
||||||
var scrollConfig: PanelScrollConfig = PanelScrollConfig()
|
var scrollConfig: PanelScrollConfig = PanelScrollConfig()
|
||||||
|
|
||||||
private var relatedVideos: [Video] { context.video.relatedVideos ?? [] }
|
private var relatedVideos: [Video] { context.video.relatedVideos ?? [] }
|
||||||
@@ -22,7 +23,11 @@ struct RelatedPanelPage: View {
|
|||||||
} else {
|
} else {
|
||||||
LazyVStack(spacing: 12) {
|
LazyVStack(spacing: 12) {
|
||||||
ForEach(Array(relatedVideos.enumerated()), id: \.element.id) { index, relatedVideo in
|
ForEach(Array(relatedVideos.enumerated()), id: \.element.id) { index, relatedVideo in
|
||||||
VideoRowView(video: relatedVideo, style: .regular)
|
VideoRowView(
|
||||||
|
video: relatedVideo,
|
||||||
|
style: .regular,
|
||||||
|
showThumbnail: settings.effectiveShowThumbnails
|
||||||
|
)
|
||||||
.tappableVideo(
|
.tappableVideo(
|
||||||
relatedVideo,
|
relatedVideo,
|
||||||
queueSource: .manual,
|
queueSource: .manual,
|
||||||
@@ -34,7 +39,9 @@ struct RelatedPanelPage: View {
|
|||||||
|
|
||||||
if index < relatedVideos.count - 1 {
|
if index < relatedVideos.count - 1 {
|
||||||
Divider()
|
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 {
|
struct TracksPanelPage: View {
|
||||||
let context: PanelPageContext
|
let context: PanelPageContext
|
||||||
|
var settings: TracksPageSettings = TracksPageSettings()
|
||||||
var bottomInset: CGFloat = 0
|
var bottomInset: CGFloat = 0
|
||||||
|
|
||||||
private var appEnvironment: AppEnvironment? { context.appEnvironment }
|
private var appEnvironment: AppEnvironment? { context.appEnvironment }
|
||||||
@@ -84,7 +85,8 @@ struct TracksPanelPage: View {
|
|||||||
await playerService.setAudioMode(enabled)
|
await playerService.setAudioMode(enabled)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isEmbedded: true
|
isEmbedded: true,
|
||||||
|
visibleSections: settings
|
||||||
)
|
)
|
||||||
.safeAreaPadding(.bottom, bottomInset)
|
.safeAreaPadding(.bottom, bottomInset)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,11 @@ extension QualitySelectorView {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.onAppear {
|
.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
|
// tvOS: each row is its own rounded card with vertical spacing so the
|
||||||
// system focus hover effect has clean bounds (no clipped dividers).
|
// system focus hover effect has clean bounds (no clipped dividers).
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
mediaSelectionRow(
|
if availableTabs.contains(.video) {
|
||||||
destination: .video,
|
mediaSelectionRow(
|
||||||
label: String(localized: "player.quality.video"),
|
destination: .video,
|
||||||
systemImage: "film",
|
label: String(localized: "player.quality.video"),
|
||||||
value: currentVideoDisplayValue
|
systemImage: "film",
|
||||||
)
|
value: currentVideoDisplayValue
|
||||||
.focused($inlinePanelInitialFocus)
|
)
|
||||||
|
.focused($inlinePanelInitialFocus)
|
||||||
|
}
|
||||||
if availableTabs.contains(.audio) {
|
if availableTabs.contains(.audio) {
|
||||||
mediaSelectionRow(
|
mediaSelectionRow(
|
||||||
destination: .audio,
|
destination: .audio,
|
||||||
@@ -116,14 +122,18 @@ extension QualitySelectorView {
|
|||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
mediaSelectionRow(
|
if availableTabs.contains(.video) {
|
||||||
destination: .video,
|
mediaSelectionRow(
|
||||||
label: String(localized: "player.quality.video"),
|
destination: .video,
|
||||||
systemImage: "film",
|
label: String(localized: "player.quality.video"),
|
||||||
value: currentVideoDisplayValue
|
systemImage: "film",
|
||||||
)
|
value: currentVideoDisplayValue
|
||||||
|
)
|
||||||
|
}
|
||||||
if availableTabs.contains(.audio) {
|
if availableTabs.contains(.audio) {
|
||||||
Divider()
|
if availableTabs.contains(.video) {
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
mediaSelectionRow(
|
mediaSelectionRow(
|
||||||
destination: .audio,
|
destination: .audio,
|
||||||
label: String(localized: "stream.audio"),
|
label: String(localized: "stream.audio"),
|
||||||
@@ -132,7 +142,9 @@ extension QualitySelectorView {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if availableTabs.contains(.subtitles) {
|
if availableTabs.contains(.subtitles) {
|
||||||
Divider()
|
if availableTabs.contains(.video) || availableTabs.contains(.audio) {
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
mediaSelectionRow(
|
mediaSelectionRow(
|
||||||
destination: .subtitles,
|
destination: .subtitles,
|
||||||
label: String(localized: "stream.subtitles"),
|
label: String(localized: "stream.subtitles"),
|
||||||
@@ -334,15 +346,19 @@ extension QualitySelectorView {
|
|||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// tvOS rows are styled individually to match the Settings rows.
|
// tvOS rows are styled individually to match the Settings rows.
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
playbackSpeedRow
|
if visibleSections.effectiveShowRate {
|
||||||
|
playbackSpeedRow
|
||||||
|
}
|
||||||
|
|
||||||
audioModeRow
|
audioModeRow
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
playbackSpeedRow
|
if visibleSections.effectiveShowRate {
|
||||||
|
playbackSpeedRow
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
}
|
||||||
|
|
||||||
audioModeRow
|
audioModeRow
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ struct QualitySelectorView: View {
|
|||||||
/// toolbar item, no sheet detents, and no minimum frame.
|
/// toolbar item, no sheet detents, and no minimum frame.
|
||||||
var isEmbedded: Bool = false
|
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)
|
#if os(tvOS)
|
||||||
/// Bound to the first focusable row so we can programmatically pull focus
|
/// 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
|
/// into the panel on appear (the system doesn't auto-focus an inline
|
||||||
@@ -115,16 +119,22 @@ struct QualitySelectorView: View {
|
|||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
/// Available tabs based on streams
|
/// Available tabs based on streams and the tracks page section settings
|
||||||
var availableTabs: [QualitySelectorTab] {
|
var availableTabs: [QualitySelectorTab] {
|
||||||
var tabs: [QualitySelectorTab] = [.video]
|
var tabs: [QualitySelectorTab] = []
|
||||||
if (hasVideoOnlyStreams && !audioStreams.isEmpty) || embeddedAudioTracks.count > 1 {
|
if visibleSections.effectiveShowQuality {
|
||||||
|
tabs.append(.video)
|
||||||
|
}
|
||||||
|
if visibleSections.effectiveShowAudio,
|
||||||
|
(hasVideoOnlyStreams && !audioStreams.isEmpty) || embeddedAudioTracks.count > 1 {
|
||||||
tabs.append(.audio)
|
tabs.append(.audio)
|
||||||
}
|
}
|
||||||
if !captions.isEmpty || !embeddedSubtitleTracks.isEmpty || canLoadExternalSubtitles {
|
if visibleSections.effectiveShowSubtitles,
|
||||||
|
!captions.isEmpty || !embeddedSubtitleTracks.isEmpty || canLoadExternalSubtitles {
|
||||||
tabs.append(.subtitles)
|
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
|
/// Whether the "Load subtitle from file…" row is available: media-source
|
||||||
@@ -212,7 +222,8 @@ struct QualitySelectorView: View {
|
|||||||
onLockToggled: ((Bool) -> Void)? = nil,
|
onLockToggled: ((Bool) -> Void)? = nil,
|
||||||
onAudioModeToggled: ((Bool) -> Void)? = nil,
|
onAudioModeToggled: ((Bool) -> Void)? = nil,
|
||||||
onDismiss: (() -> Void)? = nil,
|
onDismiss: (() -> Void)? = nil,
|
||||||
isEmbedded: Bool = false
|
isEmbedded: Bool = false,
|
||||||
|
visibleSections: TracksPageSettings = TracksPageSettings()
|
||||||
) {
|
) {
|
||||||
self.streams = streams
|
self.streams = streams
|
||||||
self.captions = captions
|
self.captions = captions
|
||||||
@@ -244,6 +255,7 @@ struct QualitySelectorView: View {
|
|||||||
self.onAudioModeToggled = onAudioModeToggled
|
self.onAudioModeToggled = onAudioModeToggled
|
||||||
self.onDismiss = onDismiss
|
self.onDismiss = onDismiss
|
||||||
self.isEmbedded = isEmbedded
|
self.isEmbedded = isEmbedded
|
||||||
|
self.visibleSections = visibleSections
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ struct PanelPageEditorView: View {
|
|||||||
@State private var showDescription: Bool = true
|
@State private var showDescription: Bool = true
|
||||||
@State private var showStatsRow: Bool = true
|
@State private var showStatsRow: Bool = true
|
||||||
@State private var showChannelRow: 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? {
|
private var configuration: PanelPageConfiguration? {
|
||||||
viewModel.panelSettings.configuration(for: pageType)
|
viewModel.panelSettings.configuration(for: pageType)
|
||||||
@@ -37,8 +45,19 @@ struct PanelPageEditorView: View {
|
|||||||
regionSection
|
regionSection
|
||||||
}
|
}
|
||||||
|
|
||||||
if pageType == .info {
|
switch pageType {
|
||||||
|
case .info:
|
||||||
infoElementsSection
|
infoElementsSection
|
||||||
|
case .comments:
|
||||||
|
commentsElementsSection
|
||||||
|
case .related:
|
||||||
|
relatedElementsSection
|
||||||
|
case .chapters:
|
||||||
|
chaptersElementsSection
|
||||||
|
case .queue:
|
||||||
|
queueElementsSection
|
||||||
|
case .tracks:
|
||||||
|
tracksElementsSection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(pageType.displayName)
|
.navigationTitle(pageType.displayName)
|
||||||
@@ -136,12 +155,151 @@ struct PanelPageEditorView: View {
|
|||||||
.disabled(!viewModel.canEditActivePreset)
|
.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
|
// MARK: - Helpers
|
||||||
|
|
||||||
private var infoSettings: InfoPageSettings {
|
private var infoSettings: InfoPageSettings {
|
||||||
configuration?.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() {
|
private func syncLocalState() {
|
||||||
isEnabled = configuration?.isEnabled ?? true
|
isEnabled = configuration?.isEnabled ?? true
|
||||||
region = configuration?.region ?? .primary
|
region = configuration?.region ?? .primary
|
||||||
@@ -149,6 +307,15 @@ struct PanelPageEditorView: View {
|
|||||||
showDescription = info.effectiveShowDescription
|
showDescription = info.effectiveShowDescription
|
||||||
showStatsRow = info.effectiveShowStatsRow
|
showStatsRow = info.effectiveShowStatsRow
|
||||||
showChannelRow = info.effectiveShowChannelRow
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ struct PanelSettingsView: View {
|
|||||||
// Local state for immediate UI updates
|
// Local state for immediate UI updates
|
||||||
@State private var defaultVisibility: PanelDefaultVisibility = .hidden
|
@State private var defaultVisibility: PanelDefaultVisibility = .hidden
|
||||||
@State private var layoutMode: PanelLayoutMode = .stacked
|
@State private var layoutMode: PanelLayoutMode = .stacked
|
||||||
|
@State private var showPageBarLabels: Bool = true
|
||||||
|
|
||||||
/// Corner layout exists on iPad and macOS only.
|
/// Corner layout exists on iPad and macOS only.
|
||||||
static var supportsCornerLayout: Bool {
|
static var supportsCornerLayout: Bool {
|
||||||
@@ -31,6 +32,7 @@ struct PanelSettingsView: View {
|
|||||||
layoutSection
|
layoutSection
|
||||||
}
|
}
|
||||||
visibilitySection
|
visibilitySection
|
||||||
|
pageBarSection
|
||||||
pagesSection
|
pagesSection
|
||||||
}
|
}
|
||||||
.navigationTitle(String(localized: "settings.playerControls.detailsPanel"))
|
.navigationTitle(String(localized: "settings.playerControls.detailsPanel"))
|
||||||
@@ -57,6 +59,11 @@ struct PanelSettingsView: View {
|
|||||||
|
|
||||||
private var layoutSection: some View {
|
private var layoutSection: some View {
|
||||||
Section {
|
Section {
|
||||||
|
PanelLayoutSchematic(mode: layoutMode)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
|
||||||
Picker(
|
Picker(
|
||||||
String(localized: "panel.settings.layoutMode"),
|
String(localized: "panel.settings.layoutMode"),
|
||||||
selection: $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
|
// MARK: - Pages Section
|
||||||
|
|
||||||
private var pagesSection: some View {
|
private var pagesSection: some View {
|
||||||
@@ -126,6 +151,14 @@ struct PanelSettingsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Label(page.pageType.displayName, systemImage: page.pageType.systemImage)
|
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()
|
Spacer()
|
||||||
if page.isEnabled, layoutMode == .corner, Self.supportsCornerLayout {
|
if page.isEnabled, layoutMode == .corner, Self.supportsCornerLayout {
|
||||||
Text(page.region.displayName)
|
Text(page.region.displayName)
|
||||||
@@ -145,6 +178,66 @@ struct PanelSettingsView: View {
|
|||||||
private func syncLocalState() {
|
private func syncLocalState() {
|
||||||
defaultVisibility = viewModel.panelDefaultVisibility
|
defaultVisibility = viewModel.panelDefaultVisibility
|
||||||
layoutMode = viewModel.panelLayoutMode
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,45 @@ struct PanelSettingsTests {
|
|||||||
#expect(info?.effectiveShowChannelRow == true)
|
#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
|
// MARK: - Backward Compatibility
|
||||||
|
|
||||||
@Test("Layout without panelSettings decodes and falls back to default")
|
@Test("Layout without panelSettings decodes and falls back to default")
|
||||||
|
|||||||
Reference in New Issue
Block a user