mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
439 lines
16 KiB
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
|