// // PlayerControlsSettingsViewModel.swift // Yattee // // ViewModel for the player controls customization settings UI. // import Foundation import SwiftUI /// ViewModel for the player controls settings view. /// Manages preset selection, layout editing, and auto-save. @MainActor @Observable final class PlayerControlsSettingsViewModel { // MARK: - Dependencies private let layoutService: PlayerControlsLayoutService let settingsManager: SettingsManager /// Observer for preset changes from CloudKit sync. @ObservationIgnored private var presetsChangedObserver: NSObjectProtocol? // MARK: - State /// All available presets for the current device. private(set) var presets: [LayoutPreset] = [] /// The currently active/selected preset. private(set) var activePreset: LayoutPreset? /// Whether currently loading presets. private(set) var isLoading = false /// Error message to display, if any. private(set) var error: String? /// Whether the preview is showing landscape mode. var isPreviewingLandscape = false /// The section currently selected for editing. var selectedSection: LayoutSectionType? /// Whether a save operation is in progress. private(set) var isSaving = false /// Debounced save task for coalescing rapid updates. private var saveTask: Task? // MARK: - Computed Properties /// ID of the active preset. var activePresetID: UUID? { activePreset?.id } /// Whether the active preset can be deleted (not built-in and not active). var canDeleteActivePreset: Bool { guard let activePreset else { return false } return !activePreset.isBuiltIn } /// Whether the active preset can be edited (not built-in). var canEditActivePreset: Bool { guard let activePreset else { return false } return !activePreset.isBuiltIn } /// Current layout from active preset, or default. var currentLayout: PlayerControlsLayout { activePreset?.layout ?? .default } /// Current center section settings for observation. var centerSettings: CenterSectionSettings { activePreset?.layout.centerSettings ?? .default } /// Seek backward seconds - exposed for direct observation. var seekBackwardSeconds: Int { activePreset?.layout.centerSettings.seekBackwardSeconds ?? 10 } /// Seek forward seconds - exposed for direct observation. var seekForwardSeconds: Int { activePreset?.layout.centerSettings.seekForwardSeconds ?? 10 } /// Current progress bar settings. var progressBarSettings: ProgressBarSettings { activePreset?.layout.progressBarSettings ?? .default } /// Built-in presets sorted by name. var builtInPresets: [LayoutPreset] { presets.filter(\.isBuiltIn).sorted { $0.name < $1.name } } /// Custom (user-created) presets sorted by name. var customPresets: [LayoutPreset] { presets.filter { !$0.isBuiltIn }.sorted { $0.name < $1.name } } /// Button types that are new since last seen. var newButtonTypes: [ControlButtonType] { Task.detached { [layoutService] in await layoutService.newButtonTypes() } // Return empty for now; actual implementation will load async return [] } // MARK: - Initialization init( layoutService: PlayerControlsLayoutService, settingsManager: SettingsManager ) { self.layoutService = layoutService self.settingsManager = settingsManager // Observe preset changes from CloudKit sync presetsChangedObserver = NotificationCenter.default.addObserver( forName: .playerControlsPresetsDidChange, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in await self?.handlePresetsChanged() } } } /// Handles preset changes from CloudKit sync. private func handlePresetsChanged() async { do { let loadedPresets = try await layoutService.loadPresets() presets = loadedPresets.filter { $0.deviceClass == .current } // Refresh active preset if it was updated // Use in-memory activePreset?.id to avoid race condition with setActivePresetID if let activeID = activePreset?.id, let updatedPreset = presets.first(where: { $0.id == activeID }) { activePreset = updatedPreset } LoggingService.shared.debug("Reloaded presets after CloudKit sync", category: .general) } catch { LoggingService.shared.error("Failed to reload presets after CloudKit sync: \(error.localizedDescription)") } } deinit { if let observer = presetsChangedObserver { NotificationCenter.default.removeObserver(observer) } } // MARK: - Loading /// Loads all presets and sets the active preset. func loadPresets() async { isLoading = true error = nil do { let loadedPresets = try await layoutService.loadPresets() presets = loadedPresets.filter { $0.deviceClass == .current } activePreset = await layoutService.activePreset() LoggingService.shared.info("Loaded \(presets.count) presets, active: \(activePreset?.name ?? "none")") } catch { self.error = error.localizedDescription LoggingService.shared.error("Failed to load presets: \(error.localizedDescription)") } isLoading = false } /// Refreshes presets from storage. func refreshPresets() async { do { let loadedPresets = try await layoutService.loadPresets() presets = loadedPresets.filter { $0.deviceClass == .current } // Update active preset if it changed if let activeID = activePresetID, let updatedPreset = presets.first(where: { $0.id == activeID }) { activePreset = updatedPreset } } catch { LoggingService.shared.error("Failed to refresh presets: \(error.localizedDescription)") } } // MARK: - Preset Selection /// Selects a preset as the active preset. /// - Parameter preset: The preset to activate. func selectPreset(_ preset: LayoutPreset) { activePreset = preset Task { await layoutService.setActivePresetID(preset.id) } LoggingService.shared.info("Selected preset: \(preset.name)") } /// Selects a preset by ID. /// - Parameter id: The ID of the preset to activate. func selectPreset(id: UUID) { guard let preset = presets.first(where: { $0.id == id }) else { return } selectPreset(preset) } // MARK: - Preset Management /// Creates a new custom preset with the given name. /// - Parameters: /// - name: Name for the new preset. /// - basePreset: Optional preset to copy layout from. Uses default layout if nil. func createPreset(name: String, basedOn basePreset: LayoutPreset? = nil) async { let layout = basePreset?.layout ?? .default let newPreset = LayoutPreset( name: name, isBuiltIn: false, deviceClass: .current, layout: layout ) do { try await layoutService.savePreset(newPreset) await refreshPresets() // Select the newly created preset from the refreshed list for consistency if let savedPreset = presets.first(where: { $0.id == newPreset.id }) { selectPreset(savedPreset) } else { selectPreset(newPreset) } LoggingService.shared.info("Created preset: \(name) based on: \(basePreset?.name ?? "default")") } catch { self.error = error.localizedDescription LoggingService.shared.error("Failed to create preset: \(error.localizedDescription)") } } /// Deletes a preset. /// - Parameter preset: The preset to delete. func deletePreset(_ preset: LayoutPreset) async { guard !preset.isBuiltIn else { error = "Cannot delete built-in presets" return } // If deleting active preset, switch to default first let wasActive = preset.id == activePresetID if wasActive { if let defaultPreset = builtInPresets.first(where: { $0.name == "Default" }) ?? builtInPresets.first { selectPreset(defaultPreset) } } do { try await layoutService.deletePreset(id: preset.id) await refreshPresets() LoggingService.shared.info("Deleted preset: \(preset.name)") } catch { self.error = error.localizedDescription LoggingService.shared.error("Failed to delete preset: \(error.localizedDescription)") } } /// Renames a preset. /// - Parameters: /// - preset: The preset to rename. /// - newName: The new name. func renamePreset(_ preset: LayoutPreset, to newName: String) async { guard !preset.isBuiltIn else { error = "Cannot rename built-in presets" return } do { try await layoutService.renamePreset(preset.id, to: newName) await refreshPresets() LoggingService.shared.info("Renamed preset to: \(newName)") } catch { self.error = error.localizedDescription LoggingService.shared.error("Failed to rename preset: \(error.localizedDescription)") } } // MARK: - Layout Editing /// Updates the top section of the current layout. /// - Parameter section: The new top section configuration. func updateTopSection(_ section: LayoutSection) async { await updateLayout { layout in layout.topSection = section } } /// Updates the bottom section of the current layout. /// - Parameter section: The new bottom section configuration. func updateBottomSection(_ section: LayoutSection) async { await updateLayout { layout in layout.bottomSection = section } } /// Updates the center settings of the current layout. /// - Parameter settings: The new center section settings. func updateCenterSettings(_ settings: CenterSectionSettings) async { await updateLayout { layout in layout.centerSettings = settings } } /// Synchronously updates center settings with immediate UI feedback. /// Saves are debounced to avoid rapid disk writes. /// - Parameter mutation: A closure that mutates the center settings. func updateCenterSettingsSync(_ mutation: (inout CenterSectionSettings) -> Void) { guard var preset = activePreset, !preset.isBuiltIn else { error = "Cannot edit built-in presets. Duplicate it first." return } var layout = preset.layout let oldSettings = layout.centerSettings mutation(&layout.centerSettings) let newSettings = layout.centerSettings LoggingService.shared.debug( "updateCenterSettingsSync: seekBackward \(oldSettings.seekBackwardSeconds) -> \(newSettings.seekBackwardSeconds), seekForward \(oldSettings.seekForwardSeconds) -> \(newSettings.seekForwardSeconds)", category: .general ) preset = preset.withUpdatedLayout(layout) self.activePreset = preset // Queue debounced save queueDebouncedSave(preset: preset) } /// Updates the global settings of the current layout. /// - Parameter settings: The new global settings. func updateGlobalSettings(_ settings: GlobalLayoutSettings) async { await updateLayout { layout in layout.globalSettings = settings } } /// Synchronously updates global settings with immediate UI feedback. /// Saves are debounced to avoid rapid disk writes. /// - Parameter mutation: A closure that mutates the global settings. func updateGlobalSettingsSync(_ mutation: (inout GlobalLayoutSettings) -> Void) { guard var preset = activePreset, !preset.isBuiltIn else { error = "Cannot edit built-in presets. Duplicate it first." return } var layout = preset.layout mutation(&layout.globalSettings) preset = preset.withUpdatedLayout(layout) self.activePreset = preset // Queue debounced save queueDebouncedSave(preset: preset) } /// Synchronously updates progress bar settings with immediate UI feedback. /// Saves are debounced to avoid rapid disk writes. /// - Parameter mutation: A closure that mutates the progress bar settings. func updateProgressBarSettingsSync(_ mutation: (inout ProgressBarSettings) -> Void) { guard var preset = activePreset, !preset.isBuiltIn else { error = "Cannot edit built-in presets. Duplicate it first." return } var layout = preset.layout mutation(&layout.progressBarSettings) preset = preset.withUpdatedLayout(layout) self.activePreset = preset // Queue debounced save queueDebouncedSave(preset: preset) } /// Updates a button configuration in the specified section. /// - Parameters: /// - config: The updated button configuration. /// - section: The section containing the button. func updateButtonConfiguration(_ config: ControlButtonConfiguration, in section: LayoutSectionType) async { await updateLayout { layout in switch section { case .top: layout.topSection.update(button: config) case .bottom: layout.bottomSection.update(button: config) } } } /// Synchronously updates a button configuration with immediate UI feedback. /// Saves are debounced to avoid rapid disk writes. /// - Parameters: /// - config: The updated button configuration. /// - section: The section containing the button. func updateButtonConfigurationSync(_ config: ControlButtonConfiguration, in section: LayoutSectionType) { guard var preset = activePreset, !preset.isBuiltIn else { error = "Cannot edit built-in presets. Duplicate it first." return } var layout = preset.layout switch section { case .top: layout.topSection.update(button: config) case .bottom: layout.bottomSection.update(button: config) } preset = preset.withUpdatedLayout(layout) self.activePreset = preset // Queue debounced save queueDebouncedSave(preset: preset) } /// Adds a button to the specified section. /// - Parameters: /// - buttonType: The type of button to add. /// - section: The section to add the button to. func addButton(_ buttonType: ControlButtonType, to section: LayoutSectionType) async { await updateLayout { layout in let config = ControlButtonConfiguration.defaultConfiguration(for: buttonType) switch section { case .top: layout.topSection.add(button: config) case .bottom: layout.bottomSection.add(button: config) } } } /// Synchronously adds a button with immediate UI feedback. /// - Parameters: /// - buttonType: The type of button to add. /// - section: The section to add the button to. func addButtonSync(_ buttonType: ControlButtonType, to section: LayoutSectionType) { guard var preset = activePreset, !preset.isBuiltIn else { error = "Cannot edit built-in presets. Duplicate it first." return } var layout = preset.layout let config = ControlButtonConfiguration.defaultConfiguration(for: buttonType) switch section { case .top: layout.topSection.add(button: config) case .bottom: layout.bottomSection.add(button: config) } preset = preset.withUpdatedLayout(layout) self.activePreset = preset queueDebouncedSave(preset: preset) } /// Removes a button from the specified section. /// - Parameters: /// - buttonID: The ID of the button to remove. /// - section: The section containing the button. func removeButton(_ buttonID: UUID, from section: LayoutSectionType) async { await updateLayout { layout in switch section { case .top: layout.topSection.remove(id: buttonID) case .bottom: layout.bottomSection.remove(id: buttonID) } } } /// Synchronously removes a button with immediate UI feedback. /// - Parameters: /// - buttonID: The ID of the button to remove. /// - section: The section containing the button. func removeButtonSync(_ buttonID: UUID, from section: LayoutSectionType) { guard var preset = activePreset, !preset.isBuiltIn else { error = "Cannot edit built-in presets. Duplicate it first." return } var layout = preset.layout switch section { case .top: layout.topSection.remove(id: buttonID) case .bottom: layout.bottomSection.remove(id: buttonID) } preset = preset.withUpdatedLayout(layout) self.activePreset = preset queueDebouncedSave(preset: preset) } /// Moves a button within a section. /// - Parameters: /// - source: Source indices. /// - destination: Destination index. /// - section: The section containing the buttons. func moveButton(fromOffsets source: IndexSet, toOffset destination: Int, in section: LayoutSectionType) async { await updateLayout { layout in switch section { case .top: layout.topSection.move(fromOffsets: source, toOffset: destination) case .bottom: layout.bottomSection.move(fromOffsets: source, toOffset: destination) } } } /// Synchronously moves a button with immediate UI feedback. /// - Parameters: /// - source: Source indices. /// - destination: Destination index. /// - section: The section containing the buttons. func moveButtonSync(fromOffsets source: IndexSet, toOffset destination: Int, in section: LayoutSectionType) { guard var preset = activePreset, !preset.isBuiltIn else { error = "Cannot edit built-in presets. Duplicate it first." return } var layout = preset.layout switch section { case .top: layout.topSection.move(fromOffsets: source, toOffset: destination) case .bottom: layout.bottomSection.move(fromOffsets: source, toOffset: destination) } preset = preset.withUpdatedLayout(layout) self.activePreset = preset queueDebouncedSave(preset: preset) } // MARK: - Gesture Settings /// Current gestures settings from active preset, or default. var gesturesSettings: GesturesSettings { activePreset?.layout.effectiveGesturesSettings ?? .default } /// Synchronously updates gestures settings with immediate UI feedback. /// Saves are debounced to avoid rapid disk writes. /// - Parameter mutation: A closure that mutates the gestures settings. func updateGesturesSettingsSync(_ mutation: (inout GesturesSettings) -> Void) { guard var preset = activePreset, !preset.isBuiltIn else { error = "Cannot edit built-in presets. Duplicate it first." return } var layout = preset.layout var settings = layout.effectiveGesturesSettings mutation(&settings) layout.gesturesSettings = settings preset = preset.withUpdatedLayout(layout) self.activePreset = preset queueDebouncedSave(preset: preset) } /// Synchronously updates tap gestures settings with immediate UI feedback. /// - Parameter mutation: A closure that mutates the tap gestures settings. func updateTapGesturesSettingsSync(_ mutation: (inout TapGesturesSettings) -> Void) { updateGesturesSettingsSync { settings in mutation(&settings.tapGestures) } } /// Updates a tap zone configuration. /// - Parameter config: The updated configuration. func updateTapZoneConfigurationSync(_ config: TapZoneConfiguration) { updateTapGesturesSettingsSync { settings in settings = settings.withUpdatedConfiguration(config) } } /// Current seek gesture settings from active preset, or default. var seekGestureSettings: SeekGestureSettings { activePreset?.layout.effectiveGesturesSettings.seekGesture ?? .default } /// Synchronously updates seek gesture settings with immediate UI feedback. /// - Parameter mutation: A closure that mutates the seek gesture settings. func updateSeekGestureSettingsSync(_ mutation: (inout SeekGestureSettings) -> Void) { updateGesturesSettingsSync { settings in mutation(&settings.seekGesture) } } /// Current panscan gesture settings from active preset, or default. var panscanGestureSettings: PanscanGestureSettings { activePreset?.layout.effectiveGesturesSettings.panscanGesture ?? .default } /// Synchronously updates panscan gesture settings with immediate UI feedback. /// - Parameter mutation: A closure that mutates the panscan gesture settings. func updatePanscanGestureSettingsSync(_ mutation: (inout PanscanGestureSettings) -> Void) { updateGesturesSettingsSync { settings in mutation(&settings.panscanGesture) } } // MARK: - Player Pill Settings /// Current player pill settings from active preset, or default. var playerPillSettings: PlayerPillSettings { activePreset?.layout.effectivePlayerPillSettings ?? .default } /// Current pill visibility mode. var pillVisibility: PillVisibility { playerPillSettings.visibility } /// Current pill buttons configuration. var pillButtons: [ControlButtonConfiguration] { playerPillSettings.buttons } /// Current comments pill mode. var commentsPillMode: CommentsPillMode { playerPillSettings.effectiveCommentsPillMode } /// Synchronously updates the comments pill mode with immediate UI feedback. /// - Parameter mode: The new comments pill mode. func syncCommentsPillMode(_ mode: CommentsPillMode) { updatePlayerPillSettingsSync { settings in settings.commentsPillMode = mode } } // MARK: - Wide Layout Panel Settings /// Current wide layout panel alignment from active preset, or default. var wideLayoutPanelAlignment: FloatingPanelSide { activePreset?.layout.effectiveWideLayoutPanelAlignment ?? .right } /// Synchronously updates the wide layout panel alignment with immediate UI feedback. /// - Parameter alignment: The new panel alignment. func syncWideLayoutPanelAlignment(_ alignment: FloatingPanelSide) { guard var preset = activePreset, !preset.isBuiltIn else { error = "Cannot edit built-in presets. Duplicate it first." return } var layout = preset.layout layout.wideLayoutPanelAlignment = alignment preset = preset.withUpdatedLayout(layout) self.activePreset = preset queueDebouncedSave(preset: preset) } /// Synchronously updates the pill visibility with immediate UI feedback. /// - Parameter visibility: The new visibility mode. func syncPillVisibility(_ visibility: PillVisibility) { updatePlayerPillSettingsSync { settings in settings.visibility = visibility } } /// Adds a button to the pill. /// - Parameter buttonType: The type of button to add. func addPillButton(_ buttonType: ControlButtonType) { updatePlayerPillSettingsSync { settings in settings.add(buttonType: buttonType) } } /// Removes a button from the pill at the given index. /// - Parameter index: The index of the button to remove. func removePillButton(at index: Int) { updatePlayerPillSettingsSync { settings in settings.remove(at: index) } } /// Moves buttons within the pill. /// - Parameters: /// - source: Source indices to move from. /// - destination: Destination index to move to. func movePillButtons(fromOffsets source: IndexSet, toOffset destination: Int) { updatePlayerPillSettingsSync { settings in settings.move(fromOffsets: source, toOffset: destination) } } /// Updates a specific button configuration in the pill. /// - Parameter configuration: The updated button configuration with matching ID. func updatePillButtonConfiguration(_ configuration: ControlButtonConfiguration) { updatePlayerPillSettingsSync { settings in settings.update(configuration) } } /// Synchronously updates player pill settings with immediate UI feedback. /// Saves are debounced to avoid rapid disk writes. /// - Parameter mutation: A closure that mutates the pill settings. func updatePlayerPillSettingsSync(_ mutation: (inout PlayerPillSettings) -> Void) { guard var preset = activePreset, !preset.isBuiltIn else { error = "Cannot edit built-in presets. Duplicate it first." return } var layout = preset.layout var settings = layout.effectivePlayerPillSettings mutation(&settings) layout.playerPillSettings = settings preset = preset.withUpdatedLayout(layout) self.activePreset = preset queueDebouncedSave(preset: preset) } // MARK: - Mini Player Settings /// Current mini player settings from active preset, or default. var miniPlayerSettings: MiniPlayerSettings { activePreset?.layout.effectiveMiniPlayerSettings ?? .default } /// Whether to show video in mini player. var miniPlayerShowVideo: Bool { miniPlayerSettings.showVideo } /// Action when tapping video in mini player. var miniPlayerVideoTapAction: MiniPlayerVideoTapAction { miniPlayerSettings.videoTapAction } /// Current mini player buttons configuration. var miniPlayerButtons: [ControlButtonConfiguration] { miniPlayerSettings.buttons } /// Synchronously updates the mini player show video setting. /// - Parameter showVideo: Whether to show video in mini player. func syncMiniPlayerShowVideo(_ showVideo: Bool) { updateMiniPlayerSettingsSync { settings in settings.showVideo = showVideo } } /// Synchronously updates the mini player video tap action. /// - Parameter action: The new tap action. func syncMiniPlayerVideoTapAction(_ action: MiniPlayerVideoTapAction) { updateMiniPlayerSettingsSync { settings in settings.videoTapAction = action } } /// Adds a button to the mini player. /// - Parameter buttonType: The type of button to add. func addMiniPlayerButton(_ buttonType: ControlButtonType) { updateMiniPlayerSettingsSync { settings in settings.add(buttonType: buttonType) } } /// Removes a button from the mini player at the given index. /// - Parameter index: The index of the button to remove. func removeMiniPlayerButton(at index: Int) { updateMiniPlayerSettingsSync { settings in settings.remove(at: index) } } /// Moves buttons within the mini player. /// - Parameters: /// - source: Source indices to move from. /// - destination: Destination index to move to. func moveMiniPlayerButtons(fromOffsets source: IndexSet, toOffset destination: Int) { updateMiniPlayerSettingsSync { settings in settings.move(fromOffsets: source, toOffset: destination) } } /// Updates a specific button configuration in the mini player. /// - Parameter configuration: The updated button configuration with matching ID. func updateMiniPlayerButtonConfiguration(_ configuration: ControlButtonConfiguration) { updateMiniPlayerSettingsSync { settings in settings.update(configuration) } } /// Synchronously updates mini player settings with immediate UI feedback. /// Saves are debounced to avoid rapid disk writes. /// - Parameter mutation: A closure that mutates the mini player settings. func updateMiniPlayerSettingsSync(_ mutation: (inout MiniPlayerSettings) -> Void) { guard var preset = activePreset, !preset.isBuiltIn else { error = "Cannot edit built-in presets. Duplicate it first." return } var layout = preset.layout var settings = layout.effectiveMiniPlayerSettings mutation(&settings) layout.miniPlayerSettings = settings preset = preset.withUpdatedLayout(layout) self.activePreset = preset queueDebouncedSave(preset: preset) } // MARK: - System Controls & Volume /// Current system controls mode from active preset, or default. var systemControlsMode: SystemControlsMode { activePreset?.layout.globalSettings.systemControlsMode ?? .seek } /// Current system controls seek duration from active preset, or default. var systemControlsSeekDuration: SystemControlsSeekDuration { activePreset?.layout.globalSettings.systemControlsSeekDuration ?? .tenSeconds } /// Current volume mode from active preset, or default. var volumeMode: VolumeMode { activePreset?.layout.globalSettings.volumeMode ?? .mpv } /// Synchronously updates the system controls mode with immediate UI feedback. func updateSystemControlsModeSync(_ mode: SystemControlsMode) { updateGlobalSettingsSync { settings in settings.systemControlsMode = mode } } /// Synchronously updates the system controls seek duration with immediate UI feedback. func updateSystemControlsSeekDurationSync(_ duration: SystemControlsSeekDuration) { updateGlobalSettingsSync { settings in settings.systemControlsSeekDuration = duration } } /// Synchronously updates the volume mode with immediate UI feedback. func updateVolumeModeSync(_ mode: VolumeMode) { updateGlobalSettingsSync { settings in settings.volumeMode = mode } } // MARK: - Private Helpers /// Updates the layout and saves to storage. /// - Parameter mutation: A closure that mutates the layout. private func updateLayout(_ mutation: (inout PlayerControlsLayout) -> Void) async { guard let activePreset, !activePreset.isBuiltIn else { error = "Cannot edit built-in presets. Duplicate it first." return } var layout = activePreset.layout mutation(&layout) // Update local state immediately for responsive UI self.activePreset = activePreset.withUpdatedLayout(layout) // Save to storage isSaving = true do { try await layoutService.updatePresetLayout(activePreset.id, layout: layout) await refreshPresets() } catch { self.error = error.localizedDescription LoggingService.shared.error("Failed to save layout: \(error.localizedDescription)") } isSaving = false } /// Queues a debounced save operation. /// Cancels any pending save and waits before persisting to avoid rapid disk writes. /// - Parameter preset: The preset to save. private func queueDebouncedSave(preset: LayoutPreset) { saveTask?.cancel() saveTask = Task { // Wait for 300ms to coalesce rapid changes (e.g., slider dragging) try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } isSaving = true do { try await layoutService.updatePresetLayout(preset.id, layout: preset.layout) // Don't call refreshPresets() here - we already have the updated state locally } catch { self.error = error.localizedDescription LoggingService.shared.error("Failed to save layout: \(error.localizedDescription)") } isSaving = false } } // MARK: - NEW Badge Support /// Marks all button types as seen, hiding NEW badges. func markAllButtonsAsSeen() async { await layoutService.markAllButtonsAsSeen() } /// Returns whether a button type should show the NEW badge. /// - Parameter type: The button type to check. /// - Returns: True if the button was added after the last seen version. func isNewButton(_ type: ControlButtonType) async -> Bool { let newTypes = await layoutService.newButtonTypes() return newTypes.contains(type) } // MARK: - Error Handling /// Clears the current error. func clearError() { error = nil } // MARK: - Export/Import /// Exports a preset to a temporary JSON file. /// - Parameter preset: The preset to export. /// - Returns: URL of the temporary file, or nil if export failed. func exportPreset(_ preset: LayoutPreset) -> URL? { guard !preset.isBuiltIn else { error = "Cannot export built-in presets" return nil } guard let data = PlayerControlsPresetExportImport.exportToJSON(preset) else { error = "Failed to export preset" return nil } let filename = PlayerControlsPresetExportImport.generateExportFilename(for: preset) let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) do { try data.write(to: tempURL) LoggingService.shared.info("Exported preset '\(preset.name)' to \(tempURL.path)") return tempURL } catch { self.error = "Failed to write export file: \(error.localizedDescription)" LoggingService.shared.error("Failed to write export file: \(error.localizedDescription)") return nil } } /// Imports a preset from a file URL. /// - Parameter url: URL of the JSON file to import. /// - Returns: The imported preset name on success. /// - Throws: `LayoutPresetImportError` if import fails. @discardableResult func importPreset(from url: URL) async throws -> String { // Read file data with security-scoped resource access let data: Data let didStartAccessing = url.startAccessingSecurityScopedResource() defer { if didStartAccessing { url.stopAccessingSecurityScopedResource() } } do { data = try Data(contentsOf: url) } catch { LoggingService.shared.error("Failed to read import file: \(error.localizedDescription)") throw LayoutPresetImportError.invalidData } // Parse and validate the preset let importedPreset = try PlayerControlsPresetExportImport.importFromJSON(data) // Save the preset do { try await layoutService.savePreset(importedPreset) await refreshPresets() LoggingService.shared.info("Imported preset: \(importedPreset.name)") return importedPreset.name } catch { self.error = error.localizedDescription LoggingService.shared.error("Failed to save imported preset: \(error.localizedDescription)") throw LayoutPresetImportError.invalidData } } }