Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent d94a50f8c3
commit 100df744d9
1043 changed files with 163886 additions and 68471 deletions

View File

@@ -0,0 +1,428 @@
//
// AppEnvironment.swift
// Yattee
//
// Dependency injection container for the application.
//
import Foundation
import SwiftUI
import SwiftData
/// Main dependency injection container that holds all app services.
/// Passed through the SwiftUI environment to provide dependencies to views.
@MainActor
@Observable
final class AppEnvironment {
// MARK: - Services
let settingsManager: SettingsManager
let instancesManager: InstancesManager
let contentService: ContentService
let instanceDetector: InstanceDetector
let dataManager: DataManager
let subscriptionService: SubscriptionService
let navigationCoordinator: NavigationCoordinator
let downloadManager: DownloadManager
let downloadSettings: DownloadSettings
let playerService: PlayerService
let queueManager: QueueManager
let cloudKitSync: CloudKitSyncEngine
let deArrowBrandingProvider: DeArrowBrandingProvider
let notificationManager: NotificationManager
let backgroundRefreshManager: BackgroundRefreshManager
let mediaSourcesManager: MediaSourcesManager
let webDAVClient: WebDAVClient
let webDAVClientFactory: WebDAVClientFactory
let smbClient: SMBClient
let localFileClient: LocalFileClient
let urlSessionFactory: URLSessionFactory
let httpClientFactory: HTTPClientFactory
let localNetworkService: LocalNetworkService
let remoteControlCoordinator: RemoteControlCoordinator
let networkShareDiscoveryService: NetworkShareDiscoveryService
let connectivityMonitor: ConnectivityMonitor
let httpClient: HTTPClient
let toastManager: ToastManager
let handoffManager: HandoffManager
let invidiousCredentialsManager: InvidiousCredentialsManager
let pipedCredentialsManager: PipedCredentialsManager
let yatteeServerCredentialsManager: YatteeServerCredentialsManager
let homeInstanceCache: HomeInstanceCache
let invidiousAPI: InvidiousAPI
let pipedAPI: PipedAPI
let subscriptionAccountValidator: SubscriptionAccountValidator
let playerControlsLayoutService: PlayerControlsLayoutService
let legacyMigrationService: LegacyDataMigrationService
let sourcesSettings: SourcesSettings
// MARK: - Initialization
init(
httpClient: HTTPClient? = nil,
settingsManager: SettingsManager? = nil,
instancesManager: InstancesManager? = nil,
dataManager: DataManager? = nil,
navigationCoordinator: NavigationCoordinator? = nil,
downloadManager: DownloadManager? = nil
) {
let client = httpClient ?? HTTPClient()
self.httpClient = client
let settings = settingsManager ?? SettingsManager()
self.settingsManager = settings
// Configure HTTP client with custom User-Agent
Task {
await client.setUserAgent(settings.customUserAgent)
await client.setRandomizeUserAgentPerRequest(settings.randomizeUserAgentPerRequest)
}
let instances = instancesManager ?? InstancesManager(settingsManager: settings)
instances.setSettingsManager(settings)
self.instancesManager = instances
// Initialize Yattee Server Credentials Manager early (needed for ContentService)
let yatteeServerCreds = YatteeServerCredentialsManager()
yatteeServerCreds.settingsManager = settings
self.yatteeServerCredentialsManager = yatteeServerCreds
let contentSvc = ContentService(httpClient: client, yatteeServerCredentialsManager: yatteeServerCreds)
self.contentService = contentSvc
self.instanceDetector = InstanceDetector(httpClient: client)
self.navigationCoordinator = navigationCoordinator ?? NavigationCoordinator()
self.downloadManager = downloadManager ?? DownloadManager()
self.downloadSettings = DownloadSettings()
// Initialize DataManager, falling back to in-memory for failures
let dm: DataManager
if let manager = dataManager {
dm = manager
} else {
do {
dm = try DataManager(iCloudSyncEnabled: settings.iCloudSyncEnabled)
} catch {
// Fall back to in-memory storage if persistent storage fails
dm = try! DataManager(inMemory: true)
}
}
self.dataManager = dm
// Initialize Invidious Credentials Manager (needed for SubscriptionService)
let invidiousCreds = InvidiousCredentialsManager()
invidiousCreds.settingsManager = settings
self.invidiousCredentialsManager = invidiousCreds
// Initialize Piped Credentials Manager
let pipedCreds = PipedCredentialsManager()
pipedCreds.settingsManager = settings
self.pipedCredentialsManager = pipedCreds
// Initialize Invidious API (used by SubscriptionService and SubscriptionFeedCache)
let invidiousAPI = InvidiousAPI(httpClient: client)
self.invidiousAPI = invidiousAPI
// Initialize Piped API (used by SubscriptionService and SubscriptionFeedCache)
let pipedAPI = PipedAPI(httpClient: client)
self.pipedAPI = pipedAPI
// Initialize SubscriptionService with all required dependencies
self.subscriptionService = SubscriptionService(
dataManager: dm,
settingsManager: settings,
instancesManager: instances,
invidiousCredentialsManager: invidiousCreds,
pipedCredentialsManager: pipedCreds,
invidiousAPI: invidiousAPI,
pipedAPI: pipedAPI
)
// Initialize CloudKit Sync Engine
let cloudKit = CloudKitSyncEngine(
dataManager: dm,
settingsManager: settings,
instancesManager: instances
)
self.cloudKitSync = cloudKit
dm.cloudKitSync = cloudKit
// Initialize DeArrow with low-priority networking
let lowPrioritySession = URLSessionFactory.shared.lowPrioritySession()
let deArrowHTTPClient = HTTPClient(session: lowPrioritySession)
let deArrowAPI = DeArrowAPI(httpClient: deArrowHTTPClient, urlSession: lowPrioritySession)
let deArrowProvider = DeArrowBrandingProvider(api: deArrowAPI)
deArrowProvider.setSettingsManager(settings)
self.deArrowBrandingProvider = deArrowProvider
// Initialize PlayerService
let downloads = self.downloadManager
let player = PlayerService(
httpClient: client,
contentService: contentSvc,
dataManager: dm
)
player.setInstancesManager(instances)
player.setSettingsManager(settings)
player.setDownloadManager(downloads)
player.setNavigationCoordinator(self.navigationCoordinator)
player.setDeArrowBrandingProvider(deArrowProvider)
self.playerService = player
// Initialize QueueManager
let queue = QueueManager(contentService: contentSvc)
queue.setPlayerState(player.state)
queue.setPlayerService(player)
queue.setSettingsManager(settings)
queue.setInstancesManager(instances)
queue.setDownloadManager(downloads)
player.setQueueManager(queue)
self.queueManager = queue
// Initialize Notification & Background Refresh managers
let notifManager = NotificationManager()
#if !os(tvOS)
notifManager.registerNotificationCategories()
#endif
self.notificationManager = notifManager
let bgRefreshManager = BackgroundRefreshManager(notificationManager: notifManager)
self.backgroundRefreshManager = bgRefreshManager
// Initialize URL Session and Client Factories
let sessionFactory = URLSessionFactory.shared
self.urlSessionFactory = sessionFactory
self.httpClientFactory = HTTPClientFactory(sessionFactory: sessionFactory)
self.webDAVClientFactory = WebDAVClientFactory(sessionFactory: sessionFactory)
// Initialize Media Sources components
let mediaSources = MediaSourcesManager(settingsManager: settings)
self.mediaSourcesManager = mediaSources
mediaSources.setDataManager(dm)
// Initialize media clients
self.webDAVClient = WebDAVClient()
self.smbClient = SMBClient()
self.localFileClient = LocalFileClient()
// Wire up media services to player
player.setMediaSourcesManager(mediaSources)
self.navigationCoordinator.setMediaSourcesManager(mediaSources)
player.setSMBClient(self.smbClient)
player.setWebDAVClient(self.webDAVClient)
player.setLocalFileClient(self.localFileClient)
// Wire up SMB client to check if SMB playback is active
// This prevents crashes from concurrent libsmbclient usage
let smbClientRef = self.smbClient
Task {
await smbClientRef.setPlaybackActiveCallback { [weak player] in
player?.state.isSMBPlaybackActive ?? false
}
}
// Initialize Remote Control components
let networkService = LocalNetworkService()
self.localNetworkService = networkService
self.networkShareDiscoveryService = NetworkShareDiscoveryService()
let remoteControl = RemoteControlCoordinator(networkService: networkService)
remoteControl.setPlayerService(player)
remoteControl.setContentService(contentSvc)
remoteControl.setInstancesManager(instances)
remoteControl.setNavigationCoordinator(self.navigationCoordinator)
remoteControl.setMediaSourcesManager(mediaSources)
remoteControl.setSettingsManager(settings)
self.remoteControlCoordinator = remoteControl
// Restore remote control enabled state (after all services are set up)
remoteControl.restoreEnabledState()
// Initialize Connectivity Monitor for network-aware quality selection
let connectivity = ConnectivityMonitor()
self.connectivityMonitor = connectivity
player.setConnectivityMonitor(connectivity)
// Initialize Toast Manager
let toast = ToastManager()
self.toastManager = toast
toast.setNavigationCoordinator(self.navigationCoordinator)
remoteControl.setToastManager(toast)
self.downloadManager.setToastManager(toast)
self.downloadManager.setDownloadSettings(self.downloadSettings)
// Initialize Handoff Manager
let handoff = HandoffManager()
handoff.setPlayerState(player.state)
handoff.setSettingsManager(settings)
self.handoffManager = handoff
self.navigationCoordinator.setHandoffManager(handoff)
player.setHandoffManager(handoff)
// Initialize Home Instance Cache
self.homeInstanceCache = .shared
// Initialize Subscription Account Validator
self.subscriptionAccountValidator = SubscriptionAccountValidator(
settingsManager: settings,
instancesManager: instances,
invidiousCredentialsManager: invidiousCreds,
pipedCredentialsManager: pipedCreds,
toastManager: toast,
feedCache: .shared
)
// Initialize Player Controls Layout Service
let layoutService = PlayerControlsLayoutService()
self.playerControlsLayoutService = layoutService
// Initialize Legacy Migration Service
self.legacyMigrationService = LegacyDataMigrationService(
instancesManager: instances,
httpClient: client
)
// Initialize Sources Settings
self.sourcesSettings = SourcesSettings()
// Wire up CloudKit sync to player controls layout service (bidirectional)
cloudKit.playerControlsLayoutService = layoutService
Task {
await layoutService.setCloudKitSync(cloudKit)
}
// Wire up player controls layout service to player service (for preset-based settings)
player.setPlayerControlsLayoutService(layoutService)
// Set up circular dependencies after all properties are initialized
bgRefreshManager.setAppEnvironment(self)
// Log device capabilities on startup for debugging
HardwareCapabilities.shared.logCapabilities()
// Clean up any leftover subtitle temp files from previous sessions
cleanupAllTempSubtitles()
// Run orphan diagnostics on startup to help debug storage issues
#if !os(tvOS)
Task {
self.downloadManager.logOrphanDiagnostics()
}
#endif
}
/// Cleans up all temporary subtitle files from previous sessions.
/// Call this on app launch to ensure temp directory doesn't accumulate old files.
private func cleanupAllTempSubtitles() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("yattee-subtitles", isDirectory: true)
do {
if FileManager.default.fileExists(atPath: tempDir.path) {
try FileManager.default.removeItem(at: tempDir)
LoggingService.shared.debug("Cleaned up all temp subtitle files on launch", category: .general)
}
} catch {
// Log but don't fail - this is just cleanup
LoggingService.shared.debug(
"Failed to clean up temp subtitles on launch: \(error.localizedDescription)",
category: .general
)
}
}
// MARK: - Configuration
/// Updates the HTTP client's User-Agent configuration from current settings.
/// Call this after changing User-Agent related settings.
func updateUserAgent() {
let userAgent = settingsManager.customUserAgent
let randomizePerRequest = settingsManager.randomizeUserAgentPerRequest
Task {
await httpClient.setUserAgent(userAgent)
await httpClient.setRandomizeUserAgentPerRequest(randomizePerRequest)
}
}
// MARK: - Notifications
/// Ensures the notification infrastructure is enabled (system permission + master toggle + background refresh).
/// Call this before enabling per-channel notifications.
/// - Returns: `true` if notifications are fully enabled, `false` if the user denied permission.
func ensureNotificationsEnabled() async -> Bool {
if settingsManager.backgroundNotificationsEnabled {
return true
}
let granted = await notificationManager.requestAuthorization()
if granted {
settingsManager.backgroundNotificationsEnabled = true
backgroundRefreshManager.handleNotificationsEnabledChanged(true)
return true
}
return false
}
// MARK: - Credentials Management
/// Returns the appropriate credentials manager for an instance type.
/// - Parameter instance: The instance to get a credentials manager for
/// - Returns: The credentials manager, or nil if the instance type doesn't support authentication
func credentialsManager(for instance: Instance) -> (any InstanceCredentialsManager)? {
switch instance.type {
case .invidious:
return invidiousCredentialsManager
case .piped:
return pipedCredentialsManager
case .yatteeServer:
return yatteeServerCredentialsManager
default:
return nil
}
}
// MARK: - Preview/Testing Support
@MainActor
static var preview: AppEnvironment {
let dataManager = try? DataManager.preview()
return AppEnvironment(dataManager: dataManager)
}
}
// MARK: - Environment Key
private struct AppEnvironmentKey: EnvironmentKey {
static let defaultValue: AppEnvironment? = nil
}
extension EnvironmentValues {
var appEnvironment: AppEnvironment? {
get { self[AppEnvironmentKey.self] }
set { self[AppEnvironmentKey.self] = newValue }
}
}
extension View {
func appEnvironment(_ environment: AppEnvironment) -> some View {
self.environment(\.appEnvironment, environment)
}
}
// MARK: - Video Queue Context Environment
private struct VideoQueueContextKey: EnvironmentKey {
static let defaultValue: VideoQueueContext? = nil
}
extension EnvironmentValues {
var videoQueueContext: VideoQueueContext? {
get { self[VideoQueueContextKey.self] }
set { self[VideoQueueContextKey.self] = newValue }
}
}
extension View {
func videoQueueContext(_ context: VideoQueueContext?) -> some View {
self.environment(\.videoQueueContext, context)
}
}

View File

@@ -0,0 +1,36 @@
import Foundation
/// Centralized app identifiers - single source of truth for all app-wide identifiers.
enum AppIdentifiers {
// MARK: - Base Identifier
static let bundleIdentifier = "stream.yattee.app"
// MARK: - iCloud
static var iCloudContainer: String {
"iCloud.\(bundleIdentifier)"
}
// MARK: - Background Tasks
static var backgroundFeedRefresh: String {
"\(bundleIdentifier).feedRefresh"
}
// MARK: - User Activities (Handoff)
static var handoffActivityType: String {
"\(bundleIdentifier).activity"
}
// MARK: - URL Sessions
static let downloadSession = "stream.yattee.downloads"
// MARK: - Logging
static var logSubsystem: String {
bundleIdentifier
}
}

120
Yattee/Core/FeedCache.swift Normal file
View File

@@ -0,0 +1,120 @@
//
// FeedCache.swift
// Yattee
//
// Persistent on-device feed cache for subscription videos.
// Stores feed data on disk to enable fast loading on app launch.
//
import Foundation
/// Persistent feed cache that stores subscription videos on disk.
/// This is a local-only cache (not synced to iCloud) for fast feed loading.
actor FeedCache {
static let shared = FeedCache()
private let fileManager = FileManager.default
private let cacheDirectory: URL
private let cacheFileName = "subscription_feed.json"
/// In-memory cache of the feed data.
private var cachedData: FeedCacheData?
private init() {
// Use Caches directory - not backed up, not synced
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
cacheDirectory = caches.appendingPathComponent("FeedCache", isDirectory: true)
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
private var cacheFileURL: URL {
cacheDirectory.appendingPathComponent(cacheFileName)
}
// MARK: - Public API
/// Loads the cached feed from disk.
/// Returns nil if no cache exists or if it's corrupted.
func load() async -> FeedCacheData? {
// Return in-memory cache if available
if let cachedData {
return cachedData
}
// Try to load from disk
guard fileManager.fileExists(atPath: cacheFileURL.path) else {
return nil
}
do {
let data = try Data(contentsOf: cacheFileURL)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let cacheData = try decoder.decode(FeedCacheData.self, from: data)
// Store in memory
cachedData = cacheData
return cacheData
} catch {
// Cache is corrupted, remove it
try? fileManager.removeItem(at: cacheFileURL)
return nil
}
}
/// Saves the feed to disk.
func save(videos: [Video], lastUpdated: Date) async {
let cacheData = FeedCacheData(videos: videos, lastUpdated: lastUpdated)
// Update in-memory cache
cachedData = cacheData
// Write to disk
do {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(cacheData)
let sizeMB = Double(data.count) / (1024 * 1024)
try data.write(to: cacheFileURL, options: .atomic)
await MainActor.run {
LoggingService.shared.debug(
"FeedCache.save: Wrote \(videos.count) videos (\(String(format: "%.2f", sizeMB)) MB) to disk, lastUpdated: \(lastUpdated)",
category: .general
)
}
} catch {
await MainActor.run {
LoggingService.shared.error(
"FeedCache.save: Failed to write to disk",
category: .general,
details: error.localizedDescription
)
}
}
}
/// Clears the feed cache from both memory and disk.
func clear() async {
cachedData = nil
try? fileManager.removeItem(at: cacheFileURL)
}
/// Invalidates the cache by clearing the lastUpdated timestamp.
/// The cached videos remain available but will be considered stale.
func invalidate() async {
guard var data = cachedData else { return }
data.lastUpdated = .distantPast
cachedData = data
await save(videos: data.videos, lastUpdated: .distantPast)
}
}
// MARK: - Cache Data Model
/// Data structure for the feed cache.
struct FeedCacheData: Codable {
var videos: [Video]
var lastUpdated: Date
}

View File

@@ -0,0 +1,22 @@
//
// FileCommands.swift
// Yattee
//
// Menu bar commands for file operations.
//
import SwiftUI
#if !os(tvOS)
/// File-related menu bar commands.
struct FileCommands: Commands {
var body: some Commands {
CommandGroup(replacing: .newItem) {
Button(String(localized: "menu.file.openLink")) {
NotificationCenter.default.post(name: .showOpenLinkSheet, object: nil)
}
.keyboardShortcut("o", modifiers: [.command])
}
}
}
#endif

View File

@@ -0,0 +1,161 @@
//
// HardwareCapabilities.swift
// Yattee
//
// Detects hardware video decoding capabilities using VideoToolbox.
//
import Foundation
import VideoToolbox
import CoreMedia
/// Detects and caches hardware video decoding capabilities for the current device.
@MainActor
final class HardwareCapabilities {
static let shared = HardwareCapabilities()
// MARK: - Cached Results
private var _supportsH264Hardware: Bool?
private var _supportsHEVCHardware: Bool?
private var _supportsHEVCAlphaHardware: Bool?
private var _supportsDolbyVisionHEVCHardware: Bool?
private var _supportsVP9Hardware: Bool?
private var _supportsAV1Hardware: Bool?
private var _supportsProResHardware: Bool?
// MARK: - Hardware Support Properties
/// Whether the device supports H.264/AVC hardware decoding.
var supportsH264Hardware: Bool {
if let cached = _supportsH264Hardware { return cached }
let supported = VTIsHardwareDecodeSupported(kCMVideoCodecType_H264)
_supportsH264Hardware = supported
return supported
}
/// Whether the device supports HEVC/H.265 hardware decoding.
var supportsHEVCHardware: Bool {
if let cached = _supportsHEVCHardware { return cached }
let supported = VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC)
_supportsHEVCHardware = supported
return supported
}
/// Whether the device supports HEVC with Alpha hardware decoding.
var supportsHEVCAlphaHardware: Bool {
if let cached = _supportsHEVCAlphaHardware { return cached }
let supported = VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVCWithAlpha)
_supportsHEVCAlphaHardware = supported
return supported
}
/// Whether the device supports Dolby Vision HEVC hardware decoding.
var supportsDolbyVisionHEVCHardware: Bool {
if let cached = _supportsDolbyVisionHEVCHardware { return cached }
let supported = VTIsHardwareDecodeSupported(kCMVideoCodecType_DolbyVisionHEVC)
_supportsDolbyVisionHEVCHardware = supported
return supported
}
/// Whether the device supports VP9 hardware decoding.
var supportsVP9Hardware: Bool {
if let cached = _supportsVP9Hardware { return cached }
let supported = VTIsHardwareDecodeSupported(kCMVideoCodecType_VP9)
_supportsVP9Hardware = supported
return supported
}
/// Whether the device supports AV1 hardware decoding.
var supportsAV1Hardware: Bool {
if let cached = _supportsAV1Hardware { return cached }
let supported = VTIsHardwareDecodeSupported(kCMVideoCodecType_AV1)
_supportsAV1Hardware = supported
return supported
}
/// Whether the device supports ProRes hardware decoding.
var supportsProResHardware: Bool {
if let cached = _supportsProResHardware { return cached }
let supported = VTIsHardwareDecodeSupported(kCMVideoCodecType_AppleProRes422)
_supportsProResHardware = supported
return supported
}
// MARK: - Codec Priority
/// Returns codec priority for stream selection (higher = better).
///
/// When hardware decode is available, the codec gets a higher priority
/// to prefer battery-efficient playback. When not available, codecs that
/// require software decode get priority 0 to prefer hardware-decodable
/// alternatives at the same or similar resolution.
///
/// Priority levels:
/// - 4: Best (AV1 with hardware)
/// - 3: Great (VP9 with hardware, HEVC with hardware)
/// - 2: Good (H.264 - always hardware supported)
/// - 1: Acceptable (HEVC software - rare)
/// - 0: Avoid (AV1/VP9 software - battery drain, potential performance issues)
func codecPriority(for codec: String?) -> Int {
guard let codec = codec?.lowercased() else { return 0 }
if codec.contains("av1") || codec.contains("av01") {
// AV1: Best compression but avoid without hardware (heavy CPU usage)
return supportsAV1Hardware ? 4 : 0
} else if codec.contains("vp9") || codec.contains("vp09") {
// VP9: Good compression but avoid without hardware (battery drain)
return supportsVP9Hardware ? 3 : 0
} else if codec.contains("avc") || codec.contains("h264") || codec.contains("h.264") {
// H.264: Universal hardware support - reliable choice
return 2
} else if codec.contains("hevc") || codec.contains("hev") || codec.contains("h265") || codec.contains("h.265") {
// HEVC: Good compression, most devices have hardware support
return supportsHEVCHardware ? 3 : 1
}
return 0
}
/// Returns an ordered list of preferred codecs based on hardware support.
var preferredCodecOrder: [String] {
var codecs: [(String, Int)] = []
if supportsAV1Hardware {
codecs.append(("AV1", 4))
}
if supportsVP9Hardware {
codecs.append(("VP9", 3))
}
// H.264 is always hardware supported
codecs.append(("H.264", 2))
if supportsHEVCHardware {
codecs.append(("HEVC", 2))
}
return codecs.sorted { $0.1 > $1.1 }.map { $0.0 }
}
// MARK: - All Capabilities
/// Returns all codec capabilities for display in Device Capabilities view.
var allCapabilities: [(name: String, supported: Bool)] {
[
("H.264/AVC", supportsH264Hardware),
("HEVC/H.265", supportsHEVCHardware),
("HEVC with Alpha", supportsHEVCAlphaHardware),
("Dolby Vision HEVC", supportsDolbyVisionHEVCHardware),
("VP9", supportsVP9Hardware),
("AV1", supportsAV1Hardware),
("ProRes", supportsProResHardware)
]
}
// MARK: - Logging
/// Logs all hardware capabilities for debugging.
func logCapabilities() {
let capabilities = allCapabilities.map { "\($0.name): \($0.supported ? "Yes" : "No")" }.joined(separator: ", ")
LoggingService.shared.info("Hardware decode capabilities: \(capabilities)", category: .general)
LoggingService.shared.info("Preferred codec order: \(preferredCodecOrder.joined(separator: " > "))", category: .general)
}
}

