mirror of
https://github.com/yattee/yattee.git
synced 2026-04-10 17:46:58 +00:00
Yattee v2 rewrite
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user