Files
yattee/Yattee/Core/SettingsManager.swift
2026-02-08 18:33:56 +01:00

503 lines
17 KiB
Swift

//
// SettingsManager.swift
// Yattee
//
// Manages user settings with iCloud sync via NSUbiquitousKeyValueStore.
//
import Foundation
import SwiftUI
#if os(iOS)
import CoreHaptics
import UIKit
#endif
/// Manages application settings with platform-specific keys and iCloud sync.
@MainActor
@Observable
final class SettingsManager {
// MARK: - Storage
let ubiquitousStore = NSUbiquitousKeyValueStore.default
let localDefaults = UserDefaults.standard
// MARK: - Backing Storage for @Observable
// These stored properties trigger observation when modified.
// Internal access for extension use.
// Theme
var _theme: AppTheme?
var _accentColor: AccentColor?
var _showWatchedCheckmark: Bool?
// Playback
var _preferredQuality: VideoQuality?
var _cellularQuality: VideoQuality?
var _backgroundPlaybackEnabled: Bool?
var _dashEnabled: Bool?
var _preferredAudioLanguage: String?
var _preferredSubtitlesLanguage: String?
var _playerVolume: Float?
var _resumeAction: ResumeAction?
// SponsorBlock
var _sponsorBlockEnabled: Bool?
var _sponsorBlockCategories: Set<SponsorBlockCategory>?
var _sponsorBlockAPIURL: String?
// Return YouTube Dislike & DeArrow
var _returnYouTubeDislikeEnabled: Bool?
var _deArrowEnabled: Bool?
var _deArrowReplaceTitles: Bool?
var _deArrowReplaceThumbnails: Bool?
var _deArrowAPIURL: String?
var _deArrowThumbnailAPIURL: String?
// User Agent
var _customUserAgent: String?
var _randomizeUserAgentPerRequest: Bool?
// Feed
var _feedCacheValidityMinutes: Int?
// Player
var _keepPlayerPinnedEnabled: Bool?
#if os(iOS)
var _inAppOrientationLock: Bool?
var _rotateToMatchAspectRatio: Bool?
var _preferPortraitBrowsing: Bool?
#endif
#if os(macOS)
var _macPlayerMode: MacPlayerMode?
var _playerSheetAutoResize: Bool?
#endif
// Mini Player Minimize Behavior is kept as it's not part of the preset
// Mini Player Minimize Behavior (iOS 26+)
#if os(iOS)
var _miniPlayerMinimizeBehavior: (any RawRepresentable)?
#endif
// Haptics (iOS)
#if os(iOS)
var _hapticFeedbackEnabled: Bool?
var _hapticFeedbackIntensity: HapticFeedbackIntensity?
#endif
// iCloud sync
var _iCloudSyncEnabled: Bool?
var _lastSyncTime: Date?
var _syncInstances: Bool?
var _syncSubscriptions: Bool?
var _syncBookmarks: Bool?
var _syncPlaybackHistory: Bool?
var _syncPlaylists: Bool?
var _syncSettings: Bool?
var _syncMediaSources: Bool?
var _syncSearchHistory: Bool?
// Search history
var _searchHistoryLimit: Int?
// Home settings
var _homeShortcutOrder: [HomeShortcutItem]?
var _homeShortcutVisibility: [HomeShortcutItem: Bool]?
var _homeShortcutLayout: HomeShortcutLayout?
var _homeSectionOrder: [HomeSectionItem]?
var _homeSectionVisibility: [HomeSectionItem: Bool]?
var _homeSectionItemsLimit: Int?
// Tab bar settings (compact size class only - iOS)
var _tabBarItemOrder: [TabBarItem]?
var _tabBarItemVisibility: [TabBarItem: Bool]?
// Sidebar settings
var _sidebarMainItemOrder: [SidebarMainItem]?
var _sidebarMainItemVisibility: [SidebarMainItem: Bool]?
var _sidebarStartupTab: SidebarMainItem?
// Tab bar startup
var _tabBarStartupTab: SidebarMainItem?
var _sidebarSourcesEnabled: Bool?
var _sidebarSourceSort: SidebarSourceSort?
var _sidebarSourcesLimitEnabled: Bool?
var _sidebarMaxSources: Int?
var _sidebarChannelsEnabled: Bool?
var _sidebarMaxChannels: Int?
var _sidebarChannelSort: SidebarChannelSort?
var _sidebarChannelsLimitEnabled: Bool?
var _sidebarPlaylistsEnabled: Bool?
var _sidebarMaxPlaylists: Int?
var _sidebarPlaylistSort: SidebarPlaylistSort?
var _sidebarPlaylistsLimitEnabled: Bool?
// iCloud startup sync protection
/// When true, suppresses iCloud writes from set() methods to prevent
/// stale local values from overwriting newer iCloud data during app startup.
var isInitialSyncPending = false
// Advanced settings
var _showAdvancedStreamDetails: Bool?
var _showPlayerAreaDebug: Bool?
var _verboseMPVLogging: Bool?
var _verboseRemoteControlLogging: Bool?
var _mpvBufferSeconds: Double?
var _mpvUseEDLStreams: Bool?
var _zoomTransitionsEnabled: Bool?
// Details panel settings
var _floatingDetailsPanelSide: FloatingPanelSide?
var _floatingDetailsPanelWidth: CGFloat?
var _landscapeDetailsPanelVisible: Bool?
var _landscapeDetailsPanelPinned: Bool?
// Notification settings
var _backgroundNotificationsEnabled: Bool?
var _defaultNotificationsForNewChannels: Bool?
var _lastBackgroundCheck: Date?
var _clipboardURLDetectionEnabled: Bool?
var _incognitoModeEnabled: Bool?
var _historyRetentionDays: Int?
var _saveWatchHistory: Bool?
var _saveRecentSearches: Bool?
var _saveRecentChannels: Bool?
var _saveRecentPlaylists: Bool?
// Subscription account settings
var _subscriptionAccount: SubscriptionAccount?
// Queue settings
var _queueEnabled: Bool?
var _queueAutoPlayNext: Bool?
var _queueAutoPlayCountdown: Int?
// Handoff settings
var _handoffEnabled: Bool?
// Remote Control settings
var _remoteControlCustomDeviceName: String?
var _remoteControlHideWhenBackgrounded: Bool?
// Link action settings
var _defaultLinkAction: DefaultLinkAction?
// Video tap actions (iOS/macOS only)
#if !os(tvOS)
var _thumbnailTapAction: VideoTapAction?
var _textAreaTapAction: VideoTapAction?
#endif
// Player Controls settings (controlsButtonSize moved to preset)
// Appearance settings
var _listStyle: VideoListStyle?
#if os(iOS)
var _appIcon: AppIcon?
#endif
// Video Swipe Actions
#if !os(tvOS)
var _videoSwipeActionOrder: [VideoSwipeAction]?
var _videoSwipeActionVisibility: [VideoSwipeAction: Bool]?
#endif
// MARK: - Initialization
/// Logs KVStore change reason for debugging iCloud account switches.
private func logKVStoreChangeReason(_ reason: Int) {
let reasonDescription: String
switch reason {
case NSUbiquitousKeyValueStoreServerChange:
reasonDescription = "ServerChange"
case NSUbiquitousKeyValueStoreInitialSyncChange:
reasonDescription = "InitialSyncChange"
case NSUbiquitousKeyValueStoreQuotaViolationChange:
reasonDescription = "QuotaViolationChange"
case NSUbiquitousKeyValueStoreAccountChange:
reasonDescription = "AccountChange (iCloud account switched!)"
LoggingService.shared.logCloudKit("SettingsManager: iCloud account changed - settings will sync with new account")
default:
reasonDescription = "Unknown(\(reason))"
}
LoggingService.shared.logCloudKit("SettingsManager KVStore change: \(reasonDescription)")
}
init() {
// Listen for external changes from iCloud
NotificationCenter.default.addObserver(
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: ubiquitousStore,
queue: .main
) { [weak self] notification in
let changeReason = notification.userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
// Only process iCloud changes if sync is enabled
Task { @MainActor [weak self] in
// Log change reason for debugging account switches
if let changeReason {
self?.logKVStoreChangeReason(changeReason)
}
if let changedKeys {
LoggingService.shared.logCloudKit("SettingsManager KVStore changed keys: \(changedKeys)")
}
guard let self, self.iCloudSyncEnabled else { return }
let keySet = changedKeys.map { Set($0) }
self.refreshFromiCloud(changedKeys: keySet)
self.updateLastSyncTime()
}
}
// Initial sync from iCloud to local storage (async to avoid blocking app launch)
// This ensures local defaults have the latest iCloud values before any reads.
// While sync is pending, suppress iCloud writes from set() to prevent stale
// local values from overwriting newer iCloud data.
if localDefaults.bool(forKey: "iCloudSyncEnabled") {
isInitialSyncPending = true
LoggingService.shared.logCloudKit("SettingsManager.init: isInitialSyncPending = true, suppressing iCloud writes until refresh completes")
Task { @MainActor [weak self] in
defer {
self?.isInitialSyncPending = false
LoggingService.shared.logCloudKit("SettingsManager.init: isInitialSyncPending = false, iCloud writes re-enabled")
}
self?.ubiquitousStore.synchronize()
self?.refreshFromiCloud()
}
}
}
// MARK: - Storage Helpers
// Internal access for extension use.
func platformKey(_ key: SettingsKey) -> String {
let baseKey = key.rawValue
#if os(iOS)
return key.isPlatformSpecific ? "iOS.\(baseKey)" : baseKey
#elseif os(macOS)
return key.isPlatformSpecific ? "macOS.\(baseKey)" : baseKey
#elseif os(tvOS)
return key.isPlatformSpecific ? "tvOS.\(baseKey)" : baseKey
#endif
}
/// Returns the companion key used to store the last-modified timestamp for a protected setting.
func modifiedAtKey(for key: SettingsKey) -> String {
"\(platformKey(key))_modifiedAt"
}
func string(for key: SettingsKey) -> String? {
// Always read from local storage to avoid blocking on iCloud XPC calls
return localDefaults.string(forKey: platformKey(key))
}
func bool(for key: SettingsKey, default defaultValue: Bool = false) -> Bool {
// Always read from local storage to avoid blocking on iCloud XPC calls
let pKey = platformKey(key)
if localDefaults.object(forKey: pKey) != nil {
return localDefaults.bool(forKey: pKey)
}
return defaultValue
}
func data(for key: SettingsKey) -> Data? {
// Always read from local storage to avoid blocking on iCloud XPC calls
return localDefaults.data(forKey: platformKey(key))
}
func integer(for key: SettingsKey, default defaultValue: Int) -> Int {
// Always read from local storage to avoid blocking on iCloud XPC calls
let pKey = platformKey(key)
if localDefaults.object(forKey: pKey) != nil {
return localDefaults.integer(forKey: pKey)
}
return defaultValue
}
func double(for key: SettingsKey) -> Double {
// Always read from local storage to avoid blocking on iCloud XPC calls
return localDefaults.double(forKey: platformKey(key))
}
func set(_ value: String, for key: SettingsKey) {
let pKey = platformKey(key)
localDefaults.set(value, forKey: pKey)
// Only write to iCloud if sync is enabled, settings sync is enabled, key is not local-only,
// and initial sync has completed (to prevent stale values overwriting iCloud during startup)
if iCloudSyncEnabled && syncSettings && !key.isLocalOnly && !isInitialSyncPending {
ubiquitousStore.set(value, forKey: pKey)
} else if isInitialSyncPending && !key.isLocalOnly {
LoggingService.shared.logCloudKit("set(String): suppressed iCloud write for \(pKey) (initial sync pending)")
}
}
func set(_ value: Bool, for key: SettingsKey) {
let pKey = platformKey(key)
localDefaults.set(value, forKey: pKey)
if iCloudSyncEnabled && syncSettings && !key.isLocalOnly && !isInitialSyncPending {
ubiquitousStore.set(value, forKey: pKey)
} else if isInitialSyncPending && !key.isLocalOnly {
LoggingService.shared.logCloudKit("set(Bool): suppressed iCloud write for \(pKey) (initial sync pending)")
}
}
func set(_ value: Data, for key: SettingsKey) {
let pKey = platformKey(key)
localDefaults.set(value, forKey: pKey)
if iCloudSyncEnabled && syncSettings && !key.isLocalOnly && !isInitialSyncPending {
ubiquitousStore.set(value, forKey: pKey)
} else if isInitialSyncPending && !key.isLocalOnly {
LoggingService.shared.logCloudKit("set(Data): suppressed iCloud write for \(pKey) (initial sync pending)")
}
}
func set(_ value: Int, for key: SettingsKey) {
let pKey = platformKey(key)
localDefaults.set(value, forKey: pKey)
if iCloudSyncEnabled && syncSettings && !key.isLocalOnly && !isInitialSyncPending {
ubiquitousStore.set(value, forKey: pKey)
} else if isInitialSyncPending && !key.isLocalOnly {
LoggingService.shared.logCloudKit("set(Int): suppressed iCloud write for \(pKey) (initial sync pending)")
}
}
func set(_ value: Double, for key: SettingsKey) {
let pKey = platformKey(key)
localDefaults.set(value, forKey: pKey)
if iCloudSyncEnabled && syncSettings && !key.isLocalOnly && !isInitialSyncPending {
ubiquitousStore.set(value, forKey: pKey)
} else if isInitialSyncPending && !key.isLocalOnly {
LoggingService.shared.logCloudKit("set(Double): suppressed iCloud write for \(pKey) (initial sync pending)")
}
}
// MARK: - Cache Management
/// Clears cached values to force re-read from storage
func clearCache() {
_theme = nil
_accentColor = nil
_showWatchedCheckmark = nil
_preferredQuality = nil
_cellularQuality = nil
_backgroundPlaybackEnabled = nil
_dashEnabled = nil
_preferredAudioLanguage = nil
_preferredSubtitlesLanguage = nil
_playerVolume = nil
_resumeAction = nil
_sponsorBlockEnabled = nil
_sponsorBlockCategories = nil
_sponsorBlockAPIURL = nil
_returnYouTubeDislikeEnabled = nil
_deArrowEnabled = nil
_deArrowReplaceTitles = nil
_deArrowReplaceThumbnails = nil
_deArrowAPIURL = nil
_deArrowThumbnailAPIURL = nil
_customUserAgent = nil
_randomizeUserAgentPerRequest = nil
_feedCacheValidityMinutes = nil
_keepPlayerPinnedEnabled = nil
#if os(iOS)
_hapticFeedbackEnabled = nil
_hapticFeedbackIntensity = nil
_inAppOrientationLock = nil
_rotateToMatchAspectRatio = nil
_preferPortraitBrowsing = nil
#endif
_iCloudSyncEnabled = nil
_lastSyncTime = nil
_syncInstances = nil
_syncSubscriptions = nil
_syncBookmarks = nil
_syncPlaybackHistory = nil
_syncPlaylists = nil
_syncSettings = nil
_syncMediaSources = nil
_syncSearchHistory = nil
_searchHistoryLimit = nil
#if os(macOS)
_macPlayerMode = nil
_playerSheetAutoResize = nil
#endif
// miniPlayerShowVideo and miniPlayerVideoTapAction moved to preset
#if os(iOS)
_miniPlayerMinimizeBehavior = nil
#endif
_homeShortcutOrder = nil
_homeShortcutVisibility = nil
_homeShortcutLayout = nil
_homeSectionOrder = nil
_homeSectionVisibility = nil
_homeSectionItemsLimit = nil
_tabBarItemOrder = nil
_tabBarItemVisibility = nil
_sidebarMainItemOrder = nil
_sidebarMainItemVisibility = nil
_sidebarStartupTab = nil
_tabBarStartupTab = nil
_sidebarSourcesEnabled = nil
_sidebarSourceSort = nil
_sidebarSourcesLimitEnabled = nil
_sidebarMaxSources = nil
_sidebarChannelsEnabled = nil
_sidebarMaxChannels = nil
_sidebarChannelSort = nil
_sidebarChannelsLimitEnabled = nil
_sidebarPlaylistsEnabled = nil
_sidebarMaxPlaylists = nil
_sidebarPlaylistSort = nil
_sidebarPlaylistsLimitEnabled = nil
_showAdvancedStreamDetails = nil
_showPlayerAreaDebug = nil
_verboseMPVLogging = nil
_verboseRemoteControlLogging = nil
_mpvBufferSeconds = nil
_mpvUseEDLStreams = nil
_zoomTransitionsEnabled = nil
_floatingDetailsPanelSide = nil
_floatingDetailsPanelWidth = nil
_landscapeDetailsPanelVisible = nil
_landscapeDetailsPanelPinned = nil
_backgroundNotificationsEnabled = nil
_defaultNotificationsForNewChannels = nil
_lastBackgroundCheck = nil
_clipboardURLDetectionEnabled = nil
_incognitoModeEnabled = nil
_historyRetentionDays = nil
_saveWatchHistory = nil
_saveRecentSearches = nil
_saveRecentChannels = nil
_saveRecentPlaylists = nil
_subscriptionAccount = nil
_queueEnabled = nil
_queueAutoPlayNext = nil
_queueAutoPlayCountdown = nil
_handoffEnabled = nil
_defaultLinkAction = nil
_remoteControlCustomDeviceName = nil
_remoteControlHideWhenBackgrounded = nil
#if !os(tvOS)
_thumbnailTapAction = nil
_textAreaTapAction = nil
#endif
_listStyle = nil
#if os(iOS)
_appIcon = nil
#endif
#if !os(tvOS)
_videoSwipeActionOrder = nil
_videoSwipeActionVisibility = nil
#endif
}
}