Files
yattee/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift
Arkadiusz Fal 0bfbd47882 Replace per-page corner regions with one "Below Video" picker
The corner layout's below-video host is the portrait info panel, not a
page host: it can render only Info, plus Comments as the panel's pill on
iOS. The page editor still offered "Below Video" for every page, so
assigning Related there was accepted, stored, listed as such, and then
silently rendered in the side column.

- The Layout section gains a "Below Video" picker in corner mode:
  Nothing / Info / Info and Comments (macOS offers the first two). It
  writes the info/comments regions; the per-page Location section and
  its strings are gone.
- PanelSettings.normalizedPages clamps regions to what the host can
  render (only info/comments below, comments only together with info),
  so older presets self-correct. effectiveRegion(for:) resolves the
  platform-specific placement and is used by both the pages list labels
  and CornerPlayerLayout, replacing its own region checks.
- The layout schematic drops the below-video block for "Nothing"; the
  corner-mode footer explains the constraint.
- Tests cover the picker round-trip and the clamping.
2026-09-27 20:05:20 +02:00

1122 lines
41 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
}
/// 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: - Details Panel Settings
/// Current details panel settings from active preset, or default.
var panelSettings: PanelSettings {
activePreset?.layout.effectivePanelSettings ?? .default
}
/// Ordered pages list for the settings UI (includes disabled pages).
var panelPages: [PanelPageConfiguration] {
panelSettings.normalizedPages
}
/// Number of enabled panel pages.
var enabledPanelPageCount: Int {
panelSettings.enabledPages().count
}
/// Current default panel visibility for an orientation.
func panelDefaultVisibility(for orientation: PanelOrientation) -> PanelDefaultVisibility {
panelSettings.effectiveDefaultVisibility(for: orientation)
}
/// Synchronously updates the default panel visibility for an orientation.
func syncPanelDefaultVisibility(_ visibility: PanelDefaultVisibility, for orientation: PanelOrientation) {
updatePanelSettingsSync { settings in
settings.setDefaultVisibility(visibility, for: orientation)
}
}
/// Current panel layout mode (iPad/macOS).
var panelLayoutMode: PanelLayoutMode {
panelSettings.effectiveLayoutMode
}
/// Synchronously updates the panel layout mode.
func syncPanelLayoutMode(_ mode: PanelLayoutMode) {
updatePanelSettingsSync { settings in
settings.layoutMode = mode
}
}
/// Current panel side in the standard wide layout (iPad/macOS).
var panelWideLayoutSide: FloatingPanelSide {
panelSettings.effectiveWideLayoutSide
}
/// Synchronously updates the panel side in the standard wide layout.
func syncPanelWideLayoutSide(_ side: FloatingPanelSide) {
updatePanelSettingsSync { settings in
settings.wideLayoutSide = side
}
}
/// What the corner layout shows below the video (stored value).
var panelBelowVideoContent: PanelBelowVideoContent {
panelSettings.belowVideoContent
}
/// Synchronously updates what the corner layout shows below the video.
func syncPanelBelowVideoContent(_ content: PanelBelowVideoContent) {
updatePanelSettingsSync { settings in
settings.belowVideoContent = content
}
}
/// Enables or disables a panel page. The last enabled page cannot be
/// disabled — the panel always needs at least one page.
func setPanelPageEnabled(_ pageType: PanelPageType, isEnabled: Bool) {
updatePanelSettingsSync { settings in
settings.setEnabled(isEnabled, for: pageType)
}
}
/// Moves pages within the panel page order.
func movePanelPages(fromOffsets source: IndexSet, toOffset destination: Int) {
updatePanelSettingsSync { settings in
settings.movePages(fromOffsets: source, toOffset: destination)
}
}
/// Reorders the pages a settings list displays; offsets are relative to
/// `visible` (see `PanelSettings.moveVisiblePages`).
func movePanelPages(visible: [PanelPageType], fromOffsets source: IndexSet, toOffset destination: Int) {
updatePanelSettingsSync { settings in
settings.moveVisiblePages(visible, fromOffsets: source, toOffset: destination)
}
}
/// Synchronously updates the info page element toggles.
func updatePanelInfoSettingsSync(_ mutation: (inout InfoPageSettings) -> Void) {
updatePanelSettingsSync { settings in
var info = settings.configuration(for: .info)?.infoSettings ?? InfoPageSettings()
mutation(&info)
settings.updateSettings(.info(info), for: .info)
}
}
/// Synchronously updates the comments page element toggles.
func updatePanelCommentsSettingsSync(_ mutation: (inout CommentsPageSettings) -> Void) {
updatePanelSettingsSync { settings in
var comments = settings.configuration(for: .comments)?.commentsSettings ?? CommentsPageSettings()
mutation(&comments)
settings.updateSettings(.comments(comments), for: .comments)
}
}
/// Synchronously updates the related page element toggles.
func updatePanelRelatedSettingsSync(_ mutation: (inout RelatedPageSettings) -> Void) {
updatePanelSettingsSync { settings in
var related = settings.configuration(for: .related)?.relatedSettings ?? RelatedPageSettings()
mutation(&related)
settings.updateSettings(.related(related), for: .related)
}
}
/// Synchronously updates the chapters page element toggles.
func updatePanelChaptersSettingsSync(_ mutation: (inout ChaptersPageSettings) -> Void) {
updatePanelSettingsSync { settings in
var chapters = settings.configuration(for: .chapters)?.chaptersSettings ?? ChaptersPageSettings()
mutation(&chapters)
settings.updateSettings(.chapters(chapters), for: .chapters)
}
}
/// Synchronously updates the queue page element toggles.
func updatePanelQueueSettingsSync(_ mutation: (inout QueuePageSettings) -> Void) {
updatePanelSettingsSync { settings in
var queue = settings.configuration(for: .queue)?.queueSettings ?? QueuePageSettings()
mutation(&queue)
settings.updateSettings(.queue(queue), for: .queue)
}
}
/// Whether the page bar shows labels next to icons.
var panelShowPageBarLabels: Bool {
panelSettings.effectiveShowPageBarLabels
}
/// Synchronously updates the page bar labels setting.
func syncPanelShowPageBarLabels(_ showLabels: Bool) {
updatePanelSettingsSync { settings in
settings.showPageBarLabels = showLabels
}
}
/// Synchronously updates details panel settings with immediate UI feedback.
/// Saves are debounced to avoid rapid disk writes.
/// - Parameter mutation: A closure that mutates the panel settings.
func updatePanelSettingsSync(_ mutation: (inout PanelSettings) -> 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.effectivePanelSettings
mutation(&settings)
layout.panelSettings = 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
}
}
}