mirror of
https://github.com/yattee/yattee.git
synced 2026-02-21 10:19:46 +00:00
Yattee v2 rewrite
This commit is contained in:
504
Yattee/Views/Settings/PlayerControls/PresetSelectorView.swift
Normal file
504
Yattee/Views/Settings/PlayerControls/PresetSelectorView.swift
Normal file
@@ -0,0 +1,504 @@
|
||||
//
|
||||
// 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
|
||||
.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(tvOS)
|
||||
.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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user