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