View File

@@ -0,0 +1,127 @@
//
// HomeInstanceDiskCache.swift
// Yattee
//
// Persistent on-device cache for home instance content (Popular/Trending).
// Stores cached videos on disk to enable fast loading on app launch.
//
import Foundation
/// Persistent cache for home instance content that stores videos on disk.
/// This is a local-only cache (not synced to iCloud) for fast content loading.
actor HomeInstanceDiskCache {
static let shared = HomeInstanceDiskCache()
private let fileManager = FileManager.default
private let cacheDirectory: URL
private let cacheFileName = "library_instances.json"
/// In-memory cache of the data.
private var cachedData: CacheData?
private init() {
// Use Caches directory - not backed up, not synced
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
cacheDirectory = caches.appendingPathComponent("LibraryCache", isDirectory: true)
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
private var cacheFileURL: URL {
cacheDirectory.appendingPathComponent(cacheFileName)
}
// MARK: - Public API
/// Loads the cached data from disk.
/// Returns nil if no cache exists or if it's corrupted.
func load() async -> CacheData? {
// Return in-memory cache if available
if let cachedData {
return cachedData
}
// Try to load from disk
guard fileManager.fileExists(atPath: cacheFileURL.path) else {
return nil
}
do {
let data = try Data(contentsOf: cacheFileURL)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let cacheData = try decoder.decode(CacheData.self, from: data)
// Store in memory
cachedData = cacheData
return cacheData
} catch {
// Cache is corrupted, remove it
try? fileManager.removeItem(at: cacheFileURL)
await MainActor.run {
LoggingService.shared.warning(
"HomeInstanceDiskCache.load: Cache corrupted, removed",
category: .general,
details: error.localizedDescription
)
}
return nil
}
}
/// Saves the cache to disk.
func save(_ data: CacheData) async {
// Update in-memory cache
cachedData = data
// Write to disk
do {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = .prettyPrinted
let encodedData = try encoder.encode(data)
let sizeMB = Double(encodedData.count) / (1024 * 1024)
try encodedData.write(to: cacheFileURL, options: .atomic)
await MainActor.run {
LoggingService.shared.debug(
"HomeInstanceDiskCache.save: Wrote \(data.videos.values.map { $0.count }.reduce(0, +)) videos (\(String(format: "%.2f", sizeMB)) MB) to disk",
category: .general
)
}
} catch {
await MainActor.run {
LoggingService.shared.error(
"HomeInstanceDiskCache.save: Failed to write to disk",
category: .general,
details: error.localizedDescription
)
}
}
}
/// Clears the cache from both memory and disk.
func clear() async {
cachedData = nil
try? fileManager.removeItem(at: cacheFileURL)
await MainActor.run {
LoggingService.shared.debug("HomeInstanceDiskCache.clear: Cache cleared", category: .general)
}
}
}
// MARK: - Cache Data Model
extension HomeInstanceDiskCache {
/// Data structure for the home instance cache.
/// Uses cache keys in format "instanceID_contentType" (e.g., "UUID_popular")
struct CacheData: Codable, Sendable {
var videos: [String: [Video]] // cacheKey -> videos
var lastUpdated: [String: Date] // cacheKey -> timestamp
init(videos: [String: [Video]] = [:], lastUpdated: [String: Date] = [:]) {
self.videos = videos
self.lastUpdated = lastUpdated
}
}
}

View File

@@ -0,0 +1,388 @@
//
// InstancesManager.swift
// Yattee
//
// Manages configured backend instances with iCloud sync.
//
import Foundation
import SwiftUI
/// Status of an instance's connectivity and authentication.
enum InstanceStatus: Equatable {
/// Instance is online and working.
case online
/// Instance is offline or unreachable.
case offline
/// Instance requires authentication but credentials are not provided.
case authRequired
/// Instance authentication failed (wrong credentials).
case authFailed
}
/// Manages the list of configured backend instances with iCloud sync.
@MainActor
@Observable
final class InstancesManager {
// MARK: - Storage
private let localDefaults = UserDefaults.standard
private let ubiquitousStore = NSUbiquitousKeyValueStore.default
private let instancesKey = "configuredInstances"
private let activeInstanceKey = "activeInstanceID"
// MARK: - Dependencies
private weak var settingsManager: SettingsManager?
// MARK: - State
private(set) var instances: [Instance] = []
private(set) var activeInstanceID: UUID?
/// Current status of each instance, keyed by instance ID.
private(set) var instanceStatuses: [UUID: InstanceStatus] = [:]
// MARK: - Initialization
init(settingsManager: SettingsManager? = nil) {
self.settingsManager = settingsManager
loadInstances()
loadActiveInstance()
// Listen for external changes from iCloud
NotificationCenter.default.addObserver(
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: ubiquitousStore,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.handleiCloudChange()
}
}
}
/// Sets the settings manager reference for checking iCloud sync status.
func setSettingsManager(_ manager: SettingsManager) {
self.settingsManager = manager
}
/// Whether iCloud sync is currently enabled.
private var iCloudSyncEnabled: Bool {
settingsManager?.iCloudSyncEnabled ?? false
}
/// Whether instance sync is enabled (requires both master toggle and category toggle).
private var instanceSyncEnabled: Bool {
iCloudSyncEnabled && (settingsManager?.syncInstances ?? true)
}
/// Handles external iCloud changes by replacing local data with iCloud data.
private func handleiCloudChange() {
// Only process iCloud changes if instance sync is enabled
guard instanceSyncEnabled else { return }
guard let iCloudData = ubiquitousStore.data(forKey: instancesKey),
let iCloudInstances = try? JSONDecoder().decode([Instance].self, from: iCloudData) else {
return
}
// Replace local instances with iCloud data
instances = iCloudInstances
// Save to local defaults for offline access
localDefaults.set(iCloudData, forKey: instancesKey)
// Update sync time
settingsManager?.updateLastSyncTime()
}
/// Syncs local data to iCloud (called when enabling iCloud sync).
/// Only syncs if instance sync is enabled.
func syncToiCloud() {
guard instanceSyncEnabled else { return }
guard let data = try? JSONEncoder().encode(instances) else { return }
ubiquitousStore.set(data, forKey: instancesKey)
ubiquitousStore.synchronize()
settingsManager?.updateLastSyncTime()
}
/// Replaces local data with iCloud data (called when enabling iCloud sync).
/// Only replaces if instance sync is enabled.
func replaceWithiCloudData() {
guard instanceSyncEnabled else { return }
ubiquitousStore.synchronize()
guard let iCloudData = ubiquitousStore.data(forKey: instancesKey),
let iCloudInstances = try? JSONDecoder().decode([Instance].self, from: iCloudData) else {
// No iCloud data exists, sync local data to iCloud
syncToiCloud()
return
}
// Replace local with iCloud data
instances = iCloudInstances
localDefaults.set(iCloudData, forKey: instancesKey)
settingsManager?.updateLastSyncTime()
}
// MARK: - Public Methods
func add(_ instance: Instance) {
instances.append(instance)
saveInstances()
}
func remove(_ instance: Instance) {
instances.removeAll { $0.id == instance.id }
// Clear active instance if it was the removed one
if activeInstanceID == instance.id {
activeInstanceID = nil
localDefaults.removeObject(forKey: activeInstanceKey)
}
saveInstances()
}
func update(_ instance: Instance) {
if let index = instances.firstIndex(where: { $0.id == instance.id }) {
instances[index] = instance
saveInstances()
}
}
/// Alias for add method to maintain consistency.
func addInstance(_ instance: Instance) {
add(instance)
}
/// Toggles the enabled state of an instance.
func toggleEnabled(_ instance: Instance) {
if let index = instances.firstIndex(where: { $0.id == instance.id }) {
var updated = instances[index]
updated.isEnabled.toggle()
instances[index] = updated
saveInstances()
}
}
/// Sets the given instance as the primary (first) instance.
func setPrimary(_ instance: Instance) {
LoggingService.shared.debug("[InstancesManager] setPrimary called for: \(instance.displayName)", category: .general)
LoggingService.shared.debug("[InstancesManager] Current instances: \(instances.map { $0.displayName })", category: .general)
guard let index = instances.firstIndex(where: { $0.id == instance.id }) else {
LoggingService.shared.debug("[InstancesManager] Instance not found in list", category: .general)
return
}
if index == 0 {
LoggingService.shared.debug("[InstancesManager] Instance already at index 0, skipping", category: .general)
return
}
LoggingService.shared.debug("[InstancesManager] Moving instance from index \(index) to 0", category: .general)
// Move to front
let removed = instances.remove(at: index)
instances.insert(removed, at: 0)
saveInstances()
LoggingService.shared.debug("[InstancesManager] After move: \(instances.map { $0.displayName })", category: .general)
}
// MARK: - Computed Properties
var enabledInstances: [Instance] {
instances.filter(\.isEnabled)
}
var youtubeInstances: [Instance] {
instances.filter(\.isYouTubeInstance)
}
var peertubeInstances: [Instance] {
instances.filter(\.isPeerTubeInstance)
}
var yatteeServerInstances: [Instance] {
instances.filter(\.isYatteeServerInstance)
}
var hasYouTubeInstances: Bool {
instances.contains { $0.isYouTubeInstance }
}
var hasPeerTubeInstances: Bool {
instances.contains { $0.isPeerTubeInstance }
}
var hasYatteeServerInstances: Bool {
instances.contains { $0.isYatteeServerInstance }
}
var invidiousPipedInstances: [Instance] {
instances.filter { $0.type == .invidious || $0.type == .piped }
}
var hasInvidiousPipedInstances: Bool {
instances.contains { $0.type == .invidious || $0.type == .piped }
}
var enabledYatteeServerInstances: [Instance] {
yatteeServerInstances.filter(\.isEnabled)
}
/// Selects an enabled instance appropriate for the given video's content source.
/// - For PeerTube videos: prefers the exact instance, falls back to any PeerTube instance
/// - For YouTube/extracted content: uses YouTube-capable instance (Invidious, Piped, Yattee Server)
func instance(for video: Video) -> Instance? {
instance(for: video.id.source)
}
/// Selects an enabled instance appropriate for the given content source.
/// - For PeerTube content: prefers the exact instance, falls back to any PeerTube instance
/// - For extracted content: requires Yattee Server (only backend with yt-dlp)
/// - For YouTube content: uses YouTube-capable instance (Invidious, Piped, Yattee Server)
func instance(for contentSource: ContentSource) -> Instance? {
switch contentSource {
case .federated(let provider, let instanceURL) where provider == ContentSource.peertubeProvider:
// PeerTube content - prefer the exact instance, fall back to any PeerTube instance
return enabledInstances.first { $0.url.host == instanceURL.host }
?? enabledInstances.first(where: \.isPeerTubeInstance)
case .extracted:
// Extracted content requires Yattee Server (yt-dlp)
return yatteeServerInstances.first
case .global, .federated:
// YouTube content - prefer Yattee Server, fall back to other YouTube-capable instances
return enabledYatteeServerInstances.first
?? enabledInstances.first(where: \.isYouTubeInstance)
}
}
/// Disables all Yattee Server instances except the specified one.
func disableOtherYatteeServerInstances(except instanceID: UUID) {
for instance in enabledYatteeServerInstances where instance.id != instanceID {
var updated = instance
updated.isEnabled = false
update(updated)
}
}
// MARK: - Instance Status
/// Returns the current status of an instance.
func status(for instance: Instance) -> InstanceStatus {
instanceStatuses[instance.id] ?? .online
}
/// Updates the status of an instance.
func updateStatus(_ status: InstanceStatus, for instance: Instance) {
instanceStatuses[instance.id] = status
}
/// Updates status based on an API error.
func updateStatusFromError(_ error: Error, for instance: Instance) {
if let apiError = error as? APIError {
switch apiError {
case .unauthorized:
updateStatus(.authFailed, for: instance)
case .noConnection, .timeout:
updateStatus(.offline, for: instance)
default:
// Don't change status for other errors
break
}
} else {
// Generic network errors
let nsError = error as NSError
if nsError.domain == NSURLErrorDomain {
updateStatus(.offline, for: instance)
}
}
}
/// Clears the status of an instance (resets to online).
func clearStatus(for instance: Instance) {
instanceStatuses.removeValue(forKey: instance.id)
}
/// Instances that have auth issues (need attention).
var instancesWithAuthIssues: [Instance] {
instances.filter { instanceStatuses[$0.id] == .authFailed || instanceStatuses[$0.id] == .authRequired }
}
/// The currently active instance for browsing content.
/// Falls back to the first enabled instance if no active instance is set.
var activeInstance: Instance? {
if let id = activeInstanceID,
let instance = enabledInstances.first(where: { $0.id == id }) {
return instance
}
return enabledInstances.first
}
/// Sets the given instance as the active instance for browsing.
func setActive(_ instance: Instance) {
guard enabledInstances.contains(where: { $0.id == instance.id }) else { return }
activeInstanceID = instance.id
saveActiveInstance()
NotificationCenter.default.post(name: .activeInstanceDidChange, object: nil)
}
/// Clears the active instance, falling back to the first enabled instance.
func clearActiveInstance() {
activeInstanceID = nil
localDefaults.removeObject(forKey: activeInstanceKey)
NotificationCenter.default.post(name: .activeInstanceDidChange, object: nil)
}
// MARK: - Private Methods
private func loadInstances() {
// Only load from local defaults - never automatically pull from iCloud
// User must explicitly enable iCloud sync to get iCloud data
if let data = localDefaults.data(forKey: instancesKey),
let decoded = try? JSONDecoder().decode([Instance].self, from: data) {
instances = decoded
}
}
private func saveInstances() {
guard let data = try? JSONEncoder().encode(instances) else { return }
// Always write to local storage
localDefaults.set(data, forKey: instancesKey)
// Only write to iCloud if instance sync is enabled
if instanceSyncEnabled {
ubiquitousStore.set(data, forKey: instancesKey)
settingsManager?.updateLastSyncTime()
}
NotificationCenter.default.post(name: .instancesDidChange, object: nil)
}
private func loadActiveInstance() {
if let idString = localDefaults.string(forKey: activeInstanceKey),
let uuid = UUID(uuidString: idString) {
activeInstanceID = uuid
}
}
private func saveActiveInstance() {
if let id = activeInstanceID {
localDefaults.set(id.uuidString, forKey: activeInstanceKey)
} else {
localDefaults.removeObject(forKey: activeInstanceKey)
}
}
}
// MARK: - Notifications
extension Notification.Name {
static let instancesDidChange = Notification.Name("stream.yattee.instancesDidChange")
static let activeInstanceDidChange = Notification.Name("stream.yattee.activeInstanceDidChange")
}

View File

