Files
yattee/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift
Arkadiusz Fal 0bfbd47882 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.
2026-09-27 20:05:20 +02:00

421 lines
15 KiB
Swift

//
// PanelSettingsView.swift
// Yattee
//
// Settings editor for the player details panel pages.
//
import SwiftUI
#if os(iOS) || os(macOS)
struct PanelSettingsView: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state for immediate UI updates
@State private var portraitDefaultVisibility: PanelDefaultVisibility = .visible
@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
/// Corner layout exists on iPad and macOS only.
static var supportsCornerLayout: Bool {
#if os(macOS)
return true
#else
return UIDevice.current.userInterfaceIdiom == .pad
#endif
}
/// iPhone has no page bar: the list configures inline sections instead.
private var usesSections: Bool { PanelSettings.isSectionsOnlyDevice }
var body: some View {
List {
if Self.supportsCornerLayout {
layoutSection
}
visibilitySection
if !usesSections {
pageBarSection
}
pagesSection
if usesSections {
commentsSection
}
}
.navigationTitle(String(localized: "settings.playerControls.detailsPanel"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#elseif os(macOS)
.formStyle(.grouped)
#endif
.opaqueSettingsFormBackground()
.toolbar {
#if os(iOS)
EditButton()
#endif
}
.onAppear {
syncLocalState()
}
.onChange(of: viewModel.activePreset?.id) { _, _ in
syncLocalState()
}
}
// MARK: - Layout Section
private var layoutSection: some View {
Section {
PanelLayoutSchematic(
mode: layoutMode,
side: wideLayoutSide,
showsBelowVideo: belowVideoContent != .nothing
)
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.listRowBackground(Color.clear)
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)
}
if layoutMode == .stacked {
Picker(
String(localized: "panel.settings.side"),
selection: $wideLayoutSide
) {
ForEach(FloatingPanelSide.allCases, id: \.self) { side in
Text(side.displayName).tag(side)
}
}
.disabled(!viewModel.canEditActivePreset)
.onChange(of: wideLayoutSide) { _, newValue in
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(layoutMode == .corner
? String(localized: "panel.settings.belowVideo.footer")
: String(localized: "panel.settings.layoutMode.footer"))
}
}
// MARK: - Visibility Section
/// macOS has no portrait player layout: only the landscape default.
private static var hasPortraitLayout: Bool {
#if os(macOS)
return false
#else
return true
#endif
}
private var visibilitySection: some View {
Section {
if Self.hasPortraitLayout {
visibilityPicker(
String(localized: "panel.settings.defaultVisibility.portrait"),
selection: $portraitDefaultVisibility,
orientation: .portrait
)
visibilityPicker(
String(localized: "panel.settings.defaultVisibility.landscape"),
selection: $landscapeDefaultVisibility,
orientation: .landscape
)
} else {
visibilityPicker(
String(localized: "panel.settings.defaultVisibility"),
selection: $landscapeDefaultVisibility,
orientation: .landscape
)
}
} header: {
if Self.hasPortraitLayout {
Text(String(localized: "panel.settings.defaultVisibility"))
}
} footer: {
Text(String(localized: "panel.settings.defaultVisibility.footer"))
}
}
private func visibilityPicker(
_ title: String,
selection: Binding<PanelDefaultVisibility>,
orientation: PanelOrientation
) -> some View {
Picker(title, selection: selection) {
ForEach(PanelDefaultVisibility.allCases, id: \.self) { visibility in
Text(visibility.displayName).tag(visibility)
}
}
.disabled(!viewModel.canEditActivePreset)
.onChange(of: selection.wrappedValue) { _, newValue in
guard newValue != viewModel.panelDefaultVisibility(for: orientation) else { return }
viewModel.syncPanelDefaultVisibility(newValue, for: orientation)
}
}
// 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: - Comments Section (iPhone)
/// iPhone never renders a comments page: comments live on the pill and
/// overlay, so their settings sit here instead of in the sections list.
private var commentsSettings: CommentsPageSettings {
viewModel.panelSettings.configuration(for: .comments)?.commentsSettings ?? CommentsPageSettings()
}
private var commentsSection: some View {
Section {
Picker(
String(localized: "commentsPill.mode.title"),
selection: $commentsPillMode
) {
ForEach(CommentsPillMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.onChange(of: commentsPillMode) { _, newValue in
guard newValue != commentsSettings.effectivePillMode else { return }
viewModel.updatePanelCommentsSettingsSync { $0.pillMode = newValue }
}
Toggle(
String(localized: "panel.settings.comments.showAvatars"),
isOn: $commentsShowAvatars
)
.onChange(of: commentsShowAvatars) { _, newValue in
guard newValue != commentsSettings.effectiveShowAvatars else { return }
viewModel.updatePanelCommentsSettingsSync { $0.showAvatars = newValue }
}
} header: {
Text(String(localized: "player.comments"))
} footer: {
if !viewModel.canEditActivePreset {
Text(String(localized: "settings.playerControls.duplicateToEdit"))
}
}
.disabled(!viewModel.canEditActivePreset)
}
// MARK: - Pages Section
/// The pinned page (info) is rendered outside the reorderable list so it
/// gets no drag handle and nothing can be dropped above it.
private var pinnedPage: PanelPageConfiguration? {
viewModel.panelPages.first { $0.pageType == PanelSettings.pinnedPageType }
}
/// Pages the user can reorder: everything but the pinned page and the
/// types this device never renders (iPhone: queue).
private var reorderablePages: [PanelPageConfiguration] {
let hidden = PanelSettings.hiddenSettingsPageTypes
return viewModel.panelPages.filter {
$0.pageType != PanelSettings.pinnedPageType && !hidden.contains($0.pageType)
}
}
private var pagesSection: some View {
Section {
if let pinnedPage {
pageRow(for: pinnedPage)
.moveDisabled(true)
}
ForEach(reorderablePages) { page in
pageRow(for: page)
}
.onMove { source, destination in
// Offsets are relative to the displayed list; the pinned and
// hidden pages keep their slots in the stored list.
viewModel.movePanelPages(
visible: reorderablePages.map(\.pageType),
fromOffsets: source,
toOffset: destination
)
}
} header: {
Text(usesSections
? String(localized: "panel.settings.sections.header")
: String(localized: "panel.settings.pages.header"))
} footer: {
if viewModel.canEditActivePreset {
Text(usesSections
? String(localized: "panel.settings.sections.footer")
: String(localized: "panel.settings.pages.footer"))
} else {
Text(String(localized: "settings.playerControls.duplicateToEdit"))
}
}
.disabled(!viewModel.canEditActivePreset)
}
@ViewBuilder
private func pageRow(for page: PanelPageConfiguration) -> some View {
NavigationLink {
PanelPageEditorView(pageType: page.pageType, viewModel: viewModel)
} label: {
HStack {
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()
if page.isEnabled, layoutMode == .corner, Self.supportsCornerLayout {
Text(viewModel.panelSettings.effectiveRegion(for: page.pageType).displayName)
.foregroundStyle(.secondary)
} else {
Text(page.isEnabled
? String(localized: "common.on")
: String(localized: "common.off"))
.foregroundStyle(.secondary)
}
}
}
}
// MARK: - Helpers
private func syncLocalState() {
portraitDefaultVisibility = viewModel.panelDefaultVisibility(for: .portrait)
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
}
}
// 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
/// 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
var body: some View {
HStack(spacing: 3) {
if mode == .stacked && side == .left {
panelBlock
.frame(width: 44)
}
if mode == .corner {
VStack(spacing: 3) {
videoBlock
if showsBelowVideo {
panelBlock
.frame(height: 26)
}
}
} else {
videoBlock
}
if mode == .corner || side == .right {
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)
.animation(.easeInOut(duration: 0.25), value: side)
.animation(.easeInOut(duration: 0.25), value: showsBelowVideo)
}
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)
}
}
}
#endif