Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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