mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
The NavigationStack-level background wasn't enough: macOS draws its own translucent material behind pushed navigation pages, and Form/List containers draw a translucent scroll background on top of anything placed behind them, so several pages kept the wallpaper-tinted look. Bake the opaque background into SettingsFormContainer (covers all pages built on it, pushed or not) and add opaqueSettingsFormBackground(), which hides the container's scroll background before applying the opaque one, to every macOS-reachable Form/List settings page: player controls and its sub-editors, sidebar settings, edit source, legacy data import, subscription/playlist import, customize home and its shortcut style page, and the log viewer. Contributors, translators and the remote control device page get the plain opaque background. Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
506 lines
16 KiB
Swift
506 lines
16 KiB
Swift
//
|
|
// PresetSelectorView.swift
|
|
// Yattee
|
|
//
|
|
// View for selecting and managing player controls presets.
|
|
//
|
|
|
|
import SwiftUI
|
|
#if !os(tvOS)
|
|
import UniformTypeIdentifiers
|
|
#endif
|
|
|
|
// MARK: - Export File Wrapper
|
|
|
|
#if !os(tvOS)
|
|
private struct ExportFile: Identifiable {
|
|
let id = UUID()
|
|
let url: URL
|
|
}
|
|
#endif
|
|
|
|
/// Pending preset creation request
|
|
private struct PendingPresetCreation: Equatable {
|
|
let name: String
|
|
let basePresetID: UUID?
|
|
}
|
|
|
|
/// Pending preset rename request
|
|
private struct PendingPresetRename: Equatable {
|
|
let presetID: UUID
|
|
let newName: String
|
|
}
|
|
|
|
/// Notification posted when a preset is selected in PresetSelectorView
|
|
extension Notification.Name {
|
|
static let presetSelectionDidChange = Notification.Name("presetSelectionDidChange")
|
|
}
|
|
|
|
/// View for selecting and managing player controls layout presets.
|
|
struct PresetSelectorView: View {
|
|
@Bindable var viewModel: PlayerControlsSettingsViewModel
|
|
var onPresetSelected: ((String) -> Void)?
|
|
|
|
@State private var showCreateSheet = false
|
|
@State private var presetToRename: LayoutPreset?
|
|
@State private var pendingCreation: PendingPresetCreation?
|
|
@State private var pendingRename: PendingPresetRename?
|
|
@State private var listRefreshID = UUID()
|
|
|
|
// Track active preset ID locally to force view updates
|
|
@State private var trackedActivePresetID: UUID?
|
|
|
|
// Import/Export state
|
|
#if !os(tvOS)
|
|
@State private var showingImportPicker = false
|
|
@State private var isImporting = false
|
|
@State private var importError: String?
|
|
@State private var showingImportError = false
|
|
@State private var importedPresetName: String?
|
|
@State private var showingImportSuccess = false
|
|
@State private var exportFile: ExportFile?
|
|
#endif
|
|
|
|
var body: some View {
|
|
presetList
|
|
.opaqueSettingsFormBackground()
|
|
.navigationTitle(String(localized: "settings.playerControls.presets"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.toolbar { toolbarContent }
|
|
.sheet(isPresented: $showCreateSheet) { createPresetSheet }
|
|
.sheet(item: $presetToRename) { preset in renamePresetSheet(preset) }
|
|
#if os(iOS)
|
|
.sheet(isPresented: $showingImportPicker) { importPickerSheet }
|
|
.sheet(item: $exportFile) { file in ShareSheet(items: [file.url]) }
|
|
#endif
|
|
#if !os(tvOS)
|
|
.alert(
|
|
String(localized: "settings.playerControls.import.error.title"),
|
|
isPresented: $showingImportError
|
|
) {
|
|
Button(String(localized: "settings.playerControls.ok")) {}
|
|
} message: {
|
|
if let error = importError {
|
|
Text(error)
|
|
}
|
|
}
|
|
.alert(
|
|
String(localized: "settings.playerControls.import.success.title"),
|
|
isPresented: $showingImportSuccess
|
|
) {
|
|
Button(String(localized: "settings.playerControls.ok")) {}
|
|
} message: {
|
|
if let name = importedPresetName {
|
|
Text(String(localized: "settings.playerControls.import.success.message \(name)"))
|
|
}
|
|
}
|
|
#endif
|
|
.task(id: pendingCreation) {
|
|
guard let creation = pendingCreation else { return }
|
|
let basePreset = creation.basePresetID.flatMap { id in
|
|
viewModel.presets.first { $0.id == id }
|
|
}
|
|
await viewModel.createPreset(name: creation.name, basedOn: basePreset)
|
|
pendingCreation = nil
|
|
// Force view update by updating local tracked state
|
|
trackedActivePresetID = viewModel.activePreset?.id
|
|
listRefreshID = UUID()
|
|
// Notify parent of selection change
|
|
if let name = viewModel.activePreset?.name {
|
|
onPresetSelected?(name)
|
|
NotificationCenter.default.post(name: .presetSelectionDidChange, object: name)
|
|
}
|
|
}
|
|
.task(id: pendingRename) {
|
|
guard let rename = pendingRename else { return }
|
|
if let preset = viewModel.presets.first(where: { $0.id == rename.presetID }) {
|
|
await viewModel.renamePreset(preset, to: rename.newName)
|
|
}
|
|
pendingRename = nil
|
|
listRefreshID = UUID()
|
|
}
|
|
.onAppear {
|
|
trackedActivePresetID = viewModel.activePreset?.id
|
|
}
|
|
}
|
|
|
|
// MARK: - View Components
|
|
|
|
private var presetList: some View {
|
|
List {
|
|
builtInPresetsSection
|
|
customPresetsSection
|
|
}
|
|
.id(listRefreshID)
|
|
}
|
|
|
|
private var builtInPresetsSection: some View {
|
|
Section {
|
|
ForEach(viewModel.builtInPresets) { preset in
|
|
PresetRow(
|
|
preset: preset,
|
|
isActive: preset.id == trackedActivePresetID,
|
|
onSelect: {
|
|
viewModel.selectPreset(preset)
|
|
trackedActivePresetID = preset.id
|
|
onPresetSelected?(preset.name)
|
|
NotificationCenter.default.post(name: .presetSelectionDidChange, object: preset.name)
|
|
}
|
|
)
|
|
}
|
|
} header: {
|
|
Text(String(localized: "settings.playerControls.builtInPresets"))
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var customPresetsSection: some View {
|
|
if !viewModel.customPresets.isEmpty {
|
|
Section {
|
|
ForEach(viewModel.customPresets) { preset in
|
|
customPresetRow(preset)
|
|
}
|
|
} header: {
|
|
Text(String(localized: "settings.playerControls.customPresets"))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func customPresetRow(_ preset: LayoutPreset) -> some View {
|
|
PresetRow(
|
|
preset: preset,
|
|
isActive: preset.id == trackedActivePresetID,
|
|
onSelect: {
|
|
viewModel.selectPreset(preset)
|
|
trackedActivePresetID = preset.id
|
|
onPresetSelected?(preset.name)
|
|
NotificationCenter.default.post(name: .presetSelectionDidChange, object: preset.name)
|
|
},
|
|
onRename: { presetToRename = preset },
|
|
onExport: {
|
|
#if !os(tvOS)
|
|
exportPreset(preset)
|
|
#endif
|
|
},
|
|
onDelete: {
|
|
Task { await viewModel.deletePreset(preset) }
|
|
},
|
|
canDelete: preset.id != trackedActivePresetID
|
|
)
|
|
}
|
|
|
|
@ToolbarContentBuilder
|
|
private var toolbarContent: some ToolbarContent {
|
|
#if !os(tvOS)
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Menu {
|
|
Button {
|
|
showCreateSheet = true
|
|
} label: {
|
|
Label(
|
|
String(localized: "settings.playerControls.newPreset"),
|
|
systemImage: "plus"
|
|
)
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button {
|
|
showImportPicker()
|
|
} label: {
|
|
Label(
|
|
String(localized: "settings.playerControls.importPreset"),
|
|
systemImage: "square.and.arrow.down"
|
|
)
|
|
}
|
|
.disabled(isImporting)
|
|
} label: {
|
|
Label(
|
|
String(localized: "settings.playerControls.newPreset"),
|
|
systemImage: "plus"
|
|
)
|
|
}
|
|
}
|
|
#else
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
showCreateSheet = true
|
|
} label: {
|
|
Label(
|
|
String(localized: "settings.playerControls.newPreset"),
|
|
systemImage: "plus"
|
|
)
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private var createPresetSheet: some View {
|
|
PresetEditorView(
|
|
mode: .create(
|
|
baseLayouts: viewModel.presets,
|
|
activePreset: viewModel.activePreset
|
|
),
|
|
onSave: { name, basePresetID in
|
|
pendingCreation = PendingPresetCreation(name: name, basePresetID: basePresetID)
|
|
}
|
|
)
|
|
}
|
|
|
|
private func renamePresetSheet(_ preset: LayoutPreset) -> some View {
|
|
let presetID = preset.id
|
|
return PresetEditorView(
|
|
mode: .rename(currentName: preset.name),
|
|
onSave: { name, _ in
|
|
pendingRename = PendingPresetRename(presetID: presetID, newName: name)
|
|
}
|
|
)
|
|
}
|
|
|
|
#if os(iOS)
|
|
private var importPickerSheet: some View {
|
|
PresetFilePickerView { url in
|
|
handleImportedFile(url)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// MARK: - Import/Export Actions
|
|
|
|
#if !os(tvOS)
|
|
private func showImportPicker() {
|
|
#if os(iOS)
|
|
showingImportPicker = true
|
|
#elseif os(macOS)
|
|
showMacOSImportPanel()
|
|
#endif
|
|
}
|
|
|
|
#if os(macOS)
|
|
private func showMacOSImportPanel() {
|
|
let panel = NSOpenPanel()
|
|
panel.allowedContentTypes = [.json]
|
|
panel.canChooseFiles = true
|
|
panel.canChooseDirectories = false
|
|
panel.allowsMultipleSelection = false
|
|
panel.message = String(localized: "settings.playerControls.import.panel.message")
|
|
|
|
if panel.runModal() == .OK, let url = panel.url {
|
|
handleImportedFile(url)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private func handleImportedFile(_ url: URL) {
|
|
isImporting = true
|
|
|
|
Task {
|
|
do {
|
|
let presetName = try await viewModel.importPreset(from: url)
|
|
await MainActor.run {
|
|
isImporting = false
|
|
importedPresetName = presetName
|
|
showingImportSuccess = true
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
isImporting = false
|
|
importError = error.localizedDescription
|
|
showingImportError = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func exportPreset(_ preset: LayoutPreset) {
|
|
guard let url = viewModel.exportPreset(preset) else { return }
|
|
|
|
#if os(iOS)
|
|
exportFile = ExportFile(url: url)
|
|
#elseif os(macOS)
|
|
showMacOSSavePanel(url: url, preset: preset)
|
|
#endif
|
|
}
|
|
|
|
#if os(macOS)
|
|
private func showMacOSSavePanel(url: URL, preset: LayoutPreset) {
|
|
let panel = NSSavePanel()
|
|
panel.nameFieldStringValue = PlayerControlsPresetExportImport.generateExportFilename(for: preset)
|
|
panel.allowedContentTypes = [.json]
|
|
|
|
if panel.runModal() == .OK, let saveURL = panel.url {
|
|
do {
|
|
// Remove existing file if it exists (NSSavePanel asks for confirmation)
|
|
if FileManager.default.fileExists(atPath: saveURL.path) {
|
|
try FileManager.default.removeItem(at: saveURL)
|
|
}
|
|
try FileManager.default.copyItem(at: url, to: saveURL)
|
|
} catch {
|
|
LoggingService.shared.error("Failed to save preset file: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Preset Row
|
|
|
|
private struct PresetRow: View {
|
|
let preset: LayoutPreset
|
|
let isActive: Bool
|
|
let onSelect: () -> Void
|
|
var onRename: (() -> Void)? = nil
|
|
var onExport: (() -> Void)? = nil
|
|
var onDelete: (() -> Void)? = nil
|
|
var canDelete: Bool = true
|
|
|
|
var body: some View {
|
|
Button(action: onSelect) {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(preset.name)
|
|
.foregroundStyle(.primary)
|
|
|
|
if preset.isBuiltIn {
|
|
Text(String(localized: "settings.playerControls.builtIn"))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if isActive {
|
|
Image(systemName: "checkmark")
|
|
.foregroundStyle(.tint)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
#if os(iOS)
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
if let onDelete, !preset.isBuiltIn, canDelete {
|
|
Button(role: .destructive) {
|
|
onDelete()
|
|
} label: {
|
|
Label(
|
|
String(localized: "settings.playerControls.delete"),
|
|
systemImage: "trash"
|
|
)
|
|
}
|
|
}
|
|
|
|
if let onRename, !preset.isBuiltIn {
|
|
Button {
|
|
onRename()
|
|
} label: {
|
|
Label(
|
|
String(localized: "settings.playerControls.rename"),
|
|
systemImage: "pencil"
|
|
)
|
|
}
|
|
.tint(.orange)
|
|
}
|
|
|
|
if let onExport, !preset.isBuiltIn {
|
|
Button {
|
|
onExport()
|
|
} label: {
|
|
Label(
|
|
String(localized: "settings.playerControls.exportPreset"),
|
|
systemImage: "square.and.arrow.up"
|
|
)
|
|
}
|
|
.tint(.blue)
|
|
}
|
|
}
|
|
#endif
|
|
.contextMenu {
|
|
if let onRename, !preset.isBuiltIn {
|
|
Button {
|
|
onRename()
|
|
} label: {
|
|
Label(
|
|
String(localized: "settings.playerControls.rename"),
|
|
systemImage: "pencil"
|
|
)
|
|
}
|
|
}
|
|
|
|
if let onExport, !preset.isBuiltIn {
|
|
Button {
|
|
onExport()
|
|
} label: {
|
|
Label(
|
|
String(localized: "settings.playerControls.exportPreset"),
|
|
systemImage: "square.and.arrow.up"
|
|
)
|
|
}
|
|
}
|
|
|
|
if let onDelete, !preset.isBuiltIn, canDelete {
|
|
Divider()
|
|
Button(role: .destructive) {
|
|
onDelete()
|
|
} label: {
|
|
Label(
|
|
String(localized: "settings.playerControls.delete"),
|
|
systemImage: "trash"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - File Picker (iOS)
|
|
|
|
#if os(iOS)
|
|
private struct PresetFilePickerView: UIViewControllerRepresentable {
|
|
let onSelect: (URL) -> Void
|
|
|
|
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
|
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.json])
|
|
picker.delegate = context.coordinator
|
|
picker.allowsMultipleSelection = false
|
|
return picker
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(onSelect: onSelect)
|
|
}
|
|
|
|
final class Coordinator: NSObject, UIDocumentPickerDelegate {
|
|
let onSelect: (URL) -> Void
|
|
|
|
init(onSelect: @escaping (URL) -> Void) {
|
|
self.onSelect = onSelect
|
|
}
|
|
|
|
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
|
guard let url = urls.first else { return }
|
|
onSelect(url)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
PresetSelectorView(
|
|
viewModel: PlayerControlsSettingsViewModel(
|
|
layoutService: PlayerControlsLayoutService(),
|
|
settingsManager: SettingsManager()
|
|
)
|
|
)
|
|
}
|
|
}
|