Files
yattee/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift

439 lines
16 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
/// Page types whose slot the user cannot change: the pinned page (info)
/// and, in the corner layout with the comments pill below the video,
/// comments too, since it is no longer a page in the side column.
private var lockedPageTypes: Set<PanelPageType> {
var locked: Set<PanelPageType> = [PanelSettings.pinnedPageType]
if layoutMode == .corner, Self.supportsCornerLayout,
viewModel.panelSettings.effectiveRegion(for: .comments) == .belowVideo {
locked.insert(.comments)
}
return locked
}
/// Locked pages are rendered outside the reorderable list so they get no
/// drag handle and nothing can be dropped between or above them.
private var lockedPages: [PanelPageConfiguration] {
let locked = lockedPageTypes
let hidden = PanelSettings.hiddenSettingsPageTypes
return viewModel.panelPages.filter {
locked.contains($0.pageType) && !hidden.contains($0.pageType)
}
}
/// Pages the user can reorder: everything but the locked pages and the
/// types this device never renders (iPhone: queue, comments).
private var reorderablePages: [PanelPageConfiguration] {
let locked = lockedPageTypes
let hidden = PanelSettings.hiddenSettingsPageTypes
return viewModel.panelPages.filter {
!locked.contains($0.pageType) && !hidden.contains($0.pageType)
}
}
private var pagesSection: some View {
Section {
ForEach(lockedPages) { page in
pageRow(for: page)
.moveDisabled(true)
.deleteDisabled(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