Files
yattee/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift
2026-02-08 18:33:56 +01:00

994 lines
36 KiB
Swift

//
// 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<Void, Never>?
// 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
}
}
}