Files
yattee/Yattee/Core/SettingsManager.swift
Arkadiusz Fal 8f00fe012f Add tvOS setting to close video with Menu button
When enabled, the Siri remote Menu button stops playback and clears the
queue instead of only collapsing the player, and the explicit top-bar
close (X) button is hidden.
2026-04-18 20:38:02 +02:00

527 lines
18 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?
var _tvOSMenuButtonClosesVideo: Bool?
// 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?
var _homeSectionLayout: HomeSectionLayout?
// Top Shelf (tvOS)
var _topShelfSections: [TopShelfSection]?
// 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 _showTVDebugButton: 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
// Video tap action (tvOS only)
#if os(tvOS)
var _tvOSVideoTapAction: 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()
}
}
// One-shot migration: move legacy unprefixed values for keys that became
// platform-specific into their new iOS./macOS./tvOS. slots. Must run before
// the initial iCloud refresh so it can seed the prefixed iCloud slot from the
// current local value before any remote data is read.
migrateLayoutKeysToPlatformPrefixed()
// 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
_tvOSMenuButtonClosesVideo = 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
_homeSectionLayout = nil
_topShelfSections = 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
_showTVDebugButton = 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
#if os(tvOS)
_tvOSVideoTapAction = nil
#endif
_listStyle = nil
#if os(iOS)
_appIcon = nil
#endif
#if !os(tvOS)
_videoSwipeActionOrder = nil
_videoSwipeActionVisibility = nil
#endif
}
}