@@ -0,0 +1,517 @@
//
// MediaSourcesManager.swift
// Yattee
//
// Manages configured media sources with persistence.
//
import Foundation
import Security
/// Manages the list of configured media sources.
@MainActor
@Observable
final class MediaSourcesManager {
// MARK: - Storage
private let localDefaults = UserDefaults.standard
private let ubiquitousStore = NSUbiquitousKeyValueStore.default
private let sourcesKey = "configuredMediaSources"
private let iCloudSourcesKey = "syncedMediaSources"
private let keychainServiceName = "com.yattee.mediasources"
// MARK: - Dependencies
private weak var settingsManager: SettingsManager?
private weak var dataManager: DataManager?
// MARK: - Sync State
private var isImportingFromiCloud = false
private var iCloudObserver: NSObjectProtocol?
// MARK: - State
private(set) var sources: [MediaSource] = []
/// Tracks which sources have passwords stored (for reactive UI updates)
private(set) var passwordStoredSourceIDs: Set<UUID> = []
// MARK: - Initialization
init(settingsManager: SettingsManager? = nil) {
self.settingsManager = settingsManager
loadSources()
observeiCloudChanges()
// Import or refresh network sources from iCloud on startup
importFromiCloudOnStartupIfNeeded()
}
/// Imports or refreshes network sources (WebDAV and SMB) from iCloud on startup.
/// - If no local network sources exist: imports all from iCloud (first-time setup).
/// - If local network sources differ from iCloud: replaces local with iCloud data
/// (catches name changes, enable/disable toggles, etc. made on other devices while app was closed).
private func importFromiCloudOnStartupIfNeeded() {
guard iCloudSyncEnabled else {
LoggingService.shared.debug("MediaSources startup: iCloud sync disabled, skipping import", category: .cloudKit)
return
}
ubiquitousStore.synchronize()
guard let data = ubiquitousStore.data(forKey: iCloudSourcesKey),
let exports = try? JSONDecoder().decode([MediaSourceExport].self, from: data),
!exports.isEmpty else {
LoggingService.shared.debug("MediaSources startup: No network sources in iCloud", category: .cloudKit)
return
}
let iCloudNetworkSources = exports.compactMap { $0.toMediaSource() }
if networkSources.isEmpty {
// First-time import: no local network sources
LoggingService.shared.info("MediaSources startup: Importing \(iCloudNetworkSources.count) network sources from iCloud", category: .cloudKit)
sources.append(contentsOf: iCloudNetworkSources)
saveSources()
refreshPasswordStoredStatus()
} else if networkSources != iCloudNetworkSources {
// Existing sources differ from iCloud - refresh from iCloud
// This catches name changes, enable/disable toggles, etc. made on other devices
LoggingService.shared.info("MediaSources startup: Refreshing \(iCloudNetworkSources.count) network sources from iCloud (local differs)", category: .cloudKit)
isImportingFromiCloud = true
defer { isImportingFromiCloud = false }
let localFolderSources = sources.filter { $0.type == .localFolder }
sources = localFolderSources + iCloudNetworkSources
saveSources()
refreshPasswordStoredStatus()
} else {
LoggingService.shared.debug("MediaSources startup: Local sources match iCloud, no update needed", category: .cloudKit)
}
}
/// Sets the settings manager reference (for dependency injection after init).
func configure(settingsManager: SettingsManager) {
self.settingsManager = settingsManager
}
/// Sets the data manager reference (for cleanup when sources are deleted).
func setDataManager(_ manager: DataManager) {
self.dataManager = manager
}
// MARK: - Source Management
/// Adds a new media source.
func add(_ source: MediaSource) {
sources.append(source)
saveSources()
syncToiCloudIfNeeded()
}
/// Removes a media source and its stored credentials.
func remove(_ source: MediaSource) {
// Clean up associated data (history, bookmarks, playlist items)
dataManager?.removeAllDataForMediaSource(sourceID: source.id)
// Remove from Home cards/sections
settingsManager?.removeFromHome(sourceID: source.id)
sources.removeAll { $0.id == source.id }
saveSources()
syncToiCloudIfNeeded()
// Remove password from Keychain (for network sources)
if source.type == .webdav || source.type == .smb {
deletePassword(for: source)
}
}
/// Updates an existing media source.
func update(_ source: MediaSource) {
if let index = sources.firstIndex(where: { $0.id == source.id }) {
sources[index] = source
saveSources()
syncToiCloudIfNeeded()
}
}
/// Toggles the enabled state of a source.
func toggleEnabled(_ source: MediaSource) {
if let index = sources.firstIndex(where: { $0.id == source.id }) {
var updated = sources[index]
updated.isEnabled.toggle()
sources[index] = updated
saveSources()
syncToiCloudIfNeeded()
}
}
// MARK: - Computed Properties
var enabledSources: [MediaSource] {
sources.filter(\.isEnabled)
}
var webdavSources: [MediaSource] {
sources.filter { $0.type == .webdav }
}
var smbSources: [MediaSource] {
sources.filter { $0.type == .smb }
}
/// All network sources (WebDAV and SMB) that can be synced to iCloud.
var networkSources: [MediaSource] {
sources.filter { $0.type == .webdav || $0.type == .smb }
}
var localFolderSources: [MediaSource] {
sources.filter { $0.type == .localFolder }
}
var isEmpty: Bool {
sources.isEmpty
}
/// Returns true if this network source (WebDAV or SMB) needs password to be configured.
/// Uses the tracked set for reactive UI updates.
func needsPassword(for source: MediaSource) -> Bool {
guard source.type == .webdav || source.type == .smb else { return false }
return !passwordStoredSourceIDs.contains(source.id)
}
/// Returns true if any network source needs password.
var hasSourcesNeedingPassword: Bool {
networkSources.contains { needsPassword(for: $0) }
}
/// Find source by UUID.
func source(byID id: UUID) -> MediaSource? {
sources.first { $0.id == id }
}
// MARK: - Persistence
private func loadSources() {
if let data = localDefaults.data(forKey: sourcesKey),
let decoded = try? JSONDecoder().decode([MediaSource].self, from: data) {
sources = decoded
refreshPasswordStoredStatus()
cleanupOrphanedHomeItems()
}
}
/// Removes Home items for sources that no longer exist
private func cleanupOrphanedHomeItems() {
let validSourceIDs = Set(sources.map(\.id))
settingsManager?.cleanupOrphanedHomeMediaSourceItems(validSourceIDs: validSourceIDs)
}
/// Refreshes the set of source IDs that have passwords stored (for network sources).
/// Call this when app returns from background to sync with Keychain state.
func refreshPasswordStoredStatus() {
let previousIDs = passwordStoredSourceIDs
passwordStoredSourceIDs = Set(
sources.filter { $0.type == .webdav || $0.type == .smb }
.filter { password(for: $0) != nil }
.map(\.id)
)
// Log if status changed (helps debug auth issues)
if previousIDs != passwordStoredSourceIDs {
let added = passwordStoredSourceIDs.subtracting(previousIDs)
let removed = previousIDs.subtracting(passwordStoredSourceIDs)
LoggingService.shared.info(
"Password status changed",
category: .keychain,
details: "added=\(added.count), removed=\(removed.count)"
)
}
}
private func saveSources() {
guard let data = try? JSONEncoder().encode(sources) else { return }
localDefaults.set(data, forKey: sourcesKey)
}
// MARK: - Keychain (Passwords)
/// Stores a password for a WebDAV/SMB source in the Keychain.
/// Password syncs to iCloud Keychain when iCloud sync is enabled for media sources.
func setPassword(_ password: String, for source: MediaSource) {
let account = source.id.uuidString
guard let data = password.data(using: .utf8) else {
LoggingService.shared.error("Failed to encode password data", category: .keychain)
return
}
let syncEnabled = shouldSyncCredentialsToiCloud
// First, delete any existing item (both synced and non-synced) to avoid duplicates
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
SecItemDelete(deleteQuery as CFDictionary)
// Create new item with current sync preference
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: syncEnabled,
kSecValueData as String: data
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status == errSecSuccess {
LoggingService.shared.info(
"Stored password for \(source.name)",
category: .keychain,
details: "iCloudSync=\(syncEnabled)"
)
// Update tracked set for reactive UI
passwordStoredSourceIDs.insert(source.id)
} else {
LoggingService.shared.error(
"Failed to store password for \(source.name)",
category: .keychain,
details: "status=\(status)"
)
}
}
/// Retrieves the password for a WebDAV/SMB source from the Keychain.
/// Searches both synced and non-synced items.
func password(for source: MediaSource) -> String? {
let account = source.id.uuidString
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let password = String(data: data, encoding: .utf8) else {
LoggingService.shared.debug(
"No password found for \(source.name)",
category: .keychain,
details: "status=\(status)"
)
return nil
}
LoggingService.shared.debug("Retrieved password for \(source.name)", category: .keychain)
return password
}
/// Deletes the password for a source from the Keychain.
/// Deletes both synced and non-synced items.
func deletePassword(for source: MediaSource) {
let account = source.id.uuidString
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound {
LoggingService.shared.info("Deleted password for \(source.name)", category: .keychain)
} else {
LoggingService.shared.error(
"Failed to delete password for \(source.name)",
category: .keychain,
details: "status=\(status)"
)
}
// Update tracked set for reactive UI
passwordStoredSourceIDs.remove(source.id)
}
// MARK: - Bookmarks (Local Folders)
/// Updates the bookmark data for a local folder source.
func updateBookmark(_ bookmarkData: Data, for source: MediaSource) {
if let index = sources.firstIndex(where: { $0.id == source.id }) {
var updated = sources[index]
updated.bookmarkData = bookmarkData
sources[index] = updated
saveSources()
}
}
/// Resolves and accesses a local folder source.
/// - Parameter source: The local folder source.
/// - Returns: The resolved URL, or nil if bookmark resolution failed.
func resolveLocalFolderURL(for source: MediaSource) -> URL? {
guard source.type == .localFolder,
let bookmarkData = source.bookmarkData else {
return source.url
}
var isStale = false
#if os(macOS)
let options: URL.BookmarkResolutionOptions = [.withSecurityScope]
#else
let options: URL.BookmarkResolutionOptions = []
#endif
do {
let url = try URL(
resolvingBookmarkData: bookmarkData,
options: options,
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
// If bookmark is stale, we should re-create it
// but we can't do that without user interaction
return url
} catch {
return nil
}
}
// MARK: - iCloud Sync
/// Whether iCloud sync is enabled and media sources sync is enabled.
private var iCloudSyncEnabled: Bool {
settingsManager?.iCloudSyncEnabled == true && settingsManager?.syncMediaSources == true
}
/// Whether credentials should sync to iCloud Keychain (when iCloud sync is enabled for media sources).
private var shouldSyncCredentialsToiCloud: Bool {
iCloudSyncEnabled
}
/// Syncs network sources to iCloud if sync is enabled and not currently importing.
private func syncToiCloudIfNeeded() {
guard iCloudSyncEnabled, !isImportingFromiCloud else { return }
syncToiCloud()
}
/// Syncs all network sources (WebDAV and SMB) to iCloud.
/// Note: Local folder sources are never synced as they are device-specific.
func syncToiCloud() {
guard iCloudSyncEnabled else {
LoggingService.shared.debug("MediaSources: iCloud sync disabled, skipping", category: .cloudKit)
return
}
let networkSources = sources.filter { $0.type == .webdav || $0.type == .smb }
let exports = networkSources.map { MediaSourceExport(from: $0) }
let sourceNames = exports.map { "\($0.id): \($0.name)" }.joined(separator: ", ")
LoggingService.shared.info("MediaSources: Syncing \(exports.count) network sources to iCloud", category: .cloudKit, details: sourceNames)
if let data = try? JSONEncoder().encode(exports) {
ubiquitousStore.set(data, forKey: iCloudSourcesKey)
ubiquitousStore.synchronize()
LoggingService.shared.debug("MediaSources: Synced to iCloud successfully", category: .cloudKit)
}
}
/// Replaces local network sources (WebDAV and SMB) with iCloud data.
/// Preserves local folder sources which are device-specific.
func replaceWithiCloudData() {
guard let data = ubiquitousStore.data(forKey: iCloudSourcesKey),
let exports = try? JSONDecoder().decode([MediaSourceExport].self, from: data) else {
LoggingService.shared.debug("MediaSources: replaceWithiCloudData - No data in iCloud or decode failed", category: .cloudKit)
return
}
isImportingFromiCloud = true
defer { isImportingFromiCloud = false }
let sourceNames = exports.map { "\($0.id): \($0.name)" }.joined(separator: ", ")
LoggingService.shared.info("MediaSources: Replacing with \(exports.count) network sources from iCloud", category: .cloudKit, details: sourceNames)
// Keep local folder sources
let localFolderSources = sources.filter { $0.type == .localFolder }
// Convert exports to sources (WebDAV and SMB)
let iCloudNetworkSources = exports.compactMap { $0.toMediaSource() }
// Merge: local folders + iCloud network sources
sources = localFolderSources + iCloudNetworkSources
saveSources()
// Refresh password status for UI reactivity
refreshPasswordStoredStatus()
LoggingService.shared.info("MediaSources: Now have \(sources.count) sources (\(networkSources.count) network, \(localFolderSources.count) local)", category: .cloudKit)
}
/// Observes iCloud key-value store changes.
private func observeiCloudChanges() {
iCloudObserver = NotificationCenter.default.addObserver(
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: ubiquitousStore,
queue: .main
) { [weak self] notification in
guard let self else { return }
// Log the change reason
let changeReason = notification.userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] ?? []
Task { @MainActor in
LoggingService.shared.debug("MediaSources: iCloud external change - reason=\(changeReason ?? -1), keys=\(changedKeys)", category: .cloudKit)
}
// Check if our key was changed
guard changedKeys.contains(self.iCloudSourcesKey) else {
Task { @MainActor in
LoggingService.shared.debug("MediaSources: iCloud change not for media sources key, ignoring", category: .cloudKit)
}
return
}
Task { @MainActor [weak self] in
guard let self else { return }
// Check sync settings
guard self.iCloudSyncEnabled else {
LoggingService.shared.debug("MediaSources: iCloud sync disabled, ignoring external change", category: .cloudKit)
return
}
LoggingService.shared.info("MediaSources: Processing iCloud external change", category: .cloudKit)
self.replaceWithiCloudData()
}
}
// Synchronize to get latest values
ubiquitousStore.synchronize()
}
}
// MARK: - Preview Support
extension MediaSourcesManager {
/// Preview manager with sample data.
static var preview: MediaSourcesManager {
let manager = MediaSourcesManager()
manager.sources = [
.webdav(name: "My NAS", url: URL(string: "https://nas.local:5006")!, username: "user"),
.localFolder(name: "Downloads", url: URL(fileURLWithPath: "/Users/user/Downloads"))
]
return manager
}
}

View File

@@ -0,0 +1,142 @@
//
// NavigationCommands.swift
// Yattee
//
// Menu bar commands for tab navigation.
//
import SwiftUI
#if !os(tvOS)
/// Navigation-related menu bar commands.
/// Works on both macOS and iPadOS 26+.
struct NavigationCommands: Commands {
let appEnvironment: AppEnvironment
private var navigationCoordinator: NavigationCoordinator {
appEnvironment.navigationCoordinator
}
private var settingsManager: SettingsManager {
appEnvironment.settingsManager
}
private var visibleItems: [SidebarMainItem] {
settingsManager.visibleSidebarMainItems()
}
var body: some Commands {
CommandMenu(String(localized: "menu.navigation")) {
// Home is always visible (required)
homeButton
if visibleItems.contains(.subscriptions) {
subscriptionsButton
}
Divider()
if visibleItems.contains(.bookmarks) {
bookmarksButton
}
if visibleItems.contains(.history) {
historyButton
}
if visibleItems.contains(.downloads) {
downloadsButton
}
Divider()
if visibleItems.contains(.channels) {
channelsButton
}
if visibleItems.contains(.sources) {
sourcesButton
}
Divider()
// Search is always visible (required)
searchButton
if visibleItems.contains(.settings) {
settingsButton
}
}
}
private var homeButton: some View {
Button {
navigationCoordinator.selectedTab = .home
} label: {
Text(String(localized: "menu.navigation.home"))
}
.keyboardShortcut("1", modifiers: [.command])
}
private var subscriptionsButton: some View {
Button {
navigationCoordinator.selectedTab = .subscriptions
} label: {
Text(String(localized: "menu.navigation.subscriptions"))
}
.keyboardShortcut("2", modifiers: [.command])
}
private var searchButton: some View {
Button {
navigationCoordinator.selectedTab = .search
} label: {
Text(String(localized: "menu.navigation.search"))
}
.keyboardShortcut("f", modifiers: [.command])
}
private var bookmarksButton: some View {
Button {
navigationCoordinator.selectedSidebarItem = .bookmarks
} label: {
Text(String(localized: "menu.navigation.bookmarks"))
}
.keyboardShortcut("3", modifiers: [.command])
}
private var historyButton: some View {
Button {
navigationCoordinator.selectedSidebarItem = .history
} label: {
Text(String(localized: "menu.navigation.history"))
}
.keyboardShortcut("4", modifiers: [.command])
}
private var downloadsButton: some View {
Button {
navigationCoordinator.selectedSidebarItem = .downloads
} label: {
Text(String(localized: "menu.navigation.downloads"))
}
.keyboardShortcut("5", modifiers: [.command])
}
private var channelsButton: some View {
Button {
navigationCoordinator.selectedSidebarItem = .manageChannels
} label: {
Text(String(localized: "menu.navigation.channels"))
}
.keyboardShortcut("6", modifiers: [.command])
}
private var sourcesButton: some View {
Button {
navigationCoordinator.selectedSidebarItem = .sources
} label: {
Text(String(localized: "menu.navigation.sources"))
}
.keyboardShortcut("7", modifiers: [.command])
}
private var settingsButton: some View {
Button {
navigationCoordinator.selectedSidebarItem = .settings
} label: {
Text(String(localized: "menu.navigation.settings"))
}
.keyboardShortcut("9", modifiers: [.command])
}
}
#endif

View File

@@ -0,0 +1,324 @@
//
// PlaybackCommands.swift
// Yattee
//
// Menu bar commands for playback control.
//
import SwiftUI
#if !os(tvOS)
/// Playback-related menu bar commands.
/// Works on both macOS and iPadOS 26+.
struct PlaybackCommands: Commands {
let appEnvironment: AppEnvironment
private var playerService: PlayerService {
appEnvironment.playerService
}
private var navigationCoordinator: NavigationCoordinator {
appEnvironment.navigationCoordinator
}
private var state: PlayerState {
playerService.state
}
private var settingsManager: SettingsManager {
appEnvironment.settingsManager
}
private var hasActiveVideo: Bool {
state.currentVideo != nil
}
private var isPlayerExpanded: Bool {
navigationCoordinator.isPlayerExpanded
}
var body: some Commands {
CommandMenu(String(localized: "menu.playback")) {
// Player visibility (existing)
playerToggleButton
Divider()
// Core playback
playPauseButton
Divider()
// Seeking
seekBackward10Button
seekForward10Button
seekBackward30Button
seekForward30Button
Divider()
// Navigation
previousVideoButton
nextVideoButton
Divider()
// Speed
slowerButton
fasterButton
resetSpeedButton
Divider()
// Volume
volumeUpButton
volumeDownButton
muteButton
Divider()
// Display modes
pipButton
Divider()
closeVideoButton
}
}
// MARK: - Player Visibility
private var playerToggleButton: some View {
Button {
togglePlayerExpanded()
} label: {
Text(isPlayerExpanded
? String(localized: "menu.playback.hidePlayer")
: String(localized: "menu.playback.showPlayer"))
}
.keyboardShortcut("p", modifiers: [.command, .shift])
.disabled(!hasActiveVideo)
}
private func togglePlayerExpanded() {
if isPlayerExpanded {
navigationCoordinator.isPlayerExpanded = false
} else {
navigationCoordinator.expandPlayer()
}
}
// MARK: - Core Playback
private var playPauseButton: some View {
Button {
playerService.togglePlayPause()
} label: {
Text(String(localized: "menu.playback.playPause"))
}
.keyboardShortcut("k", modifiers: [.command])
.disabled(!hasActiveVideo)
}
// MARK: - Seeking
private var seekBackward10Button: some View {
Button {
playerService.seekBackward(by: 10)
} label: {
Text(String(localized: "menu.playback.seekBackward10"))
}
.keyboardShortcut(.leftArrow, modifiers: [.command])
.disabled(!hasActiveVideo)
}
private var seekForward10Button: some View {
Button {
playerService.seekForward(by: 10)
} label: {
Text(String(localized: "menu.playback.seekForward10"))
}
.keyboardShortcut(.rightArrow, modifiers: [.command])
.disabled(!hasActiveVideo)
}
private var seekBackward30Button: some View {
Button {
playerService.seekBackward(by: 30)
} label: {
Text(String(localized: "menu.playback.seekBackward30"))
}
.keyboardShortcut(.leftArrow, modifiers: [.command, .shift])
.disabled(!hasActiveVideo)
}
private var seekForward30Button: some View {
Button {
playerService.seekForward(by: 30)
} label: {
Text(String(localized: "menu.playback.seekForward30"))
}
.keyboardShortcut(.rightArrow, modifiers: [.command, .shift])
.disabled(!hasActiveVideo)
}
// MARK: - Navigation
private var previousVideoButton: some View {
Button {
Task {
await playerService.playPrevious()
}
} label: {
Text(String(localized: "menu.playback.previousVideo"))
}
.keyboardShortcut(.leftArrow, modifiers: [.command, .option])
.disabled(!hasActiveVideo || !state.hasPrevious)
}
private var nextVideoButton: some View {
Button {
Task {
await playerService.playNext()
}
} label: {
Text(String(localized: "menu.playback.nextVideo"))
}
.keyboardShortcut(.rightArrow, modifiers: [.command, .option])
.disabled(!hasActiveVideo || !state.hasNext)
}
// MARK: - Speed
private var slowerButton: some View {
Button {
cycleSpeedDown()
} label: {
Text(String(localized: "menu.playback.slower"))
}
.keyboardShortcut("[", modifiers: [.command])
.disabled(!hasActiveVideo)
}
private var fasterButton: some View {
Button {
cycleSpeedUp()
} label: {
Text(String(localized: "menu.playback.faster"))
}
.keyboardShortcut("]", modifiers: [.command])
.disabled(!hasActiveVideo)
}
private var resetSpeedButton: some View {
Button {
state.rate = .x1
playerService.currentBackend?.rate = 1.0
} label: {
Text(String(localized: "menu.playback.resetSpeed"))
}
.keyboardShortcut("0", modifiers: [.command])
.disabled(!hasActiveVideo)
}
private func cycleSpeedDown() {
let rates = PlaybackRate.allCases
guard let currentIndex = rates.firstIndex(of: state.rate) else { return }
if currentIndex > 0 {
let newRate = rates[currentIndex - 1]
state.rate = newRate
playerService.currentBackend?.rate = Float(newRate.rawValue)
}
}
private func cycleSpeedUp() {
let rates = PlaybackRate.allCases
guard let currentIndex = rates.firstIndex(of: state.rate) else { return }
if currentIndex < rates.count - 1 {
let newRate = rates[currentIndex + 1]
state.rate = newRate
playerService.currentBackend?.rate = Float(newRate.rawValue)
}
}
// MARK: - Volume
private var volumeUpButton: some View {
Button {
let newVolume = min(1.0, state.volume + 0.1)
state.volume = newVolume
playerService.currentBackend?.volume = newVolume
} label: {
Text(String(localized: "menu.playback.volumeUp"))
}
.keyboardShortcut(.upArrow, modifiers: [.command])
.disabled(!hasActiveVideo)
}
private var volumeDownButton: some View {
Button {
let newVolume = max(0.0, state.volume - 0.1)
state.volume = newVolume
playerService.currentBackend?.volume = newVolume
} label: {
Text(String(localized: "menu.playback.volumeDown"))
}
.keyboardShortcut(.downArrow, modifiers: [.command])
.disabled(!hasActiveVideo)
}
private var muteButton: some View {
Button {
state.isMuted.toggle()
playerService.currentBackend?.isMuted = state.isMuted
} label: {
Text(String(localized: "menu.playback.mute"))
}
.keyboardShortcut("m", modifiers: [.command, .shift])
.disabled(!hasActiveVideo)
}
// MARK: - Display Modes
private var pipButton: some View {
Button {
if let mpvBackend = playerService.currentBackend as? MPVBackend {
mpvBackend.togglePiP()
}
} label: {
Text(String(localized: "menu.playback.pip"))
}
.keyboardShortcut("i", modifiers: [.command, .shift])
.disabled(!hasActiveVideo || !state.isPiPPossible)
}
// MARK: - Close video button
private var closeVideoButton: some View {
Button {
closeVideo()
} label: {
Text(String(localized: "menu.playback.closeVideo"))
}
.keyboardShortcut(".", modifiers: [.command])
.disabled(!hasActiveVideo)
}
private func closeVideo() {
// Mark as closing to hide tab accessory before dismissal
state.isClosingVideo = true
// Clear the queue when closing video
appEnvironment.queueManager.clearQueue()
// Reset panel state when closing player
settingsManager.landscapeDetailsPanelVisible = false
settingsManager.landscapeDetailsPanelPinned = false
// Stop player FIRST before dismissing window
playerService.stop()
// Then dismiss player window (after backend is stopped)
navigationCoordinator.isPlayerExpanded = false
}
}
#endif

View File

