Replace per-page corner regions with one "Below Video" picker

The corner layout's below-video host is the portrait info panel, not a
page host: it can render only Info, plus Comments as the panel's pill on
iOS. The page editor still offered "Below Video" for every page, so
assigning Related there was accepted, stored, listed as such, and then
silently rendered in the side column.

- The Layout section gains a "Below Video" picker in corner mode:
  Nothing / Info / Info and Comments (macOS offers the first two). It
  writes the info/comments regions; the per-page Location section and
  its strings are gone.
- PanelSettings.normalizedPages clamps regions to what the host can
  render (only info/comments below, comments only together with info),
  so older presets self-correct. effectiveRegion(for:) resolves the
  platform-specific placement and is used by both the pages list labels
  and CornerPlayerLayout, replacing its own region checks.
- The layout schematic drops the below-video block for "Nothing"; the
  corner-mode footer explains the constraint.
- Tests cover the picker round-trip and the clamping.
This commit is contained in:
Arkadiusz Fal
2026-09-27 20:05:20 +02:00
parent 7f74b50e94
commit 0bfbd47882
7 changed files with 248 additions and 90 deletions

View File

@@ -7153,6 +7153,39 @@
}
}
},
"panel.belowVideo.info" : {
"comment" : "Corner layout below-video content option",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Info"
}
}
}
},
"panel.belowVideo.infoAndComments" : {
"comment" : "Corner layout below-video content option",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Info and Comments"
}
}
}
},
"panel.belowVideo.nothing" : {
"comment" : "Corner layout below-video content option",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nothing"
}
}
}
},
"panel.defaultVisibility.hidden" : {
"comment" : "Details panel default visibility option",
"localizations" : {
@@ -7263,6 +7296,28 @@
}
}
},
"panel.settings.belowVideo" : {
"comment" : "Corner layout below-video content picker label",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Below Video"
}
}
}
},
"panel.settings.belowVideo.footer" : {
"comment" : "Corner layout below-video content footer",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Corner shows the video in the top corner with pages in a side column. Only the info panel can sit below the video; comments there show as its pill. Related, chapters and queue always use the side column. The standard layout is used automatically when the window is too narrow."
}
}
}
},
"panel.settings.comments.showAvatars" : {
"comment" : "Comments page element toggle",
"localizations" : {
@@ -7461,28 +7516,6 @@
}
}
},
"panel.settings.page.region" : {
"comment" : "Corner layout page region picker label",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Location"
}
}
}
},
"panel.settings.page.region.footer" : {
"comment" : "Corner layout page region footer",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Where this page appears in the corner layout."
}
}
}
},
"panel.settings.pageBarLabels" : {
"comment" : "Details panel page bar labels toggle",
"localizations" : {

View File

@@ -72,11 +72,16 @@ enum PanelPageType: String, Codable, CaseIterable, Sendable {
/// Region a page is assigned to in the corner layout.
/// `primary` is the right column in corner mode and the only panel elsewhere;
/// the assignment is ignored outside corner mode.
///
/// The below-video host is the portrait info panel, not a page host: only
/// `.info` can live there, plus `.comments` as the panel's pill (iOS). Every
/// other page is clamped to `.primary` by `PanelSettings.normalizedPages`, and
/// settings expose the choice as one `PanelBelowVideoContent` picker.
enum PanelRegion: String, Codable, CaseIterable, Sendable {
case primary
case belowVideo
/// Localized name for the settings picker.
/// Localized name for the pages list.
var displayName: String {
switch self {
case .primary:
@@ -87,6 +92,36 @@ enum PanelRegion: String, Codable, CaseIterable, Sendable {
}
}
/// What the corner layout shows below the video: the three layouts the
/// below-video host can actually render. Stored as the info/comments page
/// regions (see `PanelSettings.belowVideoContent`).
enum PanelBelowVideoContent: String, CaseIterable, Sendable {
/// Video fills the left column; every page sits in the side column.
case nothing
/// The info panel below the video; comments join the side column.
case info
/// The info panel with the comments pill below the video (iOS only).
case infoAndComments
/// Localized name for the settings picker.
var displayName: String {
switch self {
case .nothing:
return String(localized: "panel.belowVideo.nothing")
case .info:
return String(localized: "panel.belowVideo.info")
case .infoAndComments:
return String(localized: "panel.belowVideo.infoAndComments")
}
}
/// Options this platform can render: the comments pill lives on the
/// iOS portrait panel only.
static var availableCases: [PanelBelowVideoContent] {
PanelSettings.supportsBelowVideoComments ? allCases : [.nothing, .info]
}
}
// MARK: - Layout Mode
/// How the details panel is laid out on iPad/macOS.
@@ -484,10 +519,27 @@ struct PanelSettings: Codable, Hashable, Sendable {
/// other pages or switched off. Its element toggles remain configurable.
static let pinnedPageType: PanelPageType = .info
/// Page types the below-video host can render: the info panel itself and
/// comments as its pill. Other types assigned `.belowVideo` (older
/// presets) are clamped back to the side column.
static let belowVideoHostablePageTypes: Set<PanelPageType> = [.info, .comments]
/// Whether this platform renders comments below the video (the comments
/// pill on the portrait info panel exists on iOS only).
static var supportsBelowVideoComments: Bool {
#if os(iOS)
return true
#else
return false
#endif
}
/// The pages list with any page types this app version knows about but
/// the stored list is missing appended as disabled, so newly introduced
/// pages surface in settings after an update, and the pinned page forced
/// first and enabled (older presets may have stored it elsewhere).
/// pages surface in settings after an update, the pinned page forced
/// first and enabled (older presets may have stored it elsewhere), and
/// regions clamped to what the corner layout can host: only info and
/// comments may sit below the video, comments only together with info.
var normalizedPages: [PanelPageConfiguration] {
var result = pages
let known = Set(pages.map(\.pageType))
@@ -500,9 +552,59 @@ struct PanelSettings: Codable, Hashable, Sendable {
pinned.isEnabled = true
result.insert(pinned, at: 0)
}
let infoBelowVideo = result.contains { $0.pageType == .info && $0.region == .belowVideo }
for index in result.indices where result[index].region == .belowVideo {
let type = result[index].pageType
let hostable = Self.belowVideoHostablePageTypes.contains(type)
&& (type != .comments || infoBelowVideo)
if !hostable {
result[index].region = .primary
}
}
return result
}
/// The stored below-video choice, derived from the info/comments regions.
/// Platform-agnostic so presets keep their value across devices; use
/// `effectiveBelowVideoContent` for what this device renders.
var belowVideoContent: PanelBelowVideoContent {
get {
guard configuration(for: .info)?.region == .belowVideo else { return .nothing }
return configuration(for: .comments)?.region == .belowVideo ? .infoAndComments : .info
}
set {
normalize()
let infoRegion: PanelRegion = newValue == .nothing ? .primary : .belowVideo
let commentsRegion: PanelRegion = newValue == .infoAndComments ? .belowVideo : .primary
for index in pages.indices {
switch pages[index].pageType {
case .info: pages[index].region = infoRegion
case .comments: pages[index].region = commentsRegion
default: pages[index].region = .primary
}
}
}
}
/// The below-video content this platform renders: comments fall back to
/// the side column where the pill does not exist.
var effectiveBelowVideoContent: PanelBelowVideoContent {
let stored = belowVideoContent
if stored == .infoAndComments, !Self.supportsBelowVideoComments { return .info }
return stored
}
/// Where the corner layout actually places a page on this platform.
func effectiveRegion(for pageType: PanelPageType) -> PanelRegion {
switch (pageType, effectiveBelowVideoContent) {
case (.info, .info), (.info, .infoAndComments), (.comments, .infoAndComments):
return .belowVideo
default:
return .primary
}
}
/// Page types the info page can render as collapsible inline sections
/// below the description (iPhone, and the stacked iPad portrait panel).
static let inlineSectionTypes: Set<PanelPageType> = [.chapters, .related]
@@ -615,12 +717,6 @@ struct PanelSettings: Codable, Hashable, Sendable {
pages[index].settings = newSettings
}
mutating func setRegion(_ region: PanelRegion, for pageType: PanelPageType) {
normalize()
guard let index = pages.firstIndex(where: { $0.pageType == pageType }) else { return }
pages[index].region = region
}
private enum CodingKeys: String, CodingKey {
case pages
case layoutMode

View File

@@ -830,10 +830,15 @@ final class PlayerControlsSettingsViewModel {
}
}
/// Synchronously updates a page's corner-layout region assignment.
func setPanelPageRegion(_ region: PanelRegion, for pageType: PanelPageType) {
/// What the corner layout shows below the video (stored value).
var panelBelowVideoContent: PanelBelowVideoContent {
panelSettings.belowVideoContent
}
/// Synchronously updates what the corner layout shows below the video.
func syncPanelBelowVideoContent(_ content: PanelBelowVideoContent) {
updatePanelSettingsSync { settings in
settings.setRegion(region, for: pageType)
settings.belowVideoContent = content
}
}

View File

@@ -134,30 +134,23 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
playerControlsLayout.effectivePanelSettings
}
/// Whether the info page is assigned to (and enabled in) the below-video
/// region — the below panel only exists to host it.
/// Whether the info page is assigned below the video — the below panel
/// only exists to host it. (`effectiveRegion` already clamps to what
/// this platform can render; info is always enabled.)
private var hasBelowVideoInfo: Bool {
guard let info = panelSettings.configuration(for: .info) else { return false }
return info.isEnabled && info.region == .belowVideo
panelSettings.effectiveRegion(for: .info) == .belowVideo
}
/// Comments assigned below the video render as the portrait panel's
/// comments pill (iOS only, and only while the info panel is below —
/// the pill lives inside it). Everything else falls back to the column.
/// comments pill (iOS only, and only together with the info panel — the
/// pill lives inside it; `effectiveRegion` enforces both).
private var hasBelowVideoComments: Bool {
#if os(iOS)
guard hasBelowVideoInfo,
let comments = panelSettings.configuration(for: .comments) else { return false }
return comments.isEnabled && comments.region == .belowVideo
#else
return false
#endif
panelSettings.isPageEnabled(.comments)
&& panelSettings.effectiveRegion(for: .comments) == .belowVideo
}
/// Pages the below-video host actually renders. Enabled pages assigned
/// `.belowVideo` that it can't host (chapters/related/queue/tracks, or
/// comments without the info panel) fall back to the side column instead
/// of disappearing.
/// Pages the below-video host renders; everything else goes to the side
/// column.
private var belowVideoPageTypes: Set<PanelPageType> {
var types: Set<PanelPageType> = []
if hasBelowVideoInfo { types.insert(.info) }

View File

@@ -15,7 +15,6 @@ struct PanelPageEditorView: View {
// Local state for immediate UI updates
@State private var isEnabled: Bool = true
@State private var region: PanelRegion = .primary
@State private var showDescription: Bool = true
@State private var descriptionExpandedByDefault: Bool = false
@State private var descriptionCollapsedLines: Int = InfoPageSettings.defaultDescriptionCollapsedLines
@@ -47,10 +46,6 @@ struct PanelPageEditorView: View {
enabledSection
}
if PanelSettingsView.supportsCornerLayout, viewModel.panelLayoutMode == .corner {
regionSection
}
switch pageType {
case .info:
infoElementsSection
@@ -101,28 +96,6 @@ struct PanelPageEditorView: View {
}
}
// MARK: - Region Section (corner layout)
private var regionSection: some View {
Section {
Picker(
String(localized: "panel.settings.page.region"),
selection: $region
) {
ForEach(PanelRegion.allCases, id: \.self) { region in
Text(region.displayName).tag(region)
}
}
.disabled(!viewModel.canEditActivePreset)
.onChange(of: region) { _, newValue in
guard newValue != configuration?.region else { return }
viewModel.setPanelPageRegion(newValue, for: pageType)
}
} footer: {
Text(String(localized: "panel.settings.page.region.footer"))
}
}
// MARK: - Info Elements Section
@ViewBuilder
@@ -351,7 +324,6 @@ struct PanelPageEditorView: View {
private func syncLocalState() {
isEnabled = configuration?.isEnabled ?? true
region = configuration?.region ?? .primary
let info = infoSettings
showDescription = info.effectiveShowDescription
descriptionExpandedByDefault = info.effectiveDescriptionExpandedByDefault

View File

@@ -17,6 +17,7 @@ struct PanelSettingsView: View {
@State private var landscapeDefaultVisibility: PanelDefaultVisibility = .hidden
@State private var layoutMode: PanelLayoutMode = .stacked
@State private var wideLayoutSide: FloatingPanelSide = .right
@State private var belowVideoContent: PanelBelowVideoContent = .info
@State private var showPageBarLabels: Bool = true
@State private var commentsShowAvatars: Bool = true
@State private var commentsPillMode: CommentsPillMode = .pill
@@ -71,7 +72,11 @@ struct PanelSettingsView: View {
private var layoutSection: some View {
Section {
PanelLayoutSchematic(mode: layoutMode, side: wideLayoutSide)
PanelLayoutSchematic(
mode: layoutMode,
side: wideLayoutSide,
showsBelowVideo: belowVideoContent != .nothing
)
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.listRowBackground(Color.clear)
@@ -104,9 +109,25 @@ struct PanelSettingsView: View {
guard newValue != viewModel.panelWideLayoutSide else { return }
viewModel.syncPanelWideLayoutSide(newValue)
}
} else {
Picker(
String(localized: "panel.settings.belowVideo"),
selection: $belowVideoContent
) {
ForEach(PanelBelowVideoContent.availableCases, id: \.self) { content in
Text(content.displayName).tag(content)
}
}
.disabled(!viewModel.canEditActivePreset)
.onChange(of: belowVideoContent) { _, newValue in
guard newValue != viewModel.panelBelowVideoContent else { return }
viewModel.syncPanelBelowVideoContent(newValue)
}
}
} footer: {
Text(String(localized: "panel.settings.layoutMode.footer"))
Text(layoutMode == .corner
? String(localized: "panel.settings.belowVideo.footer")
: String(localized: "panel.settings.layoutMode.footer"))
}
}
@@ -295,7 +316,7 @@ struct PanelSettingsView: View {
}
Spacer()
if page.isEnabled, layoutMode == .corner, Self.supportsCornerLayout {
Text(page.region.displayName)
Text(viewModel.panelSettings.effectiveRegion(for: page.pageType).displayName)
.foregroundStyle(.secondary)
} else {
Text(page.isEnabled
@@ -314,6 +335,8 @@ struct PanelSettingsView: View {
landscapeDefaultVisibility = viewModel.panelDefaultVisibility(for: .landscape)
layoutMode = viewModel.panelLayoutMode
wideLayoutSide = viewModel.panelWideLayoutSide
// Clamp to what this platform offers so the picker never shows blank.
belowVideoContent = viewModel.panelSettings.effectiveBelowVideoContent
showPageBarLabels = viewModel.panelShowPageBarLabels
commentsShowAvatars = commentsSettings.effectiveShowAvatars
commentsPillMode = commentsSettings.effectivePillMode
@@ -328,6 +351,8 @@ private struct PanelLayoutSchematic: View {
let mode: PanelLayoutMode
/// Panel side in the standard layout; the corner column is always right.
let side: FloatingPanelSide
/// Whether the corner layout has an info panel below the video.
let showsBelowVideo: Bool
private let cornerRadius: CGFloat = 4
@@ -341,8 +366,10 @@ private struct PanelLayoutSchematic: View {
if mode == .corner {
VStack(spacing: 3) {
videoBlock
panelBlock
.frame(height: 26)
if showsBelowVideo {
panelBlock
.frame(height: 26)
}
}
} else {
videoBlock
@@ -361,6 +388,7 @@ private struct PanelLayoutSchematic: View {
}
.animation(.easeInOut(duration: 0.25), value: mode)
.animation(.easeInOut(duration: 0.25), value: side)
.animation(.easeInOut(duration: 0.25), value: showsBelowVideo)
}
private var videoBlock: some View {

View File

@@ -279,17 +279,48 @@ struct PanelSettingsTests {
#expect(settings.pages.map(\.pageType) == PanelSettings.default.pages.map(\.pageType))
}
@Test("Region assignment and layout mode round-trip through Codable")
func regionAndLayoutModeRoundTrip() throws {
@Test("Below-video content and layout mode round-trip through Codable")
func belowVideoContentAndLayoutModeRoundTrip() throws {
var settings = PanelSettings.default
settings.layoutMode = .corner
settings.setRegion(.belowVideo, for: .related)
#expect(settings.configuration(for: .related)?.region == .belowVideo)
#expect(settings.enabledPages(in: .primary).map(\.pageType) == [.chapters, .queue])
#expect(settings.belowVideoContent == .infoAndComments)
settings.belowVideoContent = .info
#expect(settings.configuration(for: .info)?.region == .belowVideo)
#expect(settings.configuration(for: .comments)?.region == .primary)
#expect(settings.enabledPages(in: .primary).map(\.pageType) == [.comments, .related, .chapters, .queue])
let data = try JSONEncoder().encode(settings)
let decoded = try JSONDecoder().decode(PanelSettings.self, from: data)
#expect(decoded.effectiveLayoutMode == .corner)
#expect(decoded.configuration(for: .related)?.region == .belowVideo)
#expect(decoded.belowVideoContent == .info)
settings.belowVideoContent = .nothing
#expect(settings.enabledPages(in: .belowVideo).isEmpty)
#expect(settings.enabledPages(in: .primary).map(\.pageType) == [.info, .comments, .related, .chapters, .queue])
}
@Test("Pages the below-video host cannot render are clamped to the side column")
func unhostableBelowVideoRegionsClamp() {
// Older presets could assign any page below the video.
var settings = PanelSettings(pages: [
PanelPageConfiguration(pageType: .info, region: .primary),
PanelPageConfiguration(pageType: .comments, region: .belowVideo),
PanelPageConfiguration(pageType: .related, region: .belowVideo),
PanelPageConfiguration(pageType: .chapters, region: .belowVideo),
PanelPageConfiguration(pageType: .queue, region: .belowVideo),
])
#expect(settings.belowVideoContent == .nothing)
#expect(settings.enabledPages(in: .belowVideo).isEmpty)
#expect(settings.effectiveRegion(for: .related) == .primary)
// Comments may sit below only together with info.
settings.belowVideoContent = .infoAndComments
#expect(settings.enabledPages(in: .belowVideo).map(\.pageType) == [.info, .comments])
#expect(settings.enabledPages(in: .primary).map(\.pageType) == [.related, .chapters, .queue])
settings.pages[0].region = .primary
#expect(settings.belowVideoContent == .nothing)
#expect(settings.effectiveRegion(for: .comments) == .primary)
}
}