mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
502
Yattee/Core/SettingsManager.swift
Normal file
502
Yattee/Core/SettingsManager.swift
Normal file
@@ -0,0 +1,502 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user