mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 17:59:45 +00:00
Yattee v2 rewrite
This commit is contained in:
@@ -0,0 +1,993 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user