// // 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, 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 { var locked: Set = [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