@@ -0,0 +1,140 @@
//
// SettingsKey.swift
// Yattee
//
// Keys used for storing settings in UserDefaults and iCloud.
//
import Foundation
/// Keys for storing settings values.
/// Used internally by SettingsManager for persistence.
enum SettingsKey: String, CaseIterable {
// General
case theme
case accentColor
case showWatchedCheckmark
// Playback
case preferredQuality
case cellularQuality
case autoplay
case backgroundPlayback
case dashEnabled
case preferredAudioLanguage
case preferredSubtitlesLanguage
case resumeAction
// SponsorBlock
case sponsorBlockEnabled
case sponsorBlockCategories
case sponsorBlockAPIURL
// Return YouTube Dislike
case returnYouTubeDislikeEnabled
// DeArrow
case deArrowEnabled
case deArrowReplaceTitles
case deArrowReplaceThumbnails
case deArrowAPIURL
case deArrowThumbnailAPIURL
// Platform-specific
case macPlayerMode
case playerSheetAutoResize
case listStyle
// Feed
case feedCacheValidityMinutes
// Player
case keepPlayerPinned
case hapticFeedbackEnabled
case hapticFeedbackIntensity
case inAppOrientationLock
case rotateToMatchAspectRatio
case preferPortraitBrowsing
// Home
case homeShortcutOrder
case homeShortcutVisibility
case homeShortcutLayout
case homeSectionOrder
case homeSectionVisibility
case homeSectionItemsLimit
// Tab Bar (compact size class)
case tabBarItemOrder
case tabBarItemVisibility
case tabBarStartupTab
// Sidebar
case sidebarMainItemOrder
case sidebarMainItemVisibility
case sidebarStartupTab
case sidebarSourcesEnabled
case sidebarSourceSort
case sidebarSourcesLimitEnabled
case sidebarMaxSources
case sidebarChannelsEnabled
case sidebarMaxChannels
case sidebarChannelSort
case sidebarChannelsLimitEnabled
case sidebarPlaylistsEnabled
case sidebarMaxPlaylists
case sidebarPlaylistSort
case sidebarPlaylistsLimitEnabled
// Remote Control
case remoteControlCustomDeviceName
case remoteControlHideWhenBackgrounded
// Advanced
case showAdvancedStreamDetails
case showPlayerAreaDebug
case verboseMPVLogging
case verboseRemoteControlLogging
case mpvBufferSeconds
case mpvUseEDLStreams
case zoomTransitionsEnabled
// Details panel
case floatingDetailsPanelSide // Landscape only - which side the panel appears on
case floatingDetailsPanelWidth // Resizable panel width in wide layout
case landscapeDetailsPanelVisible
case landscapeDetailsPanelPinned
// Player Controls
case activeControlsPresetID
// Video Swipe Actions
case videoSwipeActionOrder
case videoSwipeActionVisibility
// Onboarding
case onboardingCompleted
/// Whether this key should have platform-specific prefixes.
var isPlatformSpecific: Bool {
switch self {
case .preferredQuality, .cellularQuality, .macPlayerMode, .listStyle:
return true
default:
return false
}
}
/// Whether this key should only be stored locally (not synced to iCloud).
/// Used for device-specific settings like custom device name for remote control.
var isLocalOnly: Bool {
switch self {
case .remoteControlCustomDeviceName, .remoteControlHideWhenBackgrounded,
.activeControlsPresetID, // Per-device preset selection
.onboardingCompleted: // Per-device onboarding state
return true
default:
return false
}
}
}

View File

@@ -0,0 +1,298 @@
//
// SettingsManager+Advanced.swift
// Yattee
//
// Advanced settings: debug, MPV, floating panel.
//
import Foundation
import SwiftUI
extension SettingsManager {
// MARK: - Advanced Settings
/// Whether to show advanced stream details (codec, bitrate, size) in quality selector.
/// When disabled, only shows resolution/language and filters to best stream per resolution/language.
/// Default is false (simplified view).
var showAdvancedStreamDetails: Bool {
get {
if let cached = _showAdvancedStreamDetails { return cached }
return bool(for: .showAdvancedStreamDetails, default: false)
}
set {
_showAdvancedStreamDetails = newValue
set(newValue, for: .showAdvancedStreamDetails)
}
}
/// Whether to show player area debug overlays (frame borders, safe area values, layout info).
/// Useful for troubleshooting layout issues on different devices.
/// Default is false (hidden).
var showPlayerAreaDebug: Bool {
get {
if let cached = _showPlayerAreaDebug { return cached }
return bool(for: .showPlayerAreaDebug, default: false)
}
set {
_showPlayerAreaDebug = newValue
set(newValue, for: .showPlayerAreaDebug)
}
}
/// Whether verbose MPV rendering logging is enabled.
/// When enabled, logs detailed OpenGL context, framebuffer, and display link state
/// to help diagnose rendering issues. Default is false (disabled).
var verboseMPVLogging: Bool {
get {
if let cached = _verboseMPVLogging { return cached }
return bool(for: .verboseMPVLogging, default: false)
}
set {
_verboseMPVLogging = newValue
set(newValue, for: .verboseMPVLogging)
}
}
/// Whether verbose remote control logging is enabled.
/// When enabled, logs detailed discovery, connection, and message state
/// to help diagnose remote control issues. Default is false (disabled).
var verboseRemoteControlLogging: Bool {
get {
if let cached = _verboseRemoteControlLogging { return cached }
return bool(for: .verboseRemoteControlLogging, default: false)
}
set {
_verboseRemoteControlLogging = newValue
set(newValue, for: .verboseRemoteControlLogging)
}
}
/// Custom device name for remote control. When empty, uses system device name.
/// Allows users to set a custom name that appears to other devices on the network.
var remoteControlCustomDeviceName: String {
get {
if let cached = _remoteControlCustomDeviceName { return cached }
return string(for: .remoteControlCustomDeviceName) ?? ""
}
set {
_remoteControlCustomDeviceName = newValue
set(newValue, for: .remoteControlCustomDeviceName)
}
}
/// Whether to hide this device from remote control when app enters background.
/// When enabled, stops Bonjour advertising when backgrounded so device disappears
/// from other devices' lists. Default is true (hide when backgrounded).
/// Only applies to iOS and tvOS.
var remoteControlHideWhenBackgrounded: Bool {
get {
if let cached = _remoteControlHideWhenBackgrounded { return cached }
return bool(for: .remoteControlHideWhenBackgrounded, default: true)
}
set {
_remoteControlHideWhenBackgrounded = newValue
set(newValue, for: .remoteControlHideWhenBackgrounded)
}
}
/// Minimum buffer time in seconds before video playback starts.
/// Higher values reduce initial stuttering but increase startup delay.
/// Default is 3.0 seconds.
static let defaultMpvBufferSeconds: Double = 3.0
var mpvBufferSeconds: Double {
get {
if let cached = _mpvBufferSeconds { return cached }
let value = double(for: .mpvBufferSeconds)
return value > 0 ? value : Self.defaultMpvBufferSeconds
}
set {
_mpvBufferSeconds = newValue
set(newValue, for: .mpvBufferSeconds)
}
}
/// Whether to use EDL combined streams for separate video/audio.
/// When enabled, video and audio streams are combined into a single EDL URL
/// for unified caching and better A/V synchronization.
/// When disabled, falls back to loading video first then adding audio via audio-add.
/// Default is false (disabled) due to EDL demuxer issues with backward seeking.
var mpvUseEDLStreams: Bool {
get {
if let cached = _mpvUseEDLStreams { return cached }
return bool(for: .mpvUseEDLStreams, default: false)
}
set {
_mpvUseEDLStreams = newValue
set(newValue, for: .mpvUseEDLStreams)
}
}
/// Whether zoom navigation transitions are enabled (iOS only).
/// When enabled, navigating to video/channel/playlist details shows a zoom animation
/// from the source thumbnail. Disable if experiencing visual glitches with swipe-back gestures.
/// Default is true (enabled).
var zoomTransitionsEnabled: Bool {
get {
if let cached = _zoomTransitionsEnabled { return cached }
return bool(for: .zoomTransitionsEnabled, default: true)
}
set {
_zoomTransitionsEnabled = newValue
set(newValue, for: .zoomTransitionsEnabled)
}
}
// MARK: - Details Panel Settings
/// Which side the floating details panel appears on in landscape layout.
/// Default is right side.
var floatingDetailsPanelSide: FloatingPanelSide {
get {
if let cached = _floatingDetailsPanelSide { return cached }
return FloatingPanelSide(rawValue: string(for: .floatingDetailsPanelSide) ?? "") ?? .left
}
set {
_floatingDetailsPanelSide = newValue
set(newValue.rawValue, for: .floatingDetailsPanelSide)
}
}
/// Default panel width in wide layout.
static let defaultFloatingDetailsPanelWidth: CGFloat = 400
/// Width of the floating details panel in wide layout.
/// User can resize via drag gesture. Persisted across sessions.
var floatingDetailsPanelWidth: CGFloat {
get {
if let cached = _floatingDetailsPanelWidth { return cached }
let value = double(for: .floatingDetailsPanelWidth)
return value > 0 ? CGFloat(value) : Self.defaultFloatingDetailsPanelWidth
}
set {
_floatingDetailsPanelWidth = newValue
set(Double(newValue), for: .floatingDetailsPanelWidth)
}
}
/// Whether the details panel is visible in landscape layout.
/// Default is false (hidden). User must manually show panel.
var landscapeDetailsPanelVisible: Bool {
get {
if let cached = _landscapeDetailsPanelVisible { return cached }
return bool(for: .landscapeDetailsPanelVisible, default: false)
}
set {
_landscapeDetailsPanelVisible = newValue
set(newValue, for: .landscapeDetailsPanelVisible)
}
}
/// Whether the details panel is pinned in landscape layout.
/// Default is false (floating mode).
var landscapeDetailsPanelPinned: Bool {
get {
if let cached = _landscapeDetailsPanelPinned { return cached }
return bool(for: .landscapeDetailsPanelPinned, default: false)
}
set {
_landscapeDetailsPanelPinned = newValue
set(newValue, for: .landscapeDetailsPanelPinned)
}
}
// MARK: - Appearance Settings
/// List style for video list views.
/// Controls whether lists use inset grouped style (card background) or plain style.
/// Default is plain. Synced per-platform via iCloud.
var listStyle: VideoListStyle {
get {
if let cached = _listStyle { return cached }
guard let rawValue = string(for: .listStyle),
let style = VideoListStyle(rawValue: rawValue) else {
return .plain
}
_listStyle = style
return style
}
set {
_listStyle = newValue
set(newValue.rawValue, for: .listStyle)
}
}
// MARK: - Video Swipe Actions
#if !os(tvOS)
/// Order of video swipe actions. Actions appear in this order from left to right.
/// New actions are merged in at their default positions if not already present.
var videoSwipeActionOrder: [VideoSwipeAction] {
get {
if let cached = _videoSwipeActionOrder { return cached }
// Try to decode from storage
if let data = data(for: .videoSwipeActionOrder),
let decoded = try? JSONDecoder().decode([VideoSwipeAction].self, from: data) {
// Merge any new actions that might have been added in an update
var order = decoded
for action in VideoSwipeAction.allCases {
if !order.contains(action) {
order.append(action)
}
}
_videoSwipeActionOrder = order
return order
}
// Return default order with all actions
let order = VideoSwipeAction.allCases
_videoSwipeActionOrder = order
return order
}
set {
_videoSwipeActionOrder = newValue
if let data = try? JSONEncoder().encode(newValue) {
set(data, for: .videoSwipeActionOrder)
}
}
}
/// Visibility of each video swipe action. True = enabled, False = disabled.
var videoSwipeActionVisibility: [VideoSwipeAction: Bool] {
get {
if let cached = _videoSwipeActionVisibility { return cached }
// Try to decode from storage
if let data = data(for: .videoSwipeActionVisibility),
let decoded = try? JSONDecoder().decode([VideoSwipeAction: Bool].self, from: data) {
// Merge in defaults for any new actions
var visibility = decoded
for action in VideoSwipeAction.allCases {
if visibility[action] == nil {
visibility[action] = VideoSwipeAction.defaultVisibility[action] ?? false
}
}
_videoSwipeActionVisibility = visibility
return visibility
}
// Return default visibility
let visibility = VideoSwipeAction.defaultVisibility
_videoSwipeActionVisibility = visibility
return visibility
}
set {
_videoSwipeActionVisibility = newValue
if let data = try? JSONEncoder().encode(newValue) {
set(data, for: .videoSwipeActionVisibility)
}
}
}
/// Returns visible swipe actions in the configured order.
func visibleVideoSwipeActions() -> [VideoSwipeAction] {
videoSwipeActionOrder.filter { videoSwipeActionVisibility[$0] ?? false }
}
#endif
}

View File

@@ -0,0 +1,449 @@
//
// SettingsManager+CloudSync.swift
// Yattee
//
// iCloud sync settings and sync category toggles.
//
import Foundation
extension SettingsManager {
// MARK: - Home Visibility Sync Protection
/// Keys that require special handling during sync to preserve user customizations.
/// These settings should not be overwritten by default/stale values from iCloud.
private static let protectedVisibilityKeys: Set<SettingsKey> = [
.homeShortcutVisibility,
.homeSectionVisibility,
.homeShortcutOrder,
.homeSectionOrder
]
/// Checks if the given home shortcut visibility data represents user customization (differs from defaults).
private func homeShortcutVisibilityHasCustomization(_ data: Data?) -> Bool {
guard let data,
let visibility = try? JSONDecoder().decode([HomeShortcutItem: Bool].self, from: data) else {
return false
}
let defaults = HomeShortcutItem.defaultVisibility
// Check if any value differs from the default
for (item, isVisible) in visibility {
if let defaultValue = defaults[item], defaultValue != isVisible {
return true
}
}
// Also check if there are items in visibility that aren't in defaults (user added custom items)
for item in visibility.keys {
if defaults[item] == nil {
return true
}
}
return false
}
/// Checks if the given home section visibility data represents user customization (differs from defaults).
private func homeSectionVisibilityHasCustomization(_ data: Data?) -> Bool {
guard let data,
let visibility = try? JSONDecoder().decode([HomeSectionItem: Bool].self, from: data) else {
return false
}
let defaults = HomeSectionItem.defaultVisibility
// Check if any value differs from the default
for (item, isVisible) in visibility {
if let defaultValue = defaults[item], defaultValue != isVisible {
return true
}
}
// Also check if there are items in visibility that aren't in defaults (user added custom items)
for item in visibility.keys {
if defaults[item] == nil {
return true
}
}
return false
}
/// Determines whether local data should be preserved during iCloud sync using timestamp comparison.
/// Returns true if the local write is newer than the iCloud write, meaning we should keep local.
/// Falls back to customization-vs-defaults logic when no timestamps exist (migration path).
private func shouldPreserveLocal(for key: SettingsKey) -> Bool {
let tsKey = modifiedAtKey(for: key)
let localTimestamp = localDefaults.double(forKey: tsKey)
let iCloudTimestamp = ubiquitousStore.double(forKey: tsKey)
// If both have timestamps, compare them
if localTimestamp > 0 || iCloudTimestamp > 0 {
if localTimestamp > iCloudTimestamp {
LoggingService.shared.logCloudKit(
"Preserving local \(key.rawValue) - local timestamp \(localTimestamp) > iCloud \(iCloudTimestamp)"
)
return true
} else if iCloudTimestamp > localTimestamp {
LoggingService.shared.logCloudKit(
"Using iCloud \(key.rawValue) - iCloud timestamp \(iCloudTimestamp) > local \(localTimestamp)"
)
return false
}
// Equal timestamps - fall through to legacy logic
}
// Legacy fallback: no timestamps yet, use customization-vs-defaults comparison
// Only applicable for visibility keys that have customization detection
let pKey = platformKey(key)
let localData = localDefaults.data(forKey: pKey)
let iCloudData = ubiquitousStore.data(forKey: pKey)
let localHasCustomization: Bool
let iCloudHasCustomization: Bool
switch key {
case .homeShortcutVisibility:
localHasCustomization = homeShortcutVisibilityHasCustomization(localData)
iCloudHasCustomization = homeShortcutVisibilityHasCustomization(iCloudData)
case .homeSectionVisibility:
localHasCustomization = homeSectionVisibilityHasCustomization(localData)
iCloudHasCustomization = homeSectionVisibilityHasCustomization(iCloudData)
default:
// For order keys without timestamps, don't preserve (no way to compare)
return false
}
if localHasCustomization && !iCloudHasCustomization {
LoggingService.shared.logCloudKit(
"Preserving local \(key.rawValue) - local has customizations, iCloud has defaults (no timestamps)"
)
return true
}
if iCloudHasCustomization {
LoggingService.shared.logCloudKit(
"Using iCloud \(key.rawValue) - iCloud has user customizations (no timestamps)"
)
}
return false
}
/// Pushes local protected settings to iCloud when local was preserved.
/// Also pushes the companion _modifiedAt timestamps to keep them consistent.
private func pushLocalToiCloudForPreservedKeys(_ keysToPreserve: Set<SettingsKey>) {
for key in keysToPreserve {
let pKey = platformKey(key)
if let data = localDefaults.data(forKey: pKey) {
ubiquitousStore.set(data, forKey: pKey)
LoggingService.shared.logCloudKit(
"Pushed local \(key.rawValue) to iCloud (local was preserved)"
)
}
// Also push the timestamp so other devices see the correct modified time
let tsKey = modifiedAtKey(for: key)
let localTimestamp = localDefaults.double(forKey: tsKey)
if localTimestamp > 0 {
ubiquitousStore.set(localTimestamp, forKey: tsKey)
}
}
}
// MARK: - iCloud Sync Settings
/// Whether iCloud sync is enabled. When disabled, all data is stored locally only.
/// Default is false (disabled).
var iCloudSyncEnabled: Bool {
get {
if let cached = _iCloudSyncEnabled { return cached }
// Only check local defaults for this setting - it should not sync to iCloud
return localDefaults.bool(forKey: "iCloudSyncEnabled")
}
set {
_iCloudSyncEnabled = newValue
// Store only in local defaults - this setting should not sync
localDefaults.set(newValue, forKey: "iCloudSyncEnabled")
if newValue {
// When enabling, update last sync time
updateLastSyncTime()
}
}
}
/// The last time data was synced with iCloud.
var lastSyncTime: Date? {
get {
if let cached = _lastSyncTime { return cached }
return localDefaults.object(forKey: "lastSyncTime") as? Date
}
set {
_lastSyncTime = newValue
localDefaults.set(newValue, forKey: "lastSyncTime")
}
}
/// Updates the last sync time to now.
func updateLastSyncTime() {
lastSyncTime = Date()
}
// MARK: - iCloud Sync Category Toggles
/// Whether instances should be synced to iCloud. Default is true when iCloud sync is enabled.
var syncInstances: Bool {
get {
if let cached = _syncInstances { return cached }
// Default to true if not set (for backwards compatibility)
if localDefaults.object(forKey: "syncInstances") == nil {
return true
}
return localDefaults.bool(forKey: "syncInstances")
}
set {
_syncInstances = newValue
localDefaults.set(newValue, forKey: "syncInstances")
}
}
/// Whether subscriptions should be synced to iCloud. Default is true when iCloud sync is enabled.
var syncSubscriptions: Bool {
get {
if let cached = _syncSubscriptions { return cached }
if localDefaults.object(forKey: "syncSubscriptions") == nil {
return true
}
return localDefaults.bool(forKey: "syncSubscriptions")
}
set {
_syncSubscriptions = newValue
localDefaults.set(newValue, forKey: "syncSubscriptions")
}
}
/// Whether bookmarks should be synced to iCloud. Default is true when iCloud sync is enabled.
var syncBookmarks: Bool {
get {
if let cached = _syncBookmarks { return cached }
if localDefaults.object(forKey: "syncBookmarks") == nil {
return true
}
return localDefaults.bool(forKey: "syncBookmarks")
}
set {
_syncBookmarks = newValue
localDefaults.set(newValue, forKey: "syncBookmarks")
}
}
/// Whether playback history should be synced to iCloud. Default is true when iCloud sync is enabled.
var syncPlaybackHistory: Bool {
get {
if let cached = _syncPlaybackHistory { return cached }
if localDefaults.object(forKey: "syncPlaybackHistory") == nil {
return true
}
return localDefaults.bool(forKey: "syncPlaybackHistory")
}
set {
_syncPlaybackHistory = newValue
localDefaults.set(newValue, forKey: "syncPlaybackHistory")
}
}
/// Whether playlists should be synced to iCloud. Default is true when iCloud sync is enabled.
var syncPlaylists: Bool {
get {
if let cached = _syncPlaylists { return cached }
if localDefaults.object(forKey: "syncPlaylists") == nil {
return true
}
return localDefaults.bool(forKey: "syncPlaylists")
}
set {
_syncPlaylists = newValue
localDefaults.set(newValue, forKey: "syncPlaylists")
}
}
/// Whether settings should be synced to iCloud. Default is true when iCloud sync is enabled.
var syncSettings: Bool {
get {
if let cached = _syncSettings { return cached }
if localDefaults.object(forKey: "syncSettings") == nil {
return true
}
return localDefaults.bool(forKey: "syncSettings")
}
set {
_syncSettings = newValue
localDefaults.set(newValue, forKey: "syncSettings")
}
}
/// Whether media sources (WebDAV only) should be synced to iCloud. Default is true when iCloud sync is enabled.
/// Note: Local folder sources are never synced as they are device-specific.
var syncMediaSources: Bool {
get {
if let cached = _syncMediaSources { return cached }
if localDefaults.object(forKey: "syncMediaSources") == nil {
return true
}
return localDefaults.bool(forKey: "syncMediaSources")
}
set {
_syncMediaSources = newValue
localDefaults.set(newValue, forKey: "syncMediaSources")
}
}
/// Whether search history should be synced to iCloud. Default is true when iCloud sync is enabled.
var syncSearchHistory: Bool {
get {
if let cached = _syncSearchHistory { return cached }
if localDefaults.object(forKey: "syncSearchHistory") == nil {
return true
}
return localDefaults.bool(forKey: "syncSearchHistory")
}
set {
_syncSearchHistory = newValue
localDefaults.set(newValue, forKey: "syncSearchHistory")
}
}
/// Enables all sync categories. Called when enabling iCloud sync for the first time.
func enableAllSyncCategories() {
syncInstances = true
syncSubscriptions = true
syncBookmarks = true
syncPlaybackHistory = true
syncPlaylists = true
syncSettings = true
syncMediaSources = true
syncSearchHistory = true
}
// MARK: - Sync Operations
/// Syncs local settings to iCloud (called when enabling iCloud sync).
/// Only syncs if settings sync is enabled.
func syncToiCloud() {
guard syncSettings else { return }
// Copy all local settings to iCloud
for key in SettingsKey.allCases {
let pKey = platformKey(key)
if let value = localDefaults.object(forKey: pKey) {
ubiquitousStore.set(value, forKey: pKey)
}
}
ubiquitousStore.synchronize()
updateLastSyncTime()
}
/// Replaces local settings with iCloud data (called when enabling iCloud sync).
/// Only replaces if settings sync is enabled.
/// Protected settings are preserved if the local write is newer (timestamp-based).
func replaceWithiCloudData() {
guard syncSettings else { return }
ubiquitousStore.synchronize()
// Determine which protected keys to preserve before syncing
var keysToPreserve = Set<SettingsKey>()
for key in Self.protectedVisibilityKeys {
if shouldPreserveLocal(for: key) {
keysToPreserve.insert(key)
}
}
// Copy all iCloud settings to local defaults
for key in SettingsKey.allCases {
// Skip protected keys that should preserve local values
if keysToPreserve.contains(key) {
continue
}
let pKey = platformKey(key)
if let value = ubiquitousStore.object(forKey: pKey) {
localDefaults.set(value, forKey: pKey)
}
// Also copy companion timestamps for protected keys when accepting iCloud values
if Self.protectedVisibilityKeys.contains(key) {
let tsKey = modifiedAtKey(for: key)
let iCloudTimestamp = ubiquitousStore.double(forKey: tsKey)
if iCloudTimestamp > 0 {
localDefaults.set(iCloudTimestamp, forKey: tsKey)
}
}
}
// Push local values to iCloud for keys we preserved
if !keysToPreserve.isEmpty {
pushLocalToiCloudForPreservedKeys(keysToPreserve)
}
clearCache()
updateLastSyncTime()
}
/// Refreshes settings from iCloud by copying iCloud values to local storage.
/// When `changedKeys` is provided (from the notification), only those keys are synced.
/// Protected settings are preserved if the local write is newer (timestamp-based).
func refreshFromiCloud(changedKeys: Set<String>? = nil) {
guard syncSettings else { return }
// Determine which protected keys to preserve before syncing
var keysToPreserve = Set<SettingsKey>()
for key in Self.protectedVisibilityKeys {
if shouldPreserveLocal(for: key) {
keysToPreserve.insert(key)
}
}
// Copy settings from iCloud to local defaults
for key in SettingsKey.allCases {
// Skip local-only keys (device-specific settings that shouldn't sync)
if key.isLocalOnly {
continue
}
let pKey = platformKey(key)
// If we have a changed-keys set, skip keys that didn't change
if let changedKeys, !changedKeys.contains(pKey) {
continue
}
// Skip protected keys that should preserve local values
if keysToPreserve.contains(key) {
continue
}
if let value = ubiquitousStore.object(forKey: pKey) {
localDefaults.set(value, forKey: pKey)
}
// Also copy companion timestamps for protected keys when accepting iCloud values
if Self.protectedVisibilityKeys.contains(key) {
let tsKey = modifiedAtKey(for: key)
let iCloudTimestamp = ubiquitousStore.double(forKey: tsKey)
if iCloudTimestamp > 0 {
localDefaults.set(iCloudTimestamp, forKey: tsKey)
}
}
}
// Push local values to iCloud for keys we preserved
if !keysToPreserve.isEmpty {
pushLocalToiCloudForPreservedKeys(keysToPreserve)
}
// Clear caches to force re-read from local storage
clearCache()
}
}

