Add corner player layout on iPad and macOS

Preset-selected two-region layout: video in the top-left, a resizable
side column, and an optional region below the video. Pages are assigned
to regions per preset (Location picker in the page editor); the layout
falls back to the stacked widescreen layout below 900pt width, so Split
View and narrow windows degrade automatically.

- CornerPlayerLayout hosts the shared playerAreaForWidescreen builder,
  so the MPV hosting path is identical to the stacked layout
- Layout resolver in ExpandedPlayerSheet picks corner per layout pass;
  NavigationCoordinator.isCornerLayoutActive feeds the macOS window
  manager, which drops the aspect-ratio lock and auto-fit while corner
  is active (and restores them on exit, incl. fullscreen replay and
  window re-mounts)
- Side column width persisted via new cornerPanelColumnWidth setting
  (280-560pt, video column kept >= 480pt)
- Settings: layout mode picker (iPad/macOS) with automatic-fallback
  footer, per-page region picker when corner is on, region shown in
  the pages list
This commit is contained in:
Arkadiusz Fal
2026-09-21 23:52:09 +02:00
parent c33d125553
commit a6d989c7af
14 changed files with 582 additions and 12 deletions

View File

@@ -133,6 +133,7 @@ enum SettingsKey: String, CaseIterable {
case landscapeDetailsPanelVisible
case landscapeDetailsPanelPinned
case detailsPanelLastVisible // Last user-set visibility, for the "remember" default
case cornerPanelColumnWidth // Corner layout - width of the side column
// Player Controls
case activeControlsPresetID
@@ -168,7 +169,7 @@ enum SettingsKey: String, CaseIterable {
// Player details panel — iOS/iPadOS only, different on other platforms
.floatingDetailsPanelSide, .floatingDetailsPanelWidth,
.landscapeDetailsPanelVisible, .landscapeDetailsPanelPinned,
.detailsPanelLastVisible,
.detailsPanelLastVisible, .cornerPanelColumnWidth,
// Video swipe actions — touch-gesture feature
.videoSwipeActionOrder, .videoSwipeActionVisibility:
return true

View File

@@ -290,6 +290,23 @@ extension SettingsManager {
}
}
/// Default side column width in the corner player layout.
static let defaultCornerPanelColumnWidth: CGFloat = 380
/// Width of the side column in the corner player layout (iPad/macOS).
/// User can resize via drag gesture. Persisted across sessions.
var cornerPanelColumnWidth: CGFloat {
get {
if let cached = _cornerPanelColumnWidth { return cached }
let value = double(for: .cornerPanelColumnWidth)
return value > 0 ? CGFloat(value) : Self.defaultCornerPanelColumnWidth
}
set {
_cornerPanelColumnWidth = newValue
set(Double(newValue), for: .cornerPanelColumnWidth)
}
}
/// Whether the details panel is pinned in landscape layout.
/// Default is false (floating mode).
var landscapeDetailsPanelPinned: Bool {

View File

@@ -183,6 +183,7 @@ final class SettingsManager {
var _landscapeDetailsPanelVisible: Bool?
var _landscapeDetailsPanelPinned: Bool?
var _detailsPanelLastVisible: Bool?
var _cornerPanelColumnWidth: CGFloat?
// Notification settings
var _backgroundNotificationsEnabled: Bool?
@@ -534,6 +535,7 @@ final class SettingsManager {
_landscapeDetailsPanelVisible = nil
_landscapeDetailsPanelPinned = nil
_detailsPanelLastVisible = nil
_cornerPanelColumnWidth = nil
_backgroundNotificationsEnabled = nil
_defaultNotificationsForNewChannels = nil
_lastBackgroundCheck = nil

View File

@@ -7467,6 +7467,28 @@
}
}
},
"panel.layoutMode.corner" : {
"comment" : "Details panel layout mode option",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Corner"
}
}
}
},
"panel.layoutMode.stacked" : {
"comment" : "Details panel layout mode option",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Standard"
}
}
}
},
"panel.page.info" : {
"comment" : "Details panel page name",
"localizations" : {
@@ -7511,6 +7533,28 @@
}
}
},
"panel.region.belowVideo" : {
"comment" : "Corner layout region option",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Below Video"
}
}
}
},
"panel.region.primary" : {
"comment" : "Corner layout region option",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Side Column"
}
}
}
},
"panel.settings.defaultVisibility" : {
"comment" : "Details panel settings picker label",
"localizations" : {
@@ -7577,6 +7621,28 @@
}
}
},
"panel.settings.layoutMode" : {
"comment" : "Details panel layout mode picker label",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Layout"
}
}
}
},
"panel.settings.layoutMode.footer" : {
"comment" : "Details panel layout mode footer",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Corner shows the video in the top corner with pages in a side column and below the video. The standard layout is used automatically when the window is too narrow."
}
}
}
},
"panel.settings.page.enabled" : {
"comment" : "Details panel page editor toggle",
"localizations" : {
@@ -7599,6 +7665,28 @@
}
}
},
"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.pages.header" : {
"comment" : "Details panel settings section header",
"localizations" : {

View File

@@ -61,18 +61,38 @@ 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.
enum PanelRegion: String, Codable, Sendable {
enum PanelRegion: String, Codable, CaseIterable, Sendable {
case primary
case belowVideo
/// Localized name for the settings picker.
var displayName: String {
switch self {
case .primary:
return String(localized: "panel.region.primary")
case .belowVideo:
return String(localized: "panel.region.belowVideo")
}
}
}
// MARK: - Layout Mode
/// How the details panel is laid out on iPad/macOS.
/// Corner mode automatically falls back to stacked when the window is narrow.
enum PanelLayoutMode: String, Codable, Sendable {
enum PanelLayoutMode: String, Codable, CaseIterable, Sendable {
case stacked
case corner
/// Localized name for the settings picker.
var displayName: String {
switch self {
case .stacked:
return String(localized: "panel.layoutMode.stacked")
case .corner:
return String(localized: "panel.layoutMode.corner")
}
}
}
// MARK: - Default Visibility
@@ -377,6 +397,12 @@ 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

@@ -67,6 +67,7 @@ final class NavigationCoordinator {
// visibility once; closing the player arms the next application.
if !isPlayerExpanded {
hasAppliedPanelDefaultVisibility = false
isCornerLayoutActive = false
}
}
}
@@ -76,6 +77,11 @@ final class NavigationCoordinator {
/// so window-mode switches and layout re-mounts don't re-apply it.
var hasAppliedPanelDefaultVisibility = false
/// Whether the expanded player currently resolves to the corner layout
/// (iPad/macOS). Drives macOS window aspect-lock gating, which lives in a
/// different view than the layout resolver.
var isCornerLayoutActive = false
/// Whether the mini player queue sheet is showing.
var isMiniPlayerQueueSheetPresented = false

View File

@@ -819,6 +819,25 @@ final class PlayerControlsSettingsViewModel {
}
}
/// Current panel layout mode (iPad/macOS).
var panelLayoutMode: PanelLayoutMode {
panelSettings.effectiveLayoutMode
}
/// Synchronously updates the panel layout mode.
func syncPanelLayoutMode(_ mode: PanelLayoutMode) {
updatePanelSettingsSync { settings in
settings.layoutMode = mode
}
}
/// Synchronously updates a page's corner-layout region assignment.
func setPanelPageRegion(_ region: PanelRegion, for pageType: PanelPageType) {
updatePanelSettingsSync { settings in
settings.setRegion(region, for: pageType)
}
}
/// Enables or disables a panel page. The last enabled page cannot be
/// disabled — the panel always needs at least one page.
func setPanelPageEnabled(_ pageType: PanelPageType, isEnabled: Bool) {

View File

@@ -0,0 +1,218 @@
//
// CornerPlayerLayout.swift
// Yattee
//
// Corner player layout for iPad/macOS: video in the top-left, a resizable
// side column on the right, and an optional region below the video —
// YouTube-style. Pages are assigned to regions in the preset's panel
// settings; the layout resolver in ExpandedPlayerSheet falls back to the
// stacked layout when the window is too narrow.
//
import SwiftUI
#if os(iOS) || os(macOS)
struct CornerPlayerLayout<PlayerContent: View>: View {
@Environment(\.appEnvironment) private var appEnvironment
let playerControlsLayout: PlayerControlsLayout
/// Video + controls area builder, sized by the layout.
@ViewBuilder let playerContent: (
_ onTogglePanel: @escaping () -> Void,
_ isPanelVisible: Bool,
_ width: CGFloat,
_ height: CGFloat
) -> PlayerContent
let onChannelTap: (() -> Void)?
// MARK: - Layout Constants
/// Side column width bounds.
static var minColumnWidth: CGFloat { 280 }
static var maxColumnWidthCap: CGFloat { 560 }
/// Minimum width kept for the video column.
static var minVideoColumnWidth: CGFloat { 480 }
/// Maximum fraction of the window height the video area may take when a
/// below-video region needs space.
static var maxVideoHeightFraction: CGFloat { 0.72 }
// MARK: - State
@State private var isPanelVisible = true
@State private var columnWidth: CGFloat = SettingsManager.defaultCornerPanelColumnWidth
@State private var primaryPage: PanelPageType = .related
@State private var belowVideoPage: PanelPageType = .info
@State private var isGrabberActive = false
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
private var playerState: PlayerState? { appEnvironment?.playerService.state }
private var video: Video? { playerState?.currentVideo }
private var accentColor: Color { settingsManager?.resolvedAccentColor ?? .accentColor }
private var panelSettings: PanelSettings {
playerControlsLayout.effectivePanelSettings
}
/// Region emptiness is decided at the config level: runtime-empty pages
/// (no chapters, no related yet) are hidden inside the panel content, not
/// by collapsing regions — layout jumps during loading would be jarring.
private var hasPrimaryRegion: Bool {
!panelSettings.enabledPages(in: .primary).isEmpty
}
private var hasBelowVideoRegion: Bool {
!panelSettings.enabledPages(in: .belowVideo).isEmpty
}
private var panelBackgroundColor: Color {
#if os(iOS)
Color(uiColor: .systemBackground)
#else
Color(nsColor: .windowBackgroundColor)
#endif
}
private func clampColumnWidth(_ width: CGFloat, availableWidth: CGFloat) -> CGFloat {
let maxWidth = max(
Self.minColumnWidth,
min(Self.maxColumnWidthCap, availableWidth * 0.45, availableWidth - Self.minVideoColumnWidth)
)
return min(max(width, Self.minColumnWidth), maxWidth)
}
// MARK: - Body
var body: some View {
GeometryReader { geometry in
let width = max(1, geometry.size.width)
let height = max(1, geometry.size.height)
let showsPrimary = isPanelVisible && hasPrimaryRegion
let showsBelowVideo = isPanelVisible && hasBelowVideoRegion
let effectiveColumnWidth = clampColumnWidth(columnWidth, availableWidth: width)
// Grabber sits on the column's leading edge, inside the column background.
let totalColumnWidth = effectiveColumnWidth + 20
let videoWidth = showsPrimary ? max(1, width - totalColumnWidth) : width
let videoHeight: CGFloat = {
guard showsBelowVideo else { return height }
let rawAspectRatio = playerState?.displayAspectRatio ?? (16.0 / 9.0)
let aspectRatio = rawAspectRatio > 0 ? rawAspectRatio : (16.0 / 9.0)
return max(1, min(videoWidth / aspectRatio, height * Self.maxVideoHeightFraction))
}()
ZStack {
Color.black.ignoresSafeArea(.all)
HStack(spacing: 0) {
VStack(spacing: 0) {
playerContent(
togglePanel,
isPanelVisible,
videoWidth,
videoHeight
)
.frame(width: videoWidth, height: videoHeight)
.clipped()
if showsBelowVideo, let video {
regionPanel(
region: .belowVideo,
selectedPage: $belowVideoPage,
video: video
)
.frame(width: videoWidth)
.frame(maxHeight: .infinity)
}
}
if showsPrimary, let video {
HStack(spacing: 0) {
PanelResizeGrabber(
panelWidth: $columnWidth,
minWidth: Self.minColumnWidth,
maxWidth: clampColumnWidth(.infinity, availableWidth: width),
panelSide: .right,
isPinned: true,
isDragActive: $isGrabberActive
)
regionPanel(
region: .primary,
selectedPage: $primaryPage,
video: video
)
.frame(width: effectiveColumnWidth)
}
.background(panelBackgroundColor.ignoresSafeArea(edges: .vertical))
}
}
}
.onChange(of: width) { _, newWidth in
// Re-clamp when the window shrinks (still above the corner
// threshold — below it the resolver switches layouts).
columnWidth = clampColumnWidth(columnWidth, availableWidth: newWidth)
}
}
.onAppear {
isPanelVisible = settingsManager?.landscapeDetailsPanelVisible ?? true
if let savedWidth = settingsManager?.cornerPanelColumnWidth, savedWidth > 0 {
columnWidth = savedWidth
}
}
.onChange(of: columnWidth) { _, newWidth in
settingsManager?.cornerPanelColumnWidth = newWidth
}
.onChange(of: settingsManager?.landscapeDetailsPanelVisible) { _, newValue in
// External writes (preset default visibility, other layout hosts)
// must reach the local state too.
guard let newValue, newValue != isPanelVisible else { return }
withAnimation(.easeInOut(duration: 0.25)) {
isPanelVisible = newValue
}
}
}
// MARK: - Region Panel
@ViewBuilder
private func regionPanel(
region: PanelRegion,
selectedPage: Binding<PanelPageType>,
video: Video
) -> some View {
PlayerPanelContentView(
panelSettings: panelSettings,
region: region,
selectedPage: selectedPage,
context: PanelPageContext(
appEnvironment: appEnvironment,
video: video,
accentColor: accentColor,
onChannelTap: onChannelTap
),
scrollConfig: PanelScrollConfig(
topInset: 4,
bottomInset: 16
),
pageBarTopPadding: 8
)
.background(panelBackgroundColor.ignoresSafeArea(edges: .vertical))
}
// MARK: - Actions
private func togglePanel() {
withAnimation(.easeInOut(duration: 0.25)) {
isPanelVisible.toggle()
settingsManager?.landscapeDetailsPanelVisible = isPanelVisible
settingsManager?.detailsPanelLastVisible = isPanelVisible
}
}
}
#endif

View File

@@ -16,6 +16,11 @@ extension ExpandedPlayerSheet {
/// Minimum width required to show widescreen layout with floating panel.
static let minWidthForWidescreen: CGFloat = 700
/// Minimum width required for the corner layout; below this the corner
/// preset setting falls back to the stacked widescreen layout (Split View,
/// narrow windows).
static let minWidthForCornerLayout: CGFloat = 900
// MARK: - Layout Detection
/// Check if widescreen layout should be used based on available size.
@@ -31,6 +36,21 @@ extension ExpandedPlayerSheet {
#endif
}
#if os(iOS) || os(macOS)
/// Whether the corner layout should be used for the given size: iPad/macOS
/// only, opted into via the preset's panel settings, wide enough, and in a
/// widescreen orientation. Evaluated per layout pass so window resizes and
/// Split View changes fall back automatically.
func isCornerLayout(size: CGSize) -> Bool {
#if os(iOS)
guard UIDevice.current.userInterfaceIdiom == .pad else { return false }
#endif
return playerControlsLayout.effectivePanelSettings.effectiveLayoutMode == .corner
&& size.width >= Self.minWidthForCornerLayout
&& isWideScreenLayout(size: size)
}
#endif
// MARK: - Download Action Handler
#if !os(tvOS)
@@ -1276,6 +1296,36 @@ extension ExpandedPlayerSheet {
)
}
// MARK: - Corner Content
/// Corner layout: video top-left, side column, optional below-video
/// region. Reuses `playerAreaForWidescreen` so the MPV hosting path is
/// identical to the stacked layout — only the container size differs.
@ViewBuilder
func cornerContent(video: Video) -> some View {
CornerPlayerLayout(
playerControlsLayout: playerControlsLayout,
playerContent: { onTogglePanel, isPanelVisible, width, height in
// isPanelPinned=true keeps the controls visible alongside the
// panel regions (corner regions behave like a pinned panel).
playerAreaForWidescreen(
onTogglePanel: onTogglePanel,
isPanelVisible: isPanelVisible,
isPanelPinned: true,
panelSide: .right,
onHidePanel: {},
leadingSafeArea: 0,
trailingSafeArea: 0,
fullWidth: width,
fullHeight: height
)
},
onChannelTap: video.author.hasRealChannelInfo ? {
navigationCoordinator?.navigateToChannel(for: video, collapsePlayer: true)
} : nil
)
}
// MARK: - Widescreen Player Area
/// Player area for widescreen layout with panel support.

View File

@@ -294,6 +294,9 @@ struct ExpandedPlayerSheet: View {
private var playerContent: some View {
GeometryReader { geometry in
let wideScreen = isWideScreenLayout(size: geometry.size)
#if os(iOS) || os(macOS)
let cornerActive = isCornerLayout(size: geometry.size)
#endif
ZStack(alignment: .top) {
// Full-screen background - prevents content leaking during aspect ratio animation
@@ -310,10 +313,17 @@ struct ExpandedPlayerSheet: View {
Color.black.ignoresSafeArea(.all)
} else if wideScreen {
if let video = playerState?.currentVideo {
// Widescreen layout with floating panel
// Must ignore safe area to get full screen geometry
wideScreenContent(video: video)
.ignoresSafeArea(.all)
if cornerActive {
// Corner layout: video top-left with panel regions.
// Laid out within the safe area (black background
// extends beneath it).
cornerContent(video: video)
} else {
// Widescreen layout with floating panel
// Must ignore safe area to get full screen geometry
wideScreenContent(video: video)
.ignoresSafeArea(.all)
}
} else {
// Widescreen loading state - just show black with spinner
Color.black
@@ -337,6 +347,11 @@ struct ExpandedPlayerSheet: View {
// Disable animations during layout switch to prevent position interpolation
transaction.animation = nil
}
#if os(iOS) || os(macOS)
.onChange(of: cornerActive) { _, newValue in
navigationCoordinator?.isCornerLayoutActive = newValue
}
#endif
.onChange(of: wideScreen) { _, newValue in
isInWideScreenLayout = newValue
#if os(iOS)
@@ -371,6 +386,9 @@ struct ExpandedPlayerSheet: View {
}
.onAppear {
isInWideScreenLayout = wideScreen
#if os(iOS) || os(macOS)
navigationCoordinator?.isCornerLayoutActive = cornerActive
#endif
#if os(iOS)
previousIsLandscape = wideScreen
if wideScreen {
@@ -988,6 +1006,11 @@ private struct PlayerMacOSEventHandlersModifier: ViewModifier {
var playerState: PlayerState? { appEnvironment?.playerService.state }
/// Corner layout resizes the window freely: no aspect lock, no auto-fit.
private var isCornerLayoutActive: Bool {
appEnvironment?.navigationCoordinator.isCornerLayoutActive == true
}
func body(content: Content) -> some View {
content
.onChange(of: playerState?.showDebugOverlay) { _, isVisible in
@@ -1003,12 +1026,30 @@ private struct PlayerMacOSEventHandlersModifier: ViewModifier {
.onChange(of: playerState?.currentVideo?.id) { oldValue, newValue in
handleVideoChangeForResize(oldValue: oldValue, newValue: newValue)
}
.onChange(of: appEnvironment?.navigationCoordinator.isCornerLayoutActive) { _, isCorner in
handleCornerLayoutChange(isCorner: isCorner == true)
}
.onAppear {
handleMacOSAppear()
}
}
/// Entering corner mode drops the window's aspect-ratio constraint so it
/// resizes freely; leaving it restores the lock (and auto-fit, when on)
/// exactly as the aspect-change handler would have applied it.
private func handleCornerLayoutChange(isCorner: Bool) {
if isCorner {
ExpandedPlayerWindowManager.shared.unlockAspectRatio()
} else if let aspectRatio = playerState?.videoAspectRatio, aspectRatio > 0 {
ExpandedPlayerWindowManager.shared.lockAspectRatio(aspectRatio)
if appEnvironment?.settingsManager.playerSheetAutoResize == true {
ExpandedPlayerWindowManager.shared.resizeToFitAspectRatio(aspectRatio, animated: true)
}
}
}
private func handleAspectRatioChange(oldValue: Double?, newValue: Double?) {
guard !isCornerLayoutActive else { return }
// nil while a video is loaded means audio-only playback (no video
// track reports a size) — fall back to the standard 16:9 instead of
// keeping the previous video's ratio.
@@ -1039,6 +1080,7 @@ private struct PlayerMacOSEventHandlersModifier: ViewModifier {
}
private func handleVideoChangeForResize(oldValue: Video.ID?, newValue: Video.ID?) {
guard !isCornerLayoutActive else { return }
guard oldValue != nil, newValue != nil else { return }
guard appEnvironment?.settingsManager.playerSheetAutoResize == true else { return }
@@ -1054,6 +1096,7 @@ private struct PlayerMacOSEventHandlersModifier: ViewModifier {
}
private func handleMacOSAppear() {
guard !isCornerLayoutActive else { return }
guard let aspectRatio = playerState?.videoAspectRatio, aspectRatio > 0 else { return }
// Lock aspect ratio for manual resize unconditionally.

View File

@@ -176,7 +176,11 @@ final class ExpandedPlayerWindowManager: NSObject {
// Lock manual resize to the video aspect ratio. Seeded with the real ratio
// when known (else 16:9); updated as soon as the real ratio is known.
Self.applyAspectRatioConstraint(seedAspect, to: window)
// Corner layout (already active on a window re-mount, e.g. switching
// from the inline overlay) keeps the window unconstrained.
if appEnvironment.navigationCoordinator.isCornerLayoutActive != true {
Self.applyAspectRatioConstraint(seedAspect, to: window)
}
// Force the intended size. Assigning `contentViewController` above resizes
// the window to the hosting controller's fitting size — and the lightweight
@@ -588,6 +592,17 @@ final class ExpandedPlayerWindowManager: NSObject {
Self.applyAspectRatioConstraint(aspectRatio, to: window)
}
/// Removes the window's aspect-ratio resize constraint so it resizes
/// freely. Used by the corner layout, where the window frames the whole
/// video-plus-panels arrangement rather than the video alone. The size
/// floor stays enforced by `windowWillResize(_:to:)`.
func unlockAspectRatio() {
guard let window = playerWindow else { return }
pendingFullScreenAspectRatio = nil
window.contentAspectRatio = .zero
LoggingService.shared.debug("unlockAspectRatio (corner layout)", category: .player)
}
// MARK: - Private Helpers
/// Sets `contentAspectRatio` and a ratio-consistent minimum content size on
@@ -813,6 +828,8 @@ extension ExpandedPlayerWindowManager: NSWindowDelegate {
// exactly what the aspect-change handler would have done windowed.
if let ratio = pendingFullScreenAspectRatio {
pendingFullScreenAspectRatio = nil
// Corner layout keeps the window unconstrained.
guard appEnvironment?.navigationCoordinator.isCornerLayoutActive != true else { return }
Self.applyAspectRatioConstraint(ratio, to: window)
if appEnvironment?.settingsManager.playerSheetAutoResize == true {
resizeToFitAspectRatio(ratio, animated: false)

View File

@@ -15,6 +15,7 @@ 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 showStatsRow: Bool = true
@State private var showChannelRow: Bool = true
@@ -32,6 +33,10 @@ struct PanelPageEditorView: View {
Form {
enabledSection
if PanelSettingsView.supportsCornerLayout, viewModel.panelLayoutMode == .corner {
regionSection
}
if pageType == .info {
infoElementsSection
}
@@ -73,6 +78,28 @@ 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
private var infoElementsSection: some View {
@@ -117,6 +144,7 @@ struct PanelPageEditorView: View {
private func syncLocalState() {
isEnabled = configuration?.isEnabled ?? true
region = configuration?.region ?? .primary
let info = infoSettings
showDescription = info.effectiveShowDescription
showStatsRow = info.effectiveShowStatsRow

View File

@@ -14,9 +14,22 @@ struct PanelSettingsView: View {
// Local state for immediate UI updates
@State private var defaultVisibility: PanelDefaultVisibility = .hidden
@State private var layoutMode: PanelLayoutMode = .stacked
/// Corner layout exists on iPad and macOS only.
static var supportsCornerLayout: Bool {
#if os(macOS)
return true
#else
return UIDevice.current.userInterfaceIdiom == .pad
#endif
}
var body: some View {
List {
if Self.supportsCornerLayout {
layoutSection
}
visibilitySection
pagesSection
}
@@ -40,6 +53,28 @@ struct PanelSettingsView: View {
}
}
// MARK: - Layout Section
private var layoutSection: some View {
Section {
Picker(
String(localized: "panel.settings.layoutMode"),
selection: $layoutMode
) {
ForEach(PanelLayoutMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.disabled(!viewModel.canEditActivePreset)
.onChange(of: layoutMode) { _, newValue in
guard newValue != viewModel.panelLayoutMode else { return }
viewModel.syncPanelLayoutMode(newValue)
}
} footer: {
Text(String(localized: "panel.settings.layoutMode.footer"))
}
}
// MARK: - Visibility Section
private var visibilitySection: some View {
@@ -92,10 +127,15 @@ struct PanelSettingsView: View {
HStack {
Label(page.pageType.displayName, systemImage: page.pageType.systemImage)
Spacer()
Text(page.isEnabled
? String(localized: "common.on")
: String(localized: "common.off"))
.foregroundStyle(.secondary)
if page.isEnabled, layoutMode == .corner, Self.supportsCornerLayout {
Text(page.region.displayName)
.foregroundStyle(.secondary)
} else {
Text(page.isEnabled
? String(localized: "common.on")
: String(localized: "common.off"))
.foregroundStyle(.secondary)
}
}
}
}
@@ -104,6 +144,7 @@ struct PanelSettingsView: View {
private func syncLocalState() {
defaultVisibility = viewModel.panelDefaultVisibility
layoutMode = viewModel.panelLayoutMode
}
}

View File

@@ -160,4 +160,18 @@ struct PanelSettingsTests {
settings.setEnabled(true, for: .comments)
#expect(settings.pages.map(\.pageType) == PanelSettings.default.pages.map(\.pageType))
}
@Test("Region assignment and layout mode round-trip through Codable")
func regionAndLayoutModeRoundTrip() 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])
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)
}
}