View File

@@ -0,0 +1,90 @@
//
// SettingsManager+DeArrow.swift
// Yattee
//
// DeArrow and Return YouTube Dislike settings.
//
import Foundation
extension SettingsManager {
// MARK: - Return YouTube Dislike Settings
/// Whether Return YouTube Dislike is enabled. Default is false.
var returnYouTubeDislikeEnabled: Bool {
get {
if let cached = _returnYouTubeDislikeEnabled { return cached }
return bool(for: .returnYouTubeDislikeEnabled, default: false)
}
set {
_returnYouTubeDislikeEnabled = newValue
set(newValue, for: .returnYouTubeDislikeEnabled)
}
}
// MARK: - DeArrow Settings
/// The DeArrow API URL. Defaults to the official instance.
static let defaultDeArrowAPIURL = "https://sponsor.ajay.app"
/// The DeArrow thumbnail generation service URL. Defaults to the official instance.
static let defaultDeArrowThumbnailAPIURL = "https://dearrow-thumb.ajay.app"
/// Whether DeArrow is enabled. Default is false.
var deArrowEnabled: Bool {
get {
if let cached = _deArrowEnabled { return cached }
return bool(for: .deArrowEnabled, default: false)
}
set {
_deArrowEnabled = newValue
set(newValue, for: .deArrowEnabled)
}
}
/// Whether DeArrow should replace video titles. Default is true when DeArrow is enabled.
var deArrowReplaceTitles: Bool {
get {
if let cached = _deArrowReplaceTitles { return cached }
return bool(for: .deArrowReplaceTitles, default: true)
}
set {
_deArrowReplaceTitles = newValue
set(newValue, for: .deArrowReplaceTitles)
}
}
/// Whether DeArrow should replace video thumbnails. Default is true when DeArrow is enabled.
var deArrowReplaceThumbnails: Bool {
get {
if let cached = _deArrowReplaceThumbnails { return cached }
return bool(for: .deArrowReplaceThumbnails, default: true)
}
set {
_deArrowReplaceThumbnails = newValue
set(newValue, for: .deArrowReplaceThumbnails)
}
}
var deArrowAPIURL: String {
get {
if let cached = _deArrowAPIURL { return cached }
return string(for: .deArrowAPIURL) ?? Self.defaultDeArrowAPIURL
}
set {
_deArrowAPIURL = newValue
set(newValue, for: .deArrowAPIURL)
}
}
var deArrowThumbnailAPIURL: String {
get {
if let cached = _deArrowThumbnailAPIURL { return cached }
return string(for: .deArrowThumbnailAPIURL) ?? Self.defaultDeArrowThumbnailAPIURL
}
set {
_deArrowThumbnailAPIURL = newValue
set(newValue, for: .deArrowThumbnailAPIURL)
}
}
}

View File

@@ -0,0 +1,517 @@
//
// SettingsManager+General.swift
// Yattee
//
// General settings: theme, feed, queue, notifications, privacy, user agent, links.
//
import Foundation
#if os(iOS)
import UIKit
#endif
extension SettingsManager {
// MARK: - Theme Settings
var theme: AppTheme {
get {
if let cached = _theme { return cached }
return AppTheme(rawValue: string(for: .theme) ?? "") ?? .system
}
set {
_theme = newValue
set(newValue.rawValue, for: .theme)
}
}
var accentColor: AccentColor {
get {
if let cached = _accentColor { return cached }
return AccentColor(rawValue: string(for: .accentColor) ?? "") ?? .default
}
set {
_accentColor = newValue
set(newValue.rawValue, for: .accentColor)
}
}
// MARK: - App Icon Settings (iOS only)
#if os(iOS)
var appIcon: AppIcon {
get {
if let cached = _appIcon { return cached }
guard let rawValue = localDefaults.string(forKey: "appIcon"),
let icon = AppIcon(rawValue: rawValue) else {
return .default
}
return icon
}
set {
_appIcon = newValue
localDefaults.set(newValue.rawValue, forKey: "appIcon")
// Apply the icon change
Task { @MainActor in
do {
try await UIApplication.shared.setAlternateIconName(newValue.alternateIconName)
} catch {
LoggingService.shared.error("Failed to set alternate icon: \(error)", category: .general)
}
}
}
}
#endif
/// Whether to show a checkmark badge on fully watched video thumbnails.
/// Default is true (enabled).
var showWatchedCheckmark: Bool {
get {
if let cached = _showWatchedCheckmark { return cached }
return bool(for: .showWatchedCheckmark, default: true)
}
set {
_showWatchedCheckmark = newValue
set(newValue, for: .showWatchedCheckmark)
}
}
// MARK: - Feed Settings
/// Feed cache validity duration in minutes. Default is 30 minutes.
static let defaultFeedCacheValidityMinutes = 30
var feedCacheValidityMinutes: Int {
get {
if let cached = _feedCacheValidityMinutes { return cached }
return integer(for: .feedCacheValidityMinutes, default: Self.defaultFeedCacheValidityMinutes)
}
set {
_feedCacheValidityMinutes = newValue
set(newValue, for: .feedCacheValidityMinutes)
}
}
/// Feed cache validity duration in seconds (computed from minutes).
var feedCacheValiditySeconds: TimeInterval {
TimeInterval(feedCacheValidityMinutes * 60)
}
// MARK: - Custom User-Agent
/// Generates a new random User-Agent string.
static func generateRandomUserAgent() -> String {
UserAgentGenerator.generateRandom()
}
/// Returns the current effective User-Agent string.
/// If randomize per request is enabled, generates a new random UA.
/// Otherwise, returns the stored custom user agent.
/// This is nonisolated so it can be called from any context.
nonisolated static func currentUserAgent() -> String {
let defaults = UserDefaults.standard
if defaults.bool(forKey: "randomizeUserAgentPerRequest") {
return UserAgentGenerator.generateRandom()
}
return defaults.string(forKey: "customUserAgent") ?? UserAgentGenerator.defaultUserAgent
}
/// The custom User-Agent string used for all HTTP requests.
/// This setting is stored locally only and not synced to iCloud.
var customUserAgent: String {
get {
if let cached = _customUserAgent { return cached }
// Only read from local defaults - never sync this setting
return localDefaults.string(forKey: "customUserAgent") ?? UserAgentGenerator.defaultUserAgent
}
set {
_customUserAgent = newValue
// Store only in local defaults - this setting should not sync
localDefaults.set(newValue, forKey: "customUserAgent")
}
}
/// Randomizes the custom User-Agent to a new random value.
func randomizeUserAgent() {
customUserAgent = UserAgentGenerator.generateRandom()
}
/// Whether to generate a new random User-Agent for each HTTP request.
/// When enabled, customUserAgent is ignored and a fresh random UA is used per request.
/// This setting is stored locally only and not synced to iCloud.
var randomizeUserAgentPerRequest: Bool {
get {
if let cached = _randomizeUserAgentPerRequest { return cached }
return localDefaults.bool(forKey: "randomizeUserAgentPerRequest")
}
set {
_randomizeUserAgentPerRequest = newValue
localDefaults.set(newValue, forKey: "randomizeUserAgentPerRequest")
}
}
// MARK: - Queue Settings
/// Whether the queue feature is enabled. Default is true.
/// When disabled, tapping videos plays them directly without queue options.
var queueEnabled: Bool {
get {
if let cached = _queueEnabled { return cached }
// Default to true if not set
let value: Bool
if localDefaults.object(forKey: "queueEnabled") == nil {
value = true
} else {
value = localDefaults.bool(forKey: "queueEnabled")
}
_queueEnabled = value // Cache on first read
return value
}
set {
_queueEnabled = newValue
localDefaults.set(newValue, forKey: "queueEnabled")
}
}
/// Whether auto-play next video in queue is enabled. Default is true.
/// When enabled, the next video in queue plays automatically when current video ends.
var queueAutoPlayNext: Bool {
get {
if let cached = _queueAutoPlayNext { return cached }
// Default to true if not set
if localDefaults.object(forKey: "queueAutoPlayNext") == nil {
return true
}
return localDefaults.bool(forKey: "queueAutoPlayNext")
}
set {
_queueAutoPlayNext = newValue
localDefaults.set(newValue, forKey: "queueAutoPlayNext")
}
}
/// Countdown duration in seconds before auto-playing next video. Default is 5.
/// Range: 1-15 seconds.
var queueAutoPlayCountdown: Int {
get {
if let cached = _queueAutoPlayCountdown { return cached }
// Default to 5 if not set
if localDefaults.object(forKey: "queueAutoPlayCountdown") == nil {
return 5
}
return localDefaults.integer(forKey: "queueAutoPlayCountdown")
}
set {
// Clamp to valid range
let clamped = max(1, min(15, newValue))
_queueAutoPlayCountdown = clamped
localDefaults.set(clamped, forKey: "queueAutoPlayCountdown")
}
}
// MARK: - Subscription Account Settings
/// The subscription account storage key.
private static let subscriptionAccountKey = "subscriptionAccount"
/// The active subscription account configuration.
/// Determines where subscriptions are stored and fetched from.
/// Defaults to local (iCloud) if not set.
var subscriptionAccount: SubscriptionAccount {
get {
if let cached = _subscriptionAccount { return cached }
guard let data = localDefaults.data(forKey: Self.subscriptionAccountKey),
let account = try? JSONDecoder().decode(SubscriptionAccount.self, from: data) else {
return .local
}
return account
}
set {
_subscriptionAccount = newValue
if let data = try? JSONEncoder().encode(newValue) {
localDefaults.set(data, forKey: Self.subscriptionAccountKey)
// Sync to iCloud if enabled
if iCloudSyncEnabled && syncSettings {
ubiquitousStore.set(data, forKey: Self.subscriptionAccountKey)
}
}
}
}
// MARK: - Notification Settings
/// Whether background notifications are enabled. Default is false.
/// When enabled, the app will periodically check for new videos in the background
/// and send local notifications for channels with notifications enabled.
var backgroundNotificationsEnabled: Bool {
get {
if let cached = _backgroundNotificationsEnabled { return cached }
return localDefaults.bool(forKey: "backgroundNotificationsEnabled")
}
set {
_backgroundNotificationsEnabled = newValue
localDefaults.set(newValue, forKey: "backgroundNotificationsEnabled")
}
}
/// Whether to detect video URLs from clipboard when app becomes active.
/// Only applies to external site URLs (not YouTube). Default is false.
var clipboardURLDetectionEnabled: Bool {
get {
if let cached = _clipboardURLDetectionEnabled { return cached }
// Default to false
let value = localDefaults.object(forKey: "clipboardURLDetectionEnabled") as? Bool
return value ?? false
}
set {
_clipboardURLDetectionEnabled = newValue
localDefaults.set(newValue, forKey: "clipboardURLDetectionEnabled")
}
}
/// Default notification state for newly subscribed channels. Default is false.
/// When true, new subscriptions will have notifications enabled by default.
var defaultNotificationsForNewChannels: Bool {
get {
if let cached = _defaultNotificationsForNewChannels { return cached }
return localDefaults.bool(forKey: "defaultNotificationsForNewChannels")
}
set {
_defaultNotificationsForNewChannels = newValue
localDefaults.set(newValue, forKey: "defaultNotificationsForNewChannels")
}
}
/// The last time background notification check was performed.
/// This is separate from the main feed cache's lastUpdated timestamp.
var lastBackgroundCheck: Date? {
get {
if let cached = _lastBackgroundCheck { return cached }
return localDefaults.object(forKey: "lastBackgroundCheck") as? Date
}
set {
_lastBackgroundCheck = newValue
localDefaults.set(newValue, forKey: "lastBackgroundCheck")
}
}
/// Last notified video ID per channel (keyed by channel ID).
/// Used to prevent duplicate notifications for the same video.
/// Not cached since it's only accessed during infrequent background refreshes.
var lastNotifiedVideoPerChannel: [String: String] {
get {
localDefaults.dictionary(forKey: "lastNotifiedVideoPerChannel") as? [String: String] ?? [:]
}
set {
localDefaults.set(newValue, forKey: "lastNotifiedVideoPerChannel")
}
}
// MARK: - Privacy Settings
/// Whether incognito mode is enabled. When enabled, watch history is not recorded.
/// This is a local-only setting (not synced to iCloud). Default is false.
var incognitoModeEnabled: Bool {
get {
if let cached = _incognitoModeEnabled { return cached }
let value = localDefaults.object(forKey: "incognitoModeEnabled") as? Bool
return value ?? false
}
set {
_incognitoModeEnabled = newValue
localDefaults.set(newValue, forKey: "incognitoModeEnabled")
}
}
/// Number of days after which watch history entries are automatically deleted.
/// Set to 0 to disable auto-deletion. Default is 90 days.
static let defaultHistoryRetentionDays = 90
var historyRetentionDays: Int {
get {
if let cached = _historyRetentionDays { return cached }
if localDefaults.object(forKey: "historyRetentionDays") == nil {
return Self.defaultHistoryRetentionDays
}
return localDefaults.integer(forKey: "historyRetentionDays")
}
set {
_historyRetentionDays = newValue
localDefaults.set(newValue, forKey: "historyRetentionDays")
}
}
/// Whether to save watch history entries. Default is true.
/// When disabled, new watch history entries won't be saved. Existing entries remain visible.
/// Incognito mode overrides this setting.
var saveWatchHistory: Bool {
get {
if let cached = _saveWatchHistory { return cached }
if localDefaults.object(forKey: "saveWatchHistory") == nil {
return true
}
let value = localDefaults.bool(forKey: "saveWatchHistory")
_saveWatchHistory = value
return value
}
set {
_saveWatchHistory = newValue
localDefaults.set(newValue, forKey: "saveWatchHistory")
}
}
/// Whether to save recent search queries. Default is true.
/// When disabled, new search queries won't be saved. Existing entries remain visible.
/// Incognito mode overrides this setting.
var saveRecentSearches: Bool {
get {
if let cached = _saveRecentSearches { return cached }
if localDefaults.object(forKey: "saveRecentSearches") == nil {
return true
}
let value = localDefaults.bool(forKey: "saveRecentSearches")
_saveRecentSearches = value
return value
}
set {
_saveRecentSearches = newValue
localDefaults.set(newValue, forKey: "saveRecentSearches")
}
}
/// Whether to save recently visited channels. Default is true.
/// When disabled, new channel visits won't be saved. Existing entries remain visible.
/// Incognito mode overrides this setting.
var saveRecentChannels: Bool {
get {
if let cached = _saveRecentChannels { return cached }
if localDefaults.object(forKey: "saveRecentChannels") == nil {
return true
}
let value = localDefaults.bool(forKey: "saveRecentChannels")
_saveRecentChannels = value
return value
}
set {
_saveRecentChannels = newValue
localDefaults.set(newValue, forKey: "saveRecentChannels")
}
}
/// Whether to save recently visited playlists. Default is true.
/// When disabled, new playlist visits won't be saved. Existing entries remain visible.
/// Incognito mode overrides this setting.
var saveRecentPlaylists: Bool {
get {
if let cached = _saveRecentPlaylists { return cached }
if localDefaults.object(forKey: "saveRecentPlaylists") == nil {
return true
}
let value = localDefaults.bool(forKey: "saveRecentPlaylists")
_saveRecentPlaylists = value
return value
}
set {
_saveRecentPlaylists = newValue
localDefaults.set(newValue, forKey: "saveRecentPlaylists")
}
}
/// Number of search queries to keep in history. Default is 25.
var searchHistoryLimit: Int {
get {
if let cached = _searchHistoryLimit { return cached }
let value = localDefaults.integer(forKey: "searchHistoryLimit")
return value > 0 ? value : 25 // Default to 25
}
set {
_searchHistoryLimit = newValue
localDefaults.set(newValue, forKey: "searchHistoryLimit")
}
}
// MARK: - Handoff Settings
/// Whether Apple Handoff is enabled on this device. Default is false.
/// When enabled, the app broadcasts its current activity for continuation on other devices.
/// This is a local-only setting (not synced to iCloud).
var handoffEnabled: Bool {
get {
if let cached = _handoffEnabled { return cached }
// Default to false if not set
if localDefaults.object(forKey: "handoffEnabled") == nil {
return false
}
return localDefaults.bool(forKey: "handoffEnabled")
}
set {
_handoffEnabled = newValue
localDefaults.set(newValue, forKey: "handoffEnabled")
}
}
// MARK: - Link Action Settings
/// Default action when opening links from share extension or URL schemes.
/// Options: Open (play), Download, Ask every time. Default is "open".
/// This is a local-only setting (not synced to iCloud).
var defaultLinkAction: DefaultLinkAction {
get {
if let cached = _defaultLinkAction { return cached }
guard let rawValue = localDefaults.string(forKey: "defaultLinkAction"),
let action = DefaultLinkAction(rawValue: rawValue) else {
return .open
}
return action
}
set {
_defaultLinkAction = newValue
localDefaults.set(newValue.rawValue, forKey: "defaultLinkAction")
}
}
// MARK: - Video Tap Actions (iOS/macOS only)
#if !os(tvOS)
/// Action to perform when tapping on video thumbnails. Default is playVideo.
var thumbnailTapAction: VideoTapAction {
get {
if let cached = _thumbnailTapAction { return cached }
guard let rawValue = localDefaults.string(forKey: "thumbnailTapAction"),
let action = VideoTapAction(rawValue: rawValue) else {
return .playVideo
}
return action
}
set {
_thumbnailTapAction = newValue
localDefaults.set(newValue.rawValue, forKey: "thumbnailTapAction")
}
}
/// Action to perform when tapping on video text area (title/author/metadata). Default is openInfo.
var textAreaTapAction: VideoTapAction {
get {
if let cached = _textAreaTapAction { return cached }
guard let rawValue = localDefaults.string(forKey: "textAreaTapAction"),
let action = VideoTapAction(rawValue: rawValue) else {
return .openInfo
}
return action
}
set {
_textAreaTapAction = newValue
localDefaults.set(newValue.rawValue, forKey: "textAreaTapAction")
}
}
#endif
// MARK: - Onboarding
/// Whether onboarding has been completed on this device.
/// This is a local-only setting (not synced to iCloud) so each device shows onboarding once.
var onboardingCompleted: Bool {
get { localDefaults.bool(forKey: SettingsKey.onboardingCompleted.rawValue) }
set { localDefaults.set(newValue, forKey: SettingsKey.onboardingCompleted.rawValue) }
}
}

View File

@@ -0,0 +1,71 @@
//
// SettingsManager+Haptics.swift
// Yattee
//
// Haptic feedback settings (iOS only).
//
#if os(iOS)
import CoreHaptics
import Foundation
import UIKit
extension SettingsManager {
// MARK: - Haptic Feedback Settings
/// Whether haptic feedback is enabled. Default is true.
var hapticFeedbackEnabled: Bool {
get {
if let cached = _hapticFeedbackEnabled { return cached }
return bool(for: .hapticFeedbackEnabled, default: true)
}
set {
_hapticFeedbackEnabled = newValue
set(newValue, for: .hapticFeedbackEnabled)
}
}
/// Haptic feedback intensity. Default is light.
var hapticFeedbackIntensity: HapticFeedbackIntensity {
get {
if let cached = _hapticFeedbackIntensity { return cached }
return HapticFeedbackIntensity(rawValue: string(for: .hapticFeedbackIntensity) ?? "") ?? .light
}
set {
_hapticFeedbackIntensity = newValue
set(newValue.rawValue, for: .hapticFeedbackIntensity)
}
}
/// Whether the device supports haptic feedback.
static var deviceSupportsHaptics: Bool {
CHHapticEngine.capabilitiesForHardware().supportsHaptics
}
/// Triggers haptic feedback for the specified event if enabled.
func triggerHapticFeedback(for event: HapticEvent) {
guard Self.deviceSupportsHaptics else { return }
guard hapticFeedbackEnabled else { return }
// Determine style - some events override intensity
let style: UIImpactFeedbackGenerator.FeedbackStyle
switch event {
case .seekGestureActivation:
style = .light // Always light for activation
case .seekGestureBoundary:
style = .medium // Always medium for boundary
default:
switch hapticFeedbackIntensity {
case .off: return
case .light: style = .light
case .medium: style = .medium
case .heavy: style = .heavy
}
}
let generator = UIImpactFeedbackGenerator(style: style)
generator.prepare()
generator.impactOccurred()
}
}
#endif

View File

@@ -0,0 +1,971 @@
//
// SettingsManager+Home.swift
// Yattee
//
// Home, tab bar, and sidebar settings and management functions.
//
import Foundation
extension SettingsManager {
// MARK: - Home Shortcut Settings
/// Ordered list of home shortcuts. Default order is playlists, history, downloads.
var homeShortcutOrder: [HomeShortcutItem] {
get {
if let cached = _homeShortcutOrder { return cached }
guard let data = data(for: .homeShortcutOrder),
let savedOrder = try? JSONDecoder().decode([HomeShortcutItem].self, from: data) else {
return HomeShortcutItem.defaultOrder
}
// Merge saved order with default order to include any new items
var mergedOrder = savedOrder
for item in HomeShortcutItem.defaultOrder {
if !mergedOrder.contains(item) {
// Insert new items at their default position
if let defaultIndex = HomeShortcutItem.defaultOrder.firstIndex(of: item) {
let insertIndex = min(defaultIndex, mergedOrder.count)
mergedOrder.insert(item, at: insertIndex)
} else {
mergedOrder.append(item)
}
}
}
return mergedOrder
}
set {
_homeShortcutOrder = newValue
if let data = try? JSONEncoder().encode(newValue) {
set(data, for: .homeShortcutOrder)
}
let tsKey = modifiedAtKey(for: .homeShortcutOrder)
let now = Date().timeIntervalSince1970
localDefaults.set(now, forKey: tsKey)
if iCloudSyncEnabled && syncSettings && !isInitialSyncPending {
ubiquitousStore.set(now, forKey: tsKey)
}
}
}
/// Visibility map for home shortcuts. Default is all visible.
var homeShortcutVisibility: [HomeShortcutItem: Bool] {
get {
if let cached = _homeShortcutVisibility { return cached }
guard let data = data(for: .homeShortcutVisibility),
let savedVisibility = try? JSONDecoder().decode([HomeShortcutItem: Bool].self, from: data) else {
return HomeShortcutItem.defaultVisibility
}
// Merge saved visibility with defaults for any new items
var mergedVisibility = savedVisibility
for (item, defaultValue) in HomeShortcutItem.defaultVisibility {
if mergedVisibility[item] == nil {
mergedVisibility[item] = defaultValue
}
}
return mergedVisibility
}
set {
_homeShortcutVisibility = newValue
if let data = try? JSONEncoder().encode(newValue) {
set(data, for: .homeShortcutVisibility)
}
let tsKey = modifiedAtKey(for: .homeShortcutVisibility)
let now = Date().timeIntervalSince1970
localDefaults.set(now, forKey: tsKey)
if iCloudSyncEnabled && syncSettings && !isInitialSyncPending {
ubiquitousStore.set(now, forKey: tsKey)
}
}
}
/// Layout mode for home shortcuts (list or cards). Default is cards.
var homeShortcutLayout: HomeShortcutLayout {
get {
if let cached = _homeShortcutLayout { return cached }
guard let rawValue = string(for: .homeShortcutLayout) else {
return .cards
}
return HomeShortcutLayout(rawValue: rawValue) ?? .cards
}
set {
_homeShortcutLayout = newValue
set(newValue.rawValue, for: .homeShortcutLayout)
}
}
// MARK: - Home Section Settings
/// Ordered list of home sections. Default order is bookmarks, history, downloads.
var homeSectionOrder: [HomeSectionItem] {
get {
if let cached = _homeSectionOrder { return cached }
guard let data = data(for: .homeSectionOrder),
let savedOrder = try? JSONDecoder().decode([HomeSectionItem].self, from: data) else {
return HomeSectionItem.defaultOrder
}
// Merge saved order with default order to include any new items
var mergedOrder = savedOrder
for item in HomeSectionItem.defaultOrder {
if !mergedOrder.contains(item) {
// Insert new items at their default position
if let defaultIndex = HomeSectionItem.defaultOrder.firstIndex(of: item) {
let insertIndex = min(defaultIndex, mergedOrder.count)
mergedOrder.insert(item, at: insertIndex)
} else {
mergedOrder.append(item)
}
}
}
return mergedOrder
}
set {
_homeSectionOrder = newValue
if let data = try? JSONEncoder().encode(newValue) {
set(data, for: .homeSectionOrder)
}
let tsKey = modifiedAtKey(for: .homeSectionOrder)
let now = Date().timeIntervalSince1970
localDefaults.set(now, forKey: tsKey)
if iCloudSyncEnabled && syncSettings && !isInitialSyncPending {
ubiquitousStore.set(now, forKey: tsKey)
}
}
}
/// Visibility map for home sections. Default is bookmarks and history visible, downloads hidden.
var homeSectionVisibility: [HomeSectionItem: Bool] {
get {
if let cached = _homeSectionVisibility { return cached }
guard let data = data(for: .homeSectionVisibility),
let savedVisibility = try? JSONDecoder().decode([HomeSectionItem: Bool].self, from: data) else {
return HomeSectionItem.defaultVisibility
}
// Merge saved visibility with defaults for any new items
var mergedVisibility = savedVisibility
for (item, defaultValue) in HomeSectionItem.defaultVisibility {
if mergedVisibility[item] == nil {
mergedVisibility[item] = defaultValue
}
}
return mergedVisibility
}
set {
_homeSectionVisibility = newValue
if let data = try? JSONEncoder().encode(newValue) {
set(data, for: .homeSectionVisibility)
}
let tsKey = modifiedAtKey(for: .homeSectionVisibility)
let now = Date().timeIntervalSince1970
localDefaults.set(now, forKey: tsKey)
if iCloudSyncEnabled && syncSettings && !isInitialSyncPending {
ubiquitousStore.set(now, forKey: tsKey)
}
}
}
/// Number of items to show in each home section. Default is 5.
static let defaultHomeSectionItemsLimit = 5
var homeSectionItemsLimit: Int {
get {
if let cached = _homeSectionItemsLimit { return cached }
return integer(for: .homeSectionItemsLimit, default: Self.defaultHomeSectionItemsLimit)
}
set {
_homeSectionItemsLimit = newValue
set(newValue, for: .homeSectionItemsLimit)
}
}
/// Returns visible shortcuts in their configured order.
func visibleShortcuts() -> [HomeShortcutItem] {
let visibility = homeShortcutVisibility
return homeShortcutOrder.filter { visibility[$0] ?? true }
}
/// Returns visible sections in their configured order.
func visibleSections() -> [HomeSectionItem] {
let visibility = homeSectionVisibility
return homeSectionOrder.filter { visibility[$0] ?? false }
}
// MARK: - Home Instance Items Management
/// Adds an instance content item to Home as a card or section.
func addToHome(instanceID: UUID, contentType: InstanceContentType, asCard: Bool) {
if asCard {
// Add to cards
let newCard = HomeShortcutItem.instanceContent(instanceID: instanceID, contentType: contentType)
var order = homeShortcutOrder
if !order.contains(where: { $0.id == newCard.id }) {
order.append(newCard)
homeShortcutOrder = order
}
// Set visible by default
var visibility = homeShortcutVisibility
visibility[newCard] = true
homeShortcutVisibility = visibility
} else {
// Add to sections
let newSection = HomeSectionItem.instanceContent(instanceID: instanceID, contentType: contentType)
var order = homeSectionOrder
if !order.contains(where: { $0.id == newSection.id }) {
order.append(newSection)
homeSectionOrder = order
}
// Set visible by default
var visibility = homeSectionVisibility
visibility[newSection] = true
homeSectionVisibility = visibility
}
}
/// Removes an instance content item from Home (both cards and sections).
func removeFromHome(instanceID: UUID, contentType: InstanceContentType) {
// Remove from cards
var cardOrder = homeShortcutOrder
cardOrder.removeAll { item in
if case .instanceContent(let id, let type) = item {
return id == instanceID && type == contentType
}
return false
}
homeShortcutOrder = cardOrder
// Remove from card visibility
var cardVis = homeShortcutVisibility
cardVis.removeValue(forKey: .instanceContent(instanceID: instanceID, contentType: contentType))
homeShortcutVisibility = cardVis
// Remove from sections
var sectionOrder = homeSectionOrder
sectionOrder.removeAll { item in
if case .instanceContent(let id, let type) = item {
return id == instanceID && type == contentType
}
return false
}
homeSectionOrder = sectionOrder
// Remove from section visibility
var sectionVis = homeSectionVisibility
sectionVis.removeValue(forKey: .instanceContent(instanceID: instanceID, contentType: contentType))
homeSectionVisibility = sectionVis
}
/// Checks if an instance content item is in Home (either as card or section).
func isInHome(instanceID: UUID, contentType: InstanceContentType) -> (inCards: Bool, inSections: Bool) {
let inCards = homeShortcutOrder.contains { item in
if case .instanceContent(let id, let type) = item {
return id == instanceID && type == contentType
}
return false
}
let inSections = homeSectionOrder.contains { item in
if case .instanceContent(let id, let type) = item {
return id == instanceID && type == contentType
}
return false
}
return (inCards, inSections)
}
/// Removes all Home items for instances that no longer exist.
func cleanupOrphanedHomeInstanceItems(validInstanceIDs: Set<UUID>) {
// Collect orphaned instance IDs for cache cleanup
var orphanedInstanceIDs = Set<UUID>()
// Clean up cards - only write if items were actually removed
var cardOrder = homeShortcutOrder
let originalCardCount = cardOrder.count
cardOrder.removeAll { item in
if case .instanceContent(let instanceID, _) = item {
if !validInstanceIDs.contains(instanceID) {
orphanedInstanceIDs.insert(instanceID)
return true
}
}
return false
}
if cardOrder.count != originalCardCount {
LoggingService.shared.logCloudKit("cleanupOrphanedHomeInstanceItems: removed \(originalCardCount - cardOrder.count) orphaned cards")
homeShortcutOrder = cardOrder
}
// Clean up card visibility - only write if orphaned keys found
var cardVis = homeShortcutVisibility
let orphanedCardKeys = cardVis.keys.filter { item in
if case .instanceContent(let instanceID, _) = item {
if !validInstanceIDs.contains(instanceID) {
orphanedInstanceIDs.insert(instanceID)
return true
}
}
return false
}
if !orphanedCardKeys.isEmpty {
LoggingService.shared.logCloudKit("cleanupOrphanedHomeInstanceItems: removed \(orphanedCardKeys.count) orphaned card visibility entries")
for key in orphanedCardKeys {
cardVis.removeValue(forKey: key)
}
homeShortcutVisibility = cardVis
}
// Clean up sections - only write if items were actually removed
var sectionOrder = homeSectionOrder
let originalSectionCount = sectionOrder.count
sectionOrder.removeAll { item in
if case .instanceContent(let instanceID, _) = item {
if !validInstanceIDs.contains(instanceID) {
orphanedInstanceIDs.insert(instanceID)
return true
}
}
return false
}
if sectionOrder.count != originalSectionCount {
LoggingService.shared.logCloudKit("cleanupOrphanedHomeInstanceItems: removed \(originalSectionCount - sectionOrder.count) orphaned sections")
homeSectionOrder = sectionOrder
}
// Clean up section visibility - only write if orphaned keys found
var sectionVis = homeSectionVisibility
let orphanedSectionKeys = sectionVis.keys.filter { item in
if case .instanceContent(let instanceID, _) = item {
if !validInstanceIDs.contains(instanceID) {
orphanedInstanceIDs.insert(instanceID)
return true
}
}
return false
}
if !orphanedSectionKeys.isEmpty {
LoggingService.shared.logCloudKit("cleanupOrphanedHomeInstanceItems: removed \(orphanedSectionKeys.count) orphaned section visibility entries")
for key in orphanedSectionKeys {
sectionVis.removeValue(forKey: key)
}
homeSectionVisibility = sectionVis
}
// Clear cache for orphaned instances
for instanceID in orphanedInstanceIDs {
HomeInstanceCache.shared.clearAllForInstance(instanceID)
}
if orphanedInstanceIDs.isEmpty {
LoggingService.shared.logCloudKit("cleanupOrphanedHomeInstanceItems: no orphans found, skipped all writes")
}
}
/// Returns available content types for an instance.
/// Feed is always included for instances that support it, even if user is not logged in.
/// The UI will disable the toggle when not logged in.
func availableContentTypes(for instance: Instance) -> [InstanceContentType] {
var types: [InstanceContentType] = [.popular, .trending]
// Always add Feed for instances that support it (Invidious)
// Toggle will be disabled in UI if not logged in
if instance.supportsFeed {
types.insert(.feed, at: 0) // Feed first
}
return types
}
/// Returns all available card items for an instance that are NOT already added.
func availableShortcuts(for instance: Instance) -> [HomeShortcutItem] {
let contentTypes = availableContentTypes(for: instance)
let existingCards = Set(homeShortcutOrder.map { $0.id })
return contentTypes.compactMap { contentType in
let card = HomeShortcutItem.instanceContent(instanceID: instance.id, contentType: contentType)
return existingCards.contains(card.id) ? nil : card
}
}
/// Returns all available section items for an instance that are NOT already added.
func availableSections(for instance: Instance) -> [HomeSectionItem] {
let contentTypes = availableContentTypes(for: instance)
let existingSections = Set(homeSectionOrder.map { $0.id })
return contentTypes.compactMap { contentType in
let section = HomeSectionItem.instanceContent(instanceID: instance.id, contentType: contentType)
return existingSections.contains(section.id) ? nil : section
}
}
/// Returns all available cards across all instances, grouped by instance.
func allAvailableShortcuts(instances: [Instance]) -> [(instance: Instance, cards: [HomeShortcutItem])] {
instances.compactMap { instance in
let cards = availableShortcuts(for: instance)
return cards.isEmpty ? nil : (instance, cards)
}
}
/// Returns all available sections across all instances, grouped by instance.
func allAvailableSections(instances: [Instance]) -> [(instance: Instance, sections: [HomeSectionItem])] {
instances.compactMap { instance in
let sections = availableSections(for: instance)
return sections.isEmpty ? nil : (instance, sections)
}
}
// MARK: - Home Media Source Items Management
/// Adds a media source to Home as a card or section.
func addToHome(sourceID: UUID, asCard: Bool) {
if asCard {
// Add to cards
let newCard = HomeShortcutItem.mediaSource(sourceID: sourceID)
var order = homeShortcutOrder
if !order.contains(where: { $0.id == newCard.id }) {
order.append(newCard)
homeShortcutOrder = order
}
// Set visible by default
var visibility = homeShortcutVisibility
visibility[newCard] = true
homeShortcutVisibility = visibility
} else {
// Add to sections
let newSection = HomeSectionItem.mediaSource(sourceID: sourceID)
var order = homeSectionOrder
if !order.contains(where: { $0.id == newSection.id }) {
order.append(newSection)
homeSectionOrder = order
}
// Set visible by default
var visibility = homeSectionVisibility
visibility[newSection] = true
homeSectionVisibility = visibility
}
}
/// Removes a media source from Home (both cards and sections).
func removeFromHome(sourceID: UUID) {
// Remove from cards
var cardOrder = homeShortcutOrder
cardOrder.removeAll { item in
if case .mediaSource(let id) = item {
return id == sourceID
}
return false
}
homeShortcutOrder = cardOrder
// Remove from card visibility
var cardVis = homeShortcutVisibility
cardVis.removeValue(forKey: .mediaSource(sourceID: sourceID))
homeShortcutVisibility = cardVis
// Remove from sections
var sectionOrder = homeSectionOrder
sectionOrder.removeAll { item in
if case .mediaSource(let id) = item {
return id == sourceID
}
return false
}
homeSectionOrder = sectionOrder
// Remove from section visibility
var sectionVis = homeSectionVisibility
sectionVis.removeValue(forKey: .mediaSource(sourceID: sourceID))
homeSectionVisibility = sectionVis
}
/// Checks if a media source is in Home (either as card or section).
func isInHome(sourceID: UUID) -> (inCards: Bool, inSections: Bool) {
let inCards = homeShortcutOrder.contains { item in
if case .mediaSource(let id) = item {
return id == sourceID
}
return false
}
let inSections = homeSectionOrder.contains { item in
if case .mediaSource(let id) = item {
return id == sourceID
}
return false
}
return (inCards, inSections)
}
/// Returns all available card items for a media source that are NOT already added.
func availableShortcuts(for source: MediaSource) -> [HomeShortcutItem] {
let card = HomeShortcutItem.mediaSource(sourceID: source.id)
let existingCards = Set(homeShortcutOrder.map { $0.id })
return existingCards.contains(card.id) ? [] : [card]
}
/// Returns all available section items for a media source that are NOT already added.
func availableSections(for source: MediaSource) -> [HomeSectionItem] {
let section = HomeSectionItem.mediaSource(sourceID: source.id)
let existingSections = Set(homeSectionOrder.map { $0.id })
return existingSections.contains(section.id) ? [] : [section]
}
/// Returns all available cards across all media sources, grouped by source.
func allAvailableMediaSourceShortcuts(sources: [MediaSource]) -> [(source: MediaSource, cards: [HomeShortcutItem])] {
sources.compactMap { source in
let cards = availableShortcuts(for: source)
return cards.isEmpty ? nil : (source, cards)
}
}
/// Returns all available sections across all media sources, grouped by source.
func allAvailableMediaSourceSections(sources: [MediaSource]) -> [(source: MediaSource, sections: [HomeSectionItem])] {
sources.compactMap { source in
let sections = availableSections(for: source)
return sections.isEmpty ? nil : (source, sections)
}
}
/// Removes all Home items for media sources that no longer exist.
func cleanupOrphanedHomeMediaSourceItems(validSourceIDs: Set<UUID>) {
var hadOrphans = false
// Clean up cards - only write if items were actually removed
var cardOrder = homeShortcutOrder
let originalCardCount = cardOrder.count
cardOrder.removeAll { item in
if case .mediaSource(let sourceID) = item {
return !validSourceIDs.contains(sourceID)
}
return false
}
if cardOrder.count != originalCardCount {
LoggingService.shared.logCloudKit("cleanupOrphanedHomeMediaSourceItems: removed \(originalCardCount - cardOrder.count) orphaned cards")
homeShortcutOrder = cardOrder
hadOrphans = true
}
// Clean up card visibility - only write if orphaned keys found
var cardVis = homeShortcutVisibility
let orphanedCardKeys = cardVis.keys.filter { item in
if case .mediaSource(let sourceID) = item {
return !validSourceIDs.contains(sourceID)
}
return false
}
if !orphanedCardKeys.isEmpty {
LoggingService.shared.logCloudKit("cleanupOrphanedHomeMediaSourceItems: removed \(orphanedCardKeys.count) orphaned card visibility entries")
for key in orphanedCardKeys {
cardVis.removeValue(forKey: key)
}
homeShortcutVisibility = cardVis
hadOrphans = true
}
// Clean up sections - only write if items were actually removed
var sectionOrder = homeSectionOrder
let originalSectionCount = sectionOrder.count
sectionOrder.removeAll { item in
if case .mediaSource(let sourceID) = item {
return !validSourceIDs.contains(sourceID)
}
return false
}
if sectionOrder.count != originalSectionCount {
LoggingService.shared.logCloudKit("cleanupOrphanedHomeMediaSourceItems: removed \(originalSectionCount - sectionOrder.count) orphaned sections")
homeSectionOrder = sectionOrder
hadOrphans = true
}
// Clean up section visibility - only write if orphaned keys found
var sectionVis = homeSectionVisibility
let orphanedSectionKeys = sectionVis.keys.filter { item in
if case .mediaSource(let sourceID) = item {
return !validSourceIDs.contains(sourceID)
}
return false
}
if !orphanedSectionKeys.isEmpty {
LoggingService.shared.logCloudKit("cleanupOrphanedHomeMediaSourceItems: removed \(orphanedSectionKeys.count) orphaned section visibility entries")
for key in orphanedSectionKeys {
sectionVis.removeValue(forKey: key)
}
homeSectionVisibility = sectionVis
hadOrphans = true
}
if !hadOrphans {
LoggingService.shared.logCloudKit("cleanupOrphanedHomeMediaSourceItems: no orphans found, skipped all writes")
}
}
// MARK: - Tab Bar Settings (Compact Size Class)
/// Ordered list of tab bar items. Default order is subscriptions first, then others.
var tabBarItemOrder: [TabBarItem] {
get {
if let cached = _tabBarItemOrder { return cached }
guard let data = data(for: .tabBarItemOrder),
let savedOrder = try? JSONDecoder().decode([TabBarItem].self, from: data) else {
return TabBarItem.defaultOrder
}
// Merge saved order with default order to include any new items
var mergedOrder = savedOrder
for item in TabBarItem.defaultOrder {
if !mergedOrder.contains(item) {
if let defaultIndex = TabBarItem.defaultOrder.firstIndex(of: item) {
let insertIndex = min(defaultIndex, mergedOrder.count)
mergedOrder.insert(item, at: insertIndex)
} else {
mergedOrder.append(item)
}
}
}
return mergedOrder
}
set {
_tabBarItemOrder = newValue
if let data = try? JSONEncoder().encode(newValue) {
set(data, for: .tabBarItemOrder)
}
}
}
/// Visibility map for tab bar items. Default is only subscriptions visible.
var tabBarItemVisibility: [TabBarItem: Bool] {
get {
if let cached = _tabBarItemVisibility { return cached }
guard let data = data(for: .tabBarItemVisibility),
let savedVisibility = try? JSONDecoder().decode([TabBarItem: Bool].self, from: data) else {
return TabBarItem.defaultVisibility
}
// Merge saved visibility with defaults for any new items
var mergedVisibility = savedVisibility
for (item, defaultValue) in TabBarItem.defaultVisibility {
if mergedVisibility[item] == nil {
mergedVisibility[item] = defaultValue
}
}
return mergedVisibility
}
set {
_tabBarItemVisibility = newValue
if let data = try? JSONEncoder().encode(newValue) {
set(data, for: .tabBarItemVisibility)
}
}
}
/// Returns visible tab bar items in their configured order.
func visibleTabBarItems() -> [TabBarItem] {
let visibility = tabBarItemVisibility
return tabBarItemOrder
.filter { visibility[$0] ?? false }
}
// MARK: - Sidebar Main Navigation Settings
/// Ordered list of sidebar main navigation items.
var sidebarMainItemOrder: [SidebarMainItem] {
get {
if let cached = _sidebarMainItemOrder { return cached }
guard let data = data(for: .sidebarMainItemOrder),
let savedOrder = try? JSONDecoder().decode([SidebarMainItem].self, from: data) else {
return SidebarMainItem.defaultOrder
}
// Merge saved order with default order to include any new items
var mergedOrder = savedOrder
for item in SidebarMainItem.defaultOrder {
if !mergedOrder.contains(item) {
if let defaultIndex = SidebarMainItem.defaultOrder.firstIndex(of: item) {
let insertIndex = min(defaultIndex, mergedOrder.count)
mergedOrder.insert(item, at: insertIndex)
} else {
mergedOrder.append(item)
}
}
}
return mergedOrder
}
set {
_sidebarMainItemOrder = newValue
if let data = try? JSONEncoder().encode(newValue) {
set(data, for: .sidebarMainItemOrder)
}
}
}
/// Visibility map for sidebar main navigation items.
var sidebarMainItemVisibility: [SidebarMainItem: Bool] {
get {
if let cached = _sidebarMainItemVisibility { return cached }
guard let data = data(for: .sidebarMainItemVisibility),
let savedVisibility = try? JSONDecoder().decode([SidebarMainItem: Bool].self, from: data) else {
return SidebarMainItem.defaultVisibility
}
// Merge saved visibility with defaults for any new items
var mergedVisibility = savedVisibility
for (item, defaultValue) in SidebarMainItem.defaultVisibility {
if mergedVisibility[item] == nil {
mergedVisibility[item] = defaultValue
}
}
return mergedVisibility
}
set {
_sidebarMainItemVisibility = newValue
if let data = try? JSONEncoder().encode(newValue) {
set(data, for: .sidebarMainItemVisibility)
}
}
}
/// Returns visible sidebar main items in their configured order.
func visibleSidebarMainItems() -> [SidebarMainItem] {
let visibility = sidebarMainItemVisibility
return sidebarMainItemOrder
.filter { $0.isAvailableOnCurrentPlatform }
.filter { $0.isRequired || (visibility[$0] ?? true) }
}
// MARK: - Sidebar Sources Settings
/// Whether to show the Sources section in the sidebar. Default is true.
var sidebarSourcesEnabled: Bool {
get {
if let cached = _sidebarSourcesEnabled { return cached }
return bool(for: .sidebarSourcesEnabled, default: true)
}
set {
_sidebarSourcesEnabled = newValue
set(newValue, for: .sidebarSourcesEnabled)
}
}
/// How sources are sorted in the sidebar. Default is name.
var sidebarSourceSort: SidebarSourceSort {
get {
if let cached = _sidebarSourceSort { return cached }
guard let rawValue = string(for: .sidebarSourceSort) else {
return .name
}
return SidebarSourceSort(rawValue: rawValue) ?? .name
}
set {
_sidebarSourceSort = newValue
set(newValue.rawValue, for: .sidebarSourceSort)
}
}
/// Whether to limit the number of sources in the sidebar. Default is false (shows all).
var sidebarSourcesLimitEnabled: Bool {
get {
if let cached = _sidebarSourcesLimitEnabled { return cached }
return bool(for: .sidebarSourcesLimitEnabled, default: false)
}
set {
_sidebarSourcesLimitEnabled = newValue
set(newValue, for: .sidebarSourcesLimitEnabled)
}
}
/// Maximum number of sources to show in the sidebar. Default is 10.
static let defaultSidebarMaxSources = 10
var sidebarMaxSources: Int {
get {
if let cached = _sidebarMaxSources { return cached }
return integer(for: .sidebarMaxSources, default: Self.defaultSidebarMaxSources)
}
set {
_sidebarMaxSources = newValue
set(newValue, for: .sidebarMaxSources)
}
}
// MARK: - Sidebar Channels Settings
/// Whether to show the Channels section in the sidebar. Default is true.
var sidebarChannelsEnabled: Bool {
get {
if let cached = _sidebarChannelsEnabled { return cached }
return bool(for: .sidebarChannelsEnabled, default: true)
}
set {
_sidebarChannelsEnabled = newValue
set(newValue, for: .sidebarChannelsEnabled)
}
}
/// Maximum number of channels to show in the sidebar. Default is 10.
static let defaultSidebarMaxChannels = 10
var sidebarMaxChannels: Int {
get {
if let cached = _sidebarMaxChannels { return cached }
return integer(for: .sidebarMaxChannels, default: Self.defaultSidebarMaxChannels)
}
set {
_sidebarMaxChannels = newValue
set(newValue, for: .sidebarMaxChannels)
}
}
/// How channels are sorted in the sidebar. Default is lastUploaded.
var sidebarChannelSort: SidebarChannelSort {
get {
if let cached = _sidebarChannelSort { return cached }
guard let rawValue = string(for: .sidebarChannelSort) else {
return .lastUploaded
}
return SidebarChannelSort(rawValue: rawValue) ?? .lastUploaded
}
set {
_sidebarChannelSort = newValue
set(newValue.rawValue, for: .sidebarChannelSort)
}
}
/// Whether to limit the number of channels in the sidebar. Default is true.
var sidebarChannelsLimitEnabled: Bool {
get {
if let cached = _sidebarChannelsLimitEnabled { return cached }
return bool(for: .sidebarChannelsLimitEnabled, default: true)
}
set {
_sidebarChannelsLimitEnabled = newValue
set(newValue, for: .sidebarChannelsLimitEnabled)
}
}
/// Whether to show the Playlists section in the sidebar. Default is true.
var sidebarPlaylistsEnabled: Bool {
get {
if let cached = _sidebarPlaylistsEnabled { return cached }
return bool(for: .sidebarPlaylistsEnabled, default: true)
}
set {
_sidebarPlaylistsEnabled = newValue
set(newValue, for: .sidebarPlaylistsEnabled)
}
}
/// Maximum number of playlists to show in the sidebar. Default is 10.
static let defaultSidebarMaxPlaylists = 10
var sidebarMaxPlaylists: Int {
get {
if let cached = _sidebarMaxPlaylists { return cached }
return integer(for: .sidebarMaxPlaylists, default: Self.defaultSidebarMaxPlaylists)
}
set {
_sidebarMaxPlaylists = newValue
set(newValue, for: .sidebarMaxPlaylists)
}
}
/// How playlists are sorted in the sidebar. Default is alphabetical.
var sidebarPlaylistSort: SidebarPlaylistSort {
get {
if let cached = _sidebarPlaylistSort { return cached }
guard let rawValue = string(for: .sidebarPlaylistSort) else {
return .alphabetical
}
return SidebarPlaylistSort(rawValue: rawValue) ?? .alphabetical
}
set {
_sidebarPlaylistSort = newValue
set(newValue.rawValue, for: .sidebarPlaylistSort)
}
}
/// Whether to limit the number of playlists in the sidebar. Default is false (shows all).
var sidebarPlaylistsLimitEnabled: Bool {
get {
if let cached = _sidebarPlaylistsLimitEnabled { return cached }
return bool(for: .sidebarPlaylistsLimitEnabled, default: false)
}
set {
_sidebarPlaylistsLimitEnabled = newValue
set(newValue, for: .sidebarPlaylistsLimitEnabled)
}
}
// MARK: - Startup Tab Settings
/// The startup tab for tab bar mode (compact/iPhone). Default is home.
var tabBarStartupTab: SidebarMainItem {
get {
if let cached = _tabBarStartupTab { return cached }
guard let rawValue = string(for: .tabBarStartupTab) else {
return .home
}
return SidebarMainItem(rawValue: rawValue) ?? .home
}
set {
_tabBarStartupTab = newValue
set(newValue.rawValue, for: .tabBarStartupTab)
}
}
/// The startup tab for sidebar mode (iPad/Mac/tvOS). Default is home.
var sidebarStartupTab: SidebarMainItem {
get {
if let cached = _sidebarStartupTab { return cached }
guard let rawValue = string(for: .sidebarStartupTab) else {
return .home
}
return SidebarMainItem(rawValue: rawValue) ?? .home
}
set {
_sidebarStartupTab = newValue
set(newValue.rawValue, for: .sidebarStartupTab)
}
}
/// Valid startup tabs for tab bar mode.
/// Includes fixed tabs (Home, Search) plus all visible configurable tabs.
func validStartupTabsForTabBar() -> [SidebarMainItem] {
// Fixed tabs always available
var tabs: [SidebarMainItem] = [.home, .search]
// Add visible configurable tabs
let visibility = tabBarItemVisibility
for item in tabBarItemOrder where visibility[item] ?? false {
if let mainItem = SidebarMainItem(tabBarItem: item) {
tabs.append(mainItem)
}
}
return tabs
}
/// Valid startup tabs for sidebar mode.
/// Includes all visible main navigation items.
func validStartupTabsForSidebar() -> [SidebarMainItem] {
visibleSidebarMainItems()
}
/// Effective startup tab for tab bar mode.
/// Returns the configured startup tab if valid, otherwise falls back to home.
func effectiveStartupTabForTabBar() -> SidebarMainItem {
let validTabs = validStartupTabsForTabBar()
let configured = tabBarStartupTab
return validTabs.contains(configured) ? configured : .home
}
/// Effective startup tab for sidebar mode.
/// Returns the configured startup tab if valid, otherwise falls back to home.
func effectiveStartupTabForSidebar() -> SidebarMainItem {
let validTabs = validStartupTabsForSidebar()
let configured = sidebarStartupTab
return validTabs.contains(configured) ? configured : .home
}
}

View File

@@ -0,0 +1,42 @@
//
// SettingsManager+MPV.swift
// Yattee
//
// Custom MPV options storage (local-only, not synced to iCloud).
//
import Foundation
extension SettingsManager {
// MARK: - Custom MPV Options
/// Custom MPV options defined by the user.
/// Stored as a dictionary of option name to value (both strings).
/// These options are applied to MPV after the default options.
/// NOT synced to iCloud - local-only storage.
var customMPVOptions: [String: String] {
get {
guard let data = localDefaults.data(forKey: "customMPVOptions"),
let options = try? JSONDecoder().decode([String: String].self, from: data) else {
return [:]
}
return options
}
set {
if let data = try? JSONEncoder().encode(newValue) {
localDefaults.set(data, forKey: "customMPVOptions")
}
}
}
/// Static synchronous accessor for custom MPV options.
/// Use this from non-MainActor contexts like MPVClient.
/// Reads directly from UserDefaults.standard.
nonisolated static func customMPVOptionsSync() -> [String: String] {
guard let data = UserDefaults.standard.data(forKey: "customMPVOptions"),
let options = try? JSONDecoder().decode([String: String].self, from: data) else {
return [:]
}
return options
}
}

View File

@@ -0,0 +1,144 @@
//
// SettingsManager+Playback.swift
// Yattee
//
// Playback-related settings: quality, audio, subtitles, volume.
//
import Foundation
extension SettingsManager {
// MARK: - Playback Settings
/// The player backend type. Always returns MPV as it's the only supported backend.
var preferredBackend: PlayerBackendType {
.mpv
}
var preferredQuality: VideoQuality {
get {
if let cached = _preferredQuality { return cached }
return VideoQuality(rawValue: string(for: .preferredQuality) ?? "") ?? .hd1080p
}
set {
_preferredQuality = newValue
set(newValue.rawValue, for: .preferredQuality)
}
}
var cellularQuality: VideoQuality {
get {
if let cached = _cellularQuality { return cached }
return VideoQuality(rawValue: string(for: .cellularQuality) ?? "") ?? .hd720p
}
set {
_cellularQuality = newValue
set(newValue.rawValue, for: .cellularQuality)
}
}
var backgroundPlaybackEnabled: Bool {
get {
if let cached = _backgroundPlaybackEnabled { return cached }
return bool(for: .backgroundPlayback, default: true)
}
set {
_backgroundPlaybackEnabled = newValue
set(newValue, for: .backgroundPlayback)
}
}
/// Whether DASH streams are enabled (MPV only).
/// Disabled by default as DASH can be unreliable with some Invidious instances.
var dashEnabled: Bool {
get {
if let cached = _dashEnabled { return cached }
return bool(for: .dashEnabled, default: false)
}
set {
_dashEnabled = newValue
set(newValue, for: .dashEnabled)
}
}
/// Preferred audio language code (e.g., "en", "de", "ja").
/// When set, audio streams in this language will be auto-selected and shown first.
/// nil means no preference (use original/default audio).
var preferredAudioLanguage: String? {
get {
if let cached = _preferredAudioLanguage { return cached }
return string(for: .preferredAudioLanguage)
}
set {
_preferredAudioLanguage = newValue
if let value = newValue {
set(value, for: .preferredAudioLanguage)
} else {
// Clear the setting
let pKey = "preferredAudioLanguage"
localDefaults.removeObject(forKey: pKey)
if iCloudSyncEnabled && syncSettings {
ubiquitousStore.removeObject(forKey: pKey)
}
}
}
}
/// Preferred subtitles language code (e.g., "en", "de", "ja").
/// When set, subtitles in this language will be auto-loaded when video starts (MPV only).
/// nil means no subtitles (disabled by default).
var preferredSubtitlesLanguage: String? {
get {
if let cached = _preferredSubtitlesLanguage { return cached }
return string(for: .preferredSubtitlesLanguage)
}
set {
_preferredSubtitlesLanguage = newValue
if let value = newValue {
set(value, for: .preferredSubtitlesLanguage)
} else {
// Clear the setting
let pKey = "preferredSubtitlesLanguage"
localDefaults.removeObject(forKey: pKey)
if iCloudSyncEnabled && syncSettings {
ubiquitousStore.removeObject(forKey: pKey)
}
}
}
}
// MARK: - Resume Behavior
/// Action to perform when starting a partially watched video.
/// Default is `.continueWatching` to maintain existing behavior.
var resumeAction: ResumeAction {
get {
if let cached = _resumeAction { return cached }
return ResumeAction(rawValue: string(for: .resumeAction) ?? "") ?? .ask
}
set {
_resumeAction = newValue
set(newValue.rawValue, for: .resumeAction)
}
}
// MARK: - Volume Settings
/// The persisted player volume level (0.0 - 1.0).
/// Only used when volumeMode is .mpv.
/// This is a local-only setting (not synced to iCloud).
var playerVolume: Float {
get {
if let cached = _playerVolume { return cached }
// Check if value exists; if not, return default of 1.0
if localDefaults.object(forKey: "playerVolume") == nil {
return 1.0
}
return localDefaults.float(forKey: "playerVolume")
}
set {
_playerVolume = newValue
localDefaults.set(newValue, forKey: "playerVolume")
}
}
}

View File

@@ -0,0 +1,115 @@
//
// SettingsManager+Player.swift
// Yattee
//
// Player behavior settings and platform-specific player modes.
//
import Foundation
#if os(iOS)
import UIKit
#endif
extension SettingsManager {
// MARK: - Player Settings
var keepPlayerPinnedEnabled: Bool {
get {
if let cached = _keepPlayerPinnedEnabled { return cached }
return bool(for: .keepPlayerPinned, default: false)
}
set {
_keepPlayerPinnedEnabled = newValue
set(newValue, for: .keepPlayerPinned)
}
}
#if os(iOS)
/// Whether in-app orientation lock is enabled.
/// When enabled, ignores accelerometer rotation detection and stays in current orientation.
/// When disabled, uses accelerometer to detect rotation even if system lock is enabled.
/// Only active when player sheet is expanded (visible on screen).
var inAppOrientationLock: Bool {
get {
if let cached = _inAppOrientationLock { return cached }
return bool(for: .inAppOrientationLock, default: true)
}
set {
_inAppOrientationLock = newValue
set(newValue, for: .inAppOrientationLock)
}
}
/// Whether to automatically rotate to landscape when playing widescreen videos.
var rotateToMatchAspectRatio: Bool {
get {
if let cached = _rotateToMatchAspectRatio { return cached }
return bool(for: .rotateToMatchAspectRatio, default: true)
}
set {
_rotateToMatchAspectRatio = newValue
set(newValue, for: .rotateToMatchAspectRatio)
}
}
/// Whether to automatically rotate to portrait when dismissing the player sheet.
/// Only available on iPhone.
var preferPortraitBrowsing: Bool {
get {
guard UIDevice.current.userInterfaceIdiom == .phone else { return false }
if let cached = _preferPortraitBrowsing { return cached }
return bool(for: .preferPortraitBrowsing, default: false)
}
set {
_preferPortraitBrowsing = newValue
set(newValue, for: .preferPortraitBrowsing)
}
}
#endif
#if os(macOS)
var macPlayerMode: MacPlayerMode {
get {
if let cached = _macPlayerMode { return cached }
return MacPlayerMode(rawValue: string(for: .macPlayerMode) ?? "") ?? .window
}
set {
_macPlayerMode = newValue
set(newValue.rawValue, for: .macPlayerMode)
}
}
/// Whether the player sheet automatically resizes to match video aspect ratio.
/// When enabled, the sheet window will resize when video loads or changes.
/// Default is true.
var playerSheetAutoResize: Bool {
get {
if let cached = _playerSheetAutoResize { return cached }
return bool(for: .playerSheetAutoResize, default: true)
}
set {
_playerSheetAutoResize = newValue
set(newValue, for: .playerSheetAutoResize)
}
}
#endif
#if os(iOS)
/// Behavior for minimizing the mini player. Default is onScrollDown. (iOS 26+ only)
@available(iOS 26, *)
var miniPlayerMinimizeBehavior: MiniPlayerMinimizeBehavior {
get {
if let cached = _miniPlayerMinimizeBehavior as? MiniPlayerMinimizeBehavior { return cached }
guard let rawValue = localDefaults.string(forKey: "miniPlayerMinimizeBehavior"),
let behavior = MiniPlayerMinimizeBehavior(rawValue: rawValue) else {
return .onScrollDown // Default
}
return behavior
}
set {
_miniPlayerMinimizeBehavior = newValue
localDefaults.set(newValue.rawValue, forKey: "miniPlayerMinimizeBehavior")
}
}
#endif
}

View File

@@ -0,0 +1,54 @@
//
// SettingsManager+SponsorBlock.swift
// Yattee
//
// SponsorBlock integration settings.
//
import Foundation
extension SettingsManager {
// MARK: - SponsorBlock Settings
/// The SponsorBlock API URL. Defaults to the official instance.
static let defaultSponsorBlockAPIURL = "https://sponsor.ajay.app"
var sponsorBlockEnabled: Bool {
get {
if let cached = _sponsorBlockEnabled { return cached }
return bool(for: .sponsorBlockEnabled, default: true)
}
set {
_sponsorBlockEnabled = newValue
set(newValue, for: .sponsorBlockEnabled)
}
}
var sponsorBlockCategories: Set<SponsorBlockCategory> {
get {
if let cached = _sponsorBlockCategories { return cached }
guard let data = data(for: .sponsorBlockCategories),
let categories = try? JSONDecoder().decode(Set<SponsorBlockCategory>.self, from: data) else {
return SponsorBlockCategory.defaultEnabled
}
return categories
}
set {
_sponsorBlockCategories = newValue
if let data = try? JSONEncoder().encode(newValue) {
set(data, for: .sponsorBlockCategories)
}
}
}
var sponsorBlockAPIURL: String {
get {
if let cached = _sponsorBlockAPIURL { return cached }
return string(for: .sponsorBlockAPIURL) ?? Self.defaultSponsorBlockAPIURL
}
set {
_sponsorBlockAPIURL = newValue
set(newValue, for: .sponsorBlockAPIURL)
}
}
}

View File

@@ -0,0 +1,41 @@
//
// SettingsManager+Subtitles.swift
// Yattee
//
// Subtitle appearance settings storage (local-only, not synced to iCloud).
//
import Foundation
extension SettingsManager {
// MARK: - Subtitle Settings
/// Subtitle appearance settings for MPV.
/// Stored as JSON in local UserDefaults.
/// NOT synced to iCloud - local-only storage since these are MPV-specific.
var subtitleSettings: SubtitleSettings {
get {
guard let data = localDefaults.data(forKey: "subtitleSettings"),
let settings = try? JSONDecoder().decode(SubtitleSettings.self, from: data) else {
return .default
}
return settings
}
set {
if let data = try? JSONEncoder().encode(newValue) {
localDefaults.set(data, forKey: "subtitleSettings")
}
}
}
/// Static synchronous accessor for subtitle settings.
/// Use this from non-MainActor contexts like MPVClient.
/// Reads directly from UserDefaults.standard.
nonisolated static func subtitleSettingsSync() -> SubtitleSettings {
guard let data = UserDefaults.standard.data(forKey: "subtitleSettings"),
let settings = try? JSONDecoder().decode(SubtitleSettings.self, from: data) else {
return .default
}
return settings
}
}

View File

@@ -0,0 +1,577 @@
//
// SettingsTypes.swift
// Yattee
//
// Type definitions for settings values.
//
import Foundation
import SwiftUI
// MARK: - Theme & Appearance
enum AppTheme: String, CaseIterable, Codable {
case system
case light
case dark
var colorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
}
enum AccentColor: String, CaseIterable, Codable {
case `default`
case red
case pink
case orange
case yellow
case green
case teal
case blue
case purple
case indigo
var color: Color {
switch self {
case .default: return .blue // System default accent color
case .red: return .red
case .pink: return .pink
case .orange: return .orange
case .yellow: return .yellow
case .green: return .green
case .teal: return .teal
case .blue: return Color(red: 0.082, green: 0.396, blue: 0.753) // Darker blue #1565c0
case .purple: return .purple
case .indigo: return .indigo
}
}
}
#if os(iOS)
enum AppIcon: String, CaseIterable, Codable {
case `default`
case classic
case mascot
var alternateIconName: String? {
switch self {
case .default: return nil
case .classic: return "YatteeClassic"
case .mascot: return "YatteeMascot"
}
}
var previewImageName: String {
switch self {
case .default: return "AppIconPreview"
case .classic: return "AppIconPreviewClassic"
case .mascot: return "AppIconPreviewMascot"
}
}
var displayName: String {
switch self {
case .default: return String(localized: "settings.appearance.appIcon.default")
case .classic: return String(localized: "settings.appearance.appIcon.classic")
case .mascot: return String(localized: "settings.appearance.appIcon.mascot")
}
}
var author: String? {
switch self {
case .mascot: return "by Carolus Vitalis"
default: return nil
}
}
}
#endif
// MARK: - Video Quality
/// Playback quality preference.
enum VideoQuality: String, CaseIterable, Codable {
case auto
case hd4k = "4k"
case hd1440p = "1440p"
case hd1080p = "1080p"
case hd720p = "720p"
case sd480p = "480p"
case sd360p = "360p"
// Custom decoding to migrate legacy values
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let rawValue = try container.decode(String.self)
// Migrate legacy values
switch rawValue {
case "medium":
self = .sd480p
case "low":
self = .sd360p
default:
if let quality = VideoQuality(rawValue: rawValue) {
self = quality
} else {
self = .auto
}
}
}
/// Returns the recommended quality for the current platform
static var recommendedForPlatform: VideoQuality {
#if os(tvOS)
return .hd4k
#elseif os(macOS)
return .hd1080p
#elseif os(iOS)
// iPad vs iPhone would be determined at runtime
return .hd720p
#endif
}
/// Returns the maximum resolution for this quality setting
var maxResolution: StreamResolution? {
switch self {
case .auto:
return nil
case .hd4k:
return .p2160
case .hd1440p:
return .p1440
case .hd1080p:
return .p1080
case .hd720p:
return .p720
case .sd480p:
return .p480
case .sd360p:
return .p360
}
}
}
// MARK: - Download Quality
/// Download quality preference.
enum DownloadQuality: String, CaseIterable, Codable, Sendable {
case ask // Show stream selection sheet (current behavior)
case best // Best available quality
case hd4k = "4k"
case hd1440p = "1440p"
case hd1080p = "1080p"
case hd720p = "720p"
case sd480p = "480p"
case sd360p = "360p"
var displayName: String {
switch self {
case .ask: return String(localized: "settings.downloads.quality.ask")
case .best: return String(localized: "settings.downloads.quality.best")
case .hd4k: return "4K"
case .hd1440p: return "1440p"
case .hd1080p: return "1080p"
case .hd720p: return "720p"
case .sd480p: return "480p"
case .sd360p: return "360p"
}
}
/// Returns the maximum resolution for this quality setting.
var maxResolution: StreamResolution? {
switch self {
case .ask, .best:
return nil
case .hd4k:
return .p2160
case .hd1440p:
return .p1440
case .hd1080p:
return .p1080
case .hd720p:
return .p720
case .sd480p:
return .p480
case .sd360p:
return .p360
}
}
}
// MARK: - macOS Player Mode
#if os(macOS)
enum MacPlayerMode: String, CaseIterable, Codable {
case window
case floatingWindow
case inline
var displayName: String {
switch self {
case .window: return String(localized: "settings.playback.macOS.playerMode.window")
case .floatingWindow: return String(localized: "settings.playback.macOS.playerMode.floatingWindow")
case .inline: return String(localized: "settings.playback.macOS.playerMode.inline")
}
}
/// Whether this mode uses a separate window (vs sheet/inline)
var usesWindow: Bool {
switch self {
case .window, .floatingWindow: return true
case .inline: return false
}
}
/// Whether the window should float above other windows
var isFloating: Bool {
self == .floatingWindow
}
}
#endif
// MARK: - Haptic Feedback
/// Intensity levels for haptic feedback.
enum HapticFeedbackIntensity: String, CaseIterable, Codable {
case off
case light
case medium
case heavy
var displayName: String {
switch self {
case .off: return String(localized: "settings.haptics.intensity.off")
case .light: return String(localized: "settings.haptics.intensity.light")
case .medium: return String(localized: "settings.haptics.intensity.medium")
case .heavy: return String(localized: "settings.haptics.intensity.heavy")
}
}
}
/// Events that can trigger haptic feedback.
enum HapticEvent {
case subscribeButton
case playerShow
case playerDismiss
case commentsDismiss
case seekGestureActivation
case seekGestureBoundary
}
// MARK: - SponsorBlock
/// Categories of segments that can be skipped via SponsorBlock.
enum SponsorBlockCategory: String, CaseIterable, Codable, Sendable {
case sponsor
case selfpromo
case interaction
case intro
case outro
case preview
case musicOfftopic = "music_offtopic"
case filler
case highlight = "poi_highlight"
static var defaultEnabled: Set<SponsorBlockCategory> {
[.sponsor, .selfpromo, .interaction, .intro, .outro]
}
var displayName: String {
switch self {
case .sponsor: return String(localized: "sponsorBlock.category.sponsor")
case .selfpromo: return String(localized: "sponsorBlock.category.selfpromo")
case .interaction: return String(localized: "sponsorBlock.category.interaction")
case .intro: return String(localized: "sponsorBlock.category.intro")
case .outro: return String(localized: "sponsorBlock.category.outro")
case .preview: return String(localized: "sponsorBlock.category.preview")
case .musicOfftopic: return String(localized: "sponsorBlock.category.musicOfftopic")
case .filler: return String(localized: "sponsorBlock.category.filler")
case .highlight: return String(localized: "sponsorBlock.category.highlight")
}
}
var localizedDescription: String {
switch self {
case .sponsor: return String(localized: "sponsorBlock.category.sponsor.description")
case .selfpromo: return String(localized: "sponsorBlock.category.selfpromo.description")
case .interaction: return String(localized: "sponsorBlock.category.interaction.description")
case .intro: return String(localized: "sponsorBlock.category.intro.description")
case .outro: return String(localized: "sponsorBlock.category.outro.description")
case .preview: return String(localized: "sponsorBlock.category.preview.description")
case .musicOfftopic: return String(localized: "sponsorBlock.category.musicOfftopic.description")
case .filler: return String(localized: "sponsorBlock.category.filler.description")
case .highlight: return String(localized: "sponsorBlock.category.highlight.description")
}
}
/// Whether this category should auto-skip by default.
var defaultAutoSkip: Bool {
switch self {
case .sponsor, .selfpromo, .interaction, .intro, .outro:
return true
case .preview, .musicOfftopic, .filler, .highlight:
return false
}
}
}
// MARK: - Floating Panel
/// Which side the floating details panel appears on in widescreen layout.
enum FloatingPanelSide: String, CaseIterable, Codable {
case left
case right
/// The opposite side.
var opposite: FloatingPanelSide {
switch self {
case .left: return .right
case .right: return .left
}
}
/// The edge for alignment.
var edge: Edge {
switch self {
case .left: return .leading
case .right: return .trailing
}
}
/// The horizontal alignment.
var alignment: HorizontalAlignment {
switch self {
case .left: return .leading
case .right: return .trailing
}
}
}
// MARK: - Link Action
/// Default action when opening links from share extension or URL schemes.
enum DefaultLinkAction: String, CaseIterable, Codable {
case open
case download
case ask
var displayName: String {
switch self {
case .open: return String(localized: "settings.behavior.linkAction.open")
case .download: return String(localized: "settings.behavior.linkAction.download")
case .ask: return String(localized: "settings.behavior.linkAction.ask")
}
}
}
// MARK: - Mini Player Video Tap Action
/// Action to perform when tapping on video in mini player.
enum MiniPlayerVideoTapAction: String, CaseIterable, Codable {
case startPiP
case expandPlayer
var displayName: String {
switch self {
case .startPiP:
return String(localized: "settings.behavior.miniPlayer.videoTapAction.startPiP")
case .expandPlayer:
return String(localized: "settings.behavior.miniPlayer.videoTapAction.expandPlayer")
}
}
}
// MARK: - Mini Player Minimize Behavior
/// Behavior for minimizing the mini player (iOS 26+ only).
#if os(iOS)
@available(iOS 26, *)
enum MiniPlayerMinimizeBehavior: String, CaseIterable, Codable {
case onScrollDown
case never
var displayName: String {
switch self {
case .onScrollDown:
return String(localized: "settings.behavior.miniPlayer.minimizeBehavior.onScrollDown")
case .never:
return String(localized: "settings.behavior.miniPlayer.minimizeBehavior.never")
}
}
}
#endif
// MARK: - Video Tap Action
/// Action to perform when tapping on video cards/rows (iOS/macOS only).
enum VideoTapAction: String, CaseIterable, Codable {
case playVideo
case openInfo
case none
var displayName: String {
switch self {
case .playVideo:
return String(localized: "settings.behavior.videoTap.playVideo")
case .openInfo:
return String(localized: "settings.behavior.videoTap.openInfo")
case .none:
return String(localized: "settings.behavior.videoTap.none")
}
}
}
// MARK: - Resume Action
/// Action to perform when starting a partially watched video.
enum ResumeAction: String, CaseIterable, Codable {
/// Continue playback from where the user left off.
case continueWatching
/// Always start from the beginning.
case startFromBeginning
/// Ask the user each time.
case ask
var displayName: String {
switch self {
case .continueWatching:
return String(localized: "settings.playback.resumeAction.continueWatching")
case .startFromBeginning:
return String(localized: "settings.playback.resumeAction.startFromBeginning")
case .ask:
return String(localized: "settings.playback.resumeAction.ask")
}
}
}
// MARK: - Volume Mode
/// How volume is controlled during playback.
enum VolumeMode: String, CaseIterable, Codable {
/// In-app volume control via MPV.
case mpv
/// Use device system volume (hardware buttons/OS controls).
case system
var displayName: String {
switch self {
case .mpv: return String(localized: "settings.playback.volume.mode.inApp")
case .system: return String(localized: "settings.playback.volume.mode.system")
}
}
}
// MARK: - System Controls
/// Mode for system control buttons (Control Center, Lock Screen).
enum SystemControlsMode: String, CaseIterable, Codable {
/// Skip forward/backward by duration.
case seek
/// Navigate to previous/next video in queue.
case skipTrack
var displayName: String {
switch self {
case .seek: return String(localized: "settings.playback.systemControls.mode.seek")
case .skipTrack: return String(localized: "settings.playback.systemControls.mode.skipTrack")
}
}
}
/// Duration for seek operations in system controls.
enum SystemControlsSeekDuration: Int, CaseIterable, Codable {
case fiveSeconds = 5
case tenSeconds = 10
case fifteenSeconds = 15
case thirtySeconds = 30
case sixtySeconds = 60
var displayName: String { "\(rawValue)s" }
var timeInterval: TimeInterval { TimeInterval(rawValue) }
}
// MARK: - Video Swipe Actions
/// Available swipe actions for video lists.
#if !os(tvOS)
enum VideoSwipeAction: String, CaseIterable, Codable, Identifiable {
case playNext
case addToQueue
case download
case share
case videoInfo
case goToChannel
case addToBookmarks
case addToPlaylist
case markWatched
var id: String { rawValue }
/// SF Symbol name for this action.
var symbolImage: String {
switch self {
case .playNext: return "text.line.first.and.arrowtriangle.forward"
case .addToQueue: return "text.append"
case .download: return "arrow.down.circle"
case .share: return "square.and.arrow.up"
case .videoInfo: return "info.circle"
case .goToChannel: return "person.circle"
case .addToBookmarks: return "bookmark"
case .addToPlaylist: return "text.badge.plus"
case .markWatched: return "eye"
}
}
/// Tint color for the icon.
var tint: Color { .white }
/// Background color for the action button.
var backgroundColor: Color {
switch self {
case .playNext: return .blue
case .addToQueue: return .indigo
case .download: return .green
case .share: return .orange
case .videoInfo: return .gray
case .goToChannel: return .purple
case .addToBookmarks: return .yellow
case .addToPlaylist: return .teal
case .markWatched: return .cyan
}
}
/// Localized display name for this action.
var displayName: String {
switch self {
case .playNext: return String(localized: "swipeAction.playNext")
case .addToQueue: return String(localized: "swipeAction.addToQueue")
case .download: return String(localized: "swipeAction.download")
case .share: return String(localized: "swipeAction.share")
case .videoInfo: return String(localized: "swipeAction.videoInfo")
case .goToChannel: return String(localized: "swipeAction.goToChannel")
case .addToBookmarks: return String(localized: "swipeAction.addToBookmarks")
case .addToPlaylist: return String(localized: "swipeAction.addToPlaylist")
case .markWatched: return String(localized: "swipeAction.markWatched")
}
}
/// Default order with only download and share enabled.
static var defaultOrder: [VideoSwipeAction] {
[.download, .share]
}
/// Default visibility: only download and share are enabled by default.
static var defaultVisibility: [VideoSwipeAction: Bool] {
var visibility = [VideoSwipeAction: Bool]()
for action in allCases {
visibility[action] = (action == .download || action == .share)
}
return visibility
}
}
#endif

View 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
}
}

View File

@@ -0,0 +1,133 @@
//
// UserAgentGenerator.swift
// Yattee
//
// Generates random User-Agent strings for HTTP requests.
//
import Foundation
/// Generates random User-Agent strings mimicking common browsers and devices.
enum UserAgentGenerator {
// MARK: - Browser Templates
/// Chrome on Windows
private static let chromeWindows: [(version: String, platform: String)] = [
("120.0.0.0", "Windows NT 10.0; Win64; x64"),
("119.0.0.0", "Windows NT 10.0; Win64; x64"),
("121.0.0.0", "Windows NT 10.0; Win64; x64"),
("122.0.0.0", "Windows NT 10.0; Win64; x64"),
("123.0.0.0", "Windows NT 10.0; Win64; x64"),
]
/// Chrome on macOS
private static let chromeMac: [(version: String, platform: String)] = [
("120.0.0.0", "Macintosh; Intel Mac OS X 10_15_7"),
("119.0.0.0", "Macintosh; Intel Mac OS X 10_15_7"),
("121.0.0.0", "Macintosh; Intel Mac OS X 14_0"),
("122.0.0.0", "Macintosh; Intel Mac OS X 14_1"),
("123.0.0.0", "Macintosh; Intel Mac OS X 14_2"),
]
/// Firefox on Windows
private static let firefoxWindows: [(version: String, platform: String)] = [
("121.0", "Windows NT 10.0; Win64; x64"),
("120.0", "Windows NT 10.0; Win64; x64"),
("122.0", "Windows NT 10.0; Win64; x64"),
("123.0", "Windows NT 10.0; Win64; x64"),
]
/// Firefox on macOS
private static let firefoxMac: [(version: String, platform: String)] = [
("121.0", "Macintosh; Intel Mac OS X 10.15"),
("120.0", "Macintosh; Intel Mac OS X 10.15"),
("122.0", "Macintosh; Intel Mac OS X 14.0"),
("123.0", "Macintosh; Intel Mac OS X 14.1"),
]
/// Safari on macOS
private static let safariMac: [(safariVersion: String, webKitVersion: String, osVersion: String)] = [
("17.2", "605.1.15", "10_15_7"),
("17.1", "605.1.15", "10_15_7"),
("17.3", "605.1.15", "14_2"),
("17.0", "605.1.15", "14_0"),
]
/// Edge on Windows
private static let edgeWindows: [(edgeVersion: String, chromeVersion: String)] = [
("120.0.0.0", "120.0.0.0"),
("119.0.0.0", "119.0.0.0"),
("121.0.0.0", "121.0.0.0"),
("122.0.0.0", "122.0.0.0"),
]
// MARK: - Public Methods
/// Generates a random User-Agent string.
/// - Returns: A User-Agent string mimicking a common browser.
static func generateRandom() -> String {
let browserType = Int.random(in: 0..<10)
switch browserType {
case 0...3: // 40% Chrome
return generateChromeUserAgent()
case 4...5: // 20% Firefox
return generateFirefoxUserAgent()
case 6...7: // 20% Safari
return generateSafariUserAgent()
case 8...9: // 20% Edge
return generateEdgeUserAgent()
default:
return generateChromeUserAgent()
}
}
/// Default User-Agent used when no custom value is set.
static let defaultUserAgent: String = generateRandom()
// MARK: - Private Methods
private static func generateChromeUserAgent() -> String {
let useMac = Bool.random()
if useMac {
guard let config = chromeMac.randomElement() else {
return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
return "Mozilla/5.0 (\(config.platform)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/\(config.version) Safari/537.36"
} else {
guard let config = chromeWindows.randomElement() else {
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
return "Mozilla/5.0 (\(config.platform)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/\(config.version) Safari/537.36"
}
}
private static func generateFirefoxUserAgent() -> String {
let useMac = Bool.random()
if useMac {
guard let config = firefoxMac.randomElement() else {
return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0"
}
return "Mozilla/5.0 (\(config.platform); rv:\(config.version)) Gecko/20100101 Firefox/\(config.version)"
} else {
guard let config = firefoxWindows.randomElement() else {
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
}
return "Mozilla/5.0 (\(config.platform); rv:\(config.version)) Gecko/20100101 Firefox/\(config.version)"
}
}
private static func generateSafariUserAgent() -> String {
guard let config = safariMac.randomElement() else {
return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15"
}
return "Mozilla/5.0 (Macintosh; Intel Mac OS X \(config.osVersion)) AppleWebKit/\(config.webKitVersion) (KHTML, like Gecko) Version/\(config.safariVersion) Safari/\(config.webKitVersion)"
}
private static func generateEdgeUserAgent() -> String {
guard let config = edgeWindows.randomElement() else {
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"
}
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/\(config.chromeVersion) Safari/537.36 Edg/\(config.edgeVersion)"
}
}