Files
yattee/Yattee/Services/PlayerControls/PlayerControlsLayoutService.swift
Arkadiusz Fal 4770bbe3b9 Remove dead code found by Periphery on all platforms
Delete declarations that Periphery reports unused on every platform that
compiles them (iOS, macOS, tvOS), including nine views used only by their
own previews. Platform-gated code still used on another platform, device-
only MPV render paths, migration models, CloudKit record names, App
Intents metadata and unused parameters are kept.
2026-10-08 23:57:56 +02:00

466 lines
16 KiB
Swift

//
// PlayerControlsLayoutService.swift
// Yattee
//
// Manages player controls layout presets with local storage and CloudKit sync.
//
import Foundation
/// Manages player controls layout presets.
actor PlayerControlsLayoutService {
// MARK: - Dependencies
private let backupService: PlayerControlsBackupService
private let fileManager = FileManager.default
/// CloudKit sync engine for syncing presets across devices.
/// Set this after initialization to enable CloudKit sync.
nonisolated(unsafe) weak var cloudKitSync: CloudKitSyncEngine?
// MARK: - Storage
/// URL for the presets file in Application Support.
private var presetsFileURL: URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let yatteeDir = appSupport.appendingPathComponent("Yattee", isDirectory: true)
return yatteeDir.appendingPathComponent("PlayerControlsPresets.json")
}
/// UserDefaults key for active preset ID (per-device, not synced).
private let activePresetIDKey = "playerControlsActivePresetID"
/// UserDefaults key for last seen button version (for NEW badges).
private let lastSeenButtonVersionKey = "playerControlsLastSeenButtonVersion"
/// UserDefaults key for the last-applied built-in presets version.
private let builtInPresetsVersionKey = "playerControlsBuiltInPresetsVersion"
/// In-memory cache of presets.
private var presets: [LayoutPreset] = []
/// Whether presets have been loaded.
private var isLoaded = false
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
// MARK: - Initialization
init(backupService: PlayerControlsBackupService = PlayerControlsBackupService()) {
self.backupService = backupService
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
decoder.dateDecodingStrategy = .iso8601
}
/// Sets the CloudKit sync engine for syncing presets across devices.
/// - Parameter syncEngine: The CloudKit sync engine to use.
func setCloudKitSync(_ syncEngine: CloudKitSyncEngine) {
self.cloudKitSync = syncEngine
}
// MARK: - Loading
/// Loads presets from disk.
/// - Returns: The loaded presets.
func loadPresets() async throws -> [LayoutPreset] {
if isLoaded {
return presets
}
// Ensure directory exists
let directory = presetsFileURL.deletingLastPathComponent()
if !fileManager.fileExists(atPath: directory.path) {
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
}
// Try to load from file
if fileManager.fileExists(atPath: presetsFileURL.path) {
do {
let data = try Data(contentsOf: presetsFileURL)
presets = try decoder.decode([LayoutPreset].self, from: data)
LoggingService.shared.info("Loaded \(presets.count) player controls presets")
} catch {
LoggingService.shared.error("Failed to load presets, attempting recovery: \(error.localizedDescription)")
// Try to restore from backup
if let backupPresets = try? await backupService.restoreFromBackup() {
presets = backupPresets
try await savePresetsToDisk()
LoggingService.shared.info("Restored presets from backup")
} else {
// Try to create recovered preset from corrupted data
let corruptedData = try? Data(contentsOf: presetsFileURL)
if let data = corruptedData,
let recovered = await backupService.createRecoveredPreset(from: data) {
presets = LayoutPreset.allBuiltIn() + [recovered]
try await savePresetsToDisk()
LoggingService.shared.info("Created recovered preset from corrupted data")
} else {
// Fall back to built-in presets
presets = LayoutPreset.allBuiltIn()
try await savePresetsToDisk()
LoggingService.shared.info("Reset to built-in presets after recovery failure")
}
}
}
} else {
// First launch - create built-in presets
presets = LayoutPreset.allBuiltIn()
try await savePresetsToDisk()
LoggingService.shared.info("Created initial built-in presets")
}
// Update or add built-in presets as needed
await updateBuiltInPresetsIfNeeded()
isLoaded = true
return presets
}
/// Updates built-in presets when the code version is newer than what was last applied,
/// and ensures all built-in presets exist (in case of data corruption).
private func updateBuiltInPresetsIfNeeded() async {
let codeVersion = LayoutPreset.builtInPresetsVersion
let storedVersion = UserDefaults.standard.integer(forKey: builtInPresetsVersionKey)
let needsUpdate = storedVersion < codeVersion
let builtIn = LayoutPreset.allBuiltIn()
var modified = false
let currentActiveID = activePresetID()
var activePresetWasUpdated = false
for preset in builtIn {
if let index = presets.firstIndex(where: { $0.id == preset.id }) {
// Replace existing built-in preset if version is newer
if needsUpdate {
presets[index] = preset
modified = true
if preset.id == currentActiveID {
activePresetWasUpdated = true
}
}
} else {
// Built-in preset is missing — add it
presets.append(preset)
modified = true
}
}
if modified {
try? await savePresetsToDisk()
}
if needsUpdate {
UserDefaults.standard.set(codeVersion, forKey: builtInPresetsVersionKey)
LoggingService.shared.info("Updated built-in presets to version \(codeVersion)")
// If the active preset was a built-in that just got updated,
// notify the UI so it picks up the new layout
if activePresetWasUpdated {
await MainActor.run {
NotificationCenter.default.post(name: .playerControlsActivePresetDidChange, object: nil)
}
}
}
}
// MARK: - Saving
/// Saves presets to disk and creates backup.
private func savePresetsToDisk() async throws {
let data = try encoder.encode(presets)
try data.write(to: presetsFileURL, options: .atomic)
// Create backup
try await backupService.createBackup(presets: presets)
}
// MARK: - Preset Management
/// Saves a new or updated preset.
func savePreset(_ preset: LayoutPreset) async throws {
if !isLoaded {
_ = try? await loadPresets()
}
if let index = presets.firstIndex(where: { $0.id == preset.id }) {
// Update existing - but not if it's built-in
if presets[index].isBuiltIn {
LoggingService.shared.error("Cannot modify built-in preset")
return
}
presets[index] = preset
} else {
// Add new
presets.append(preset)
}
try await savePresetsToDisk()
LoggingService.shared.info("Saved preset: \(preset.name)")
// Sync to CloudKit and post notification (only for non-built-in presets)
let syncEngine = cloudKitSync
let shouldSync = !preset.isBuiltIn
await MainActor.run {
if shouldSync {
syncEngine?.queueControlsPresetSave(preset)
}
NotificationCenter.default.post(name: .playerControlsPresetsDidChange, object: nil)
}
}
/// Updates an existing preset's layout.
func updatePresetLayout(_ presetID: UUID, layout: PlayerControlsLayout) async throws {
if !isLoaded {
_ = try? await loadPresets()
}
guard let index = presets.firstIndex(where: { $0.id == presetID }) else {
LoggingService.shared.error("Preset not found: \(presetID)")
return
}
// Cannot modify built-in presets
if presets[index].isBuiltIn {
LoggingService.shared.error("Cannot modify built-in preset")
return
}
let updatedPreset = presets[index].withUpdatedLayout(layout)
presets[index] = updatedPreset
try await savePresetsToDisk()
// Sync to CloudKit and post notification
let syncEngine = cloudKitSync
await MainActor.run {
syncEngine?.queueControlsPresetSave(updatedPreset)
NotificationCenter.default.post(name: .playerControlsPresetsDidChange, object: nil)
}
}
/// Deletes a preset.
func deletePreset(id: UUID) async throws {
if !isLoaded {
_ = try? await loadPresets()
}
guard let preset = presets.first(where: { $0.id == id }) else {
return
}
// Cannot delete built-in presets
if preset.isBuiltIn {
LoggingService.shared.error("Cannot delete built-in preset")
return
}
// Cannot delete active preset
if activePresetID() == id {
LoggingService.shared.error("Cannot delete active preset")
return
}
presets.removeAll { $0.id == id }
try await savePresetsToDisk()
LoggingService.shared.info("Deleted preset: \(preset.name)")
// Sync deletion to CloudKit and post notification
let syncEngine = cloudKitSync
await MainActor.run {
syncEngine?.queueControlsPresetDelete(id: id)
NotificationCenter.default.post(name: .playerControlsPresetsDidChange, object: nil)
}
}
/// Renames a preset.
func renamePreset(_ presetID: UUID, to newName: String) async throws {
if !isLoaded {
_ = try? await loadPresets()
}
guard let index = presets.firstIndex(where: { $0.id == presetID }) else {
return
}
// Cannot rename built-in presets
if presets[index].isBuiltIn {
LoggingService.shared.error("Cannot rename built-in preset")
return
}
let renamedPreset = presets[index].renamed(to: newName)
presets[index] = renamedPreset
try await savePresetsToDisk()
// Sync to CloudKit and post notification
let syncEngine = cloudKitSync
await MainActor.run {
syncEngine?.queueControlsPresetSave(renamedPreset)
NotificationCenter.default.post(name: .playerControlsPresetsDidChange, object: nil)
}
}
// MARK: - Active Preset
/// Returns the ID of the active preset for this device.
func activePresetID() -> UUID? {
guard let string = UserDefaults.standard.string(forKey: activePresetIDKey),
let uuid = UUID(uuidString: string) else {
return nil
}
return uuid
}
/// Sets the active preset ID for this device.
func setActivePresetID(_ id: UUID) {
UserDefaults.standard.set(id.uuidString, forKey: activePresetIDKey)
LoggingService.shared.info("Set active preset: \(id)")
// Post notification
Task { @MainActor in
NotificationCenter.default.post(name: .playerControlsActivePresetDidChange, object: nil)
}
}
/// Returns the active preset, or the default preset if none is set.
func activePreset() async -> LayoutPreset {
if !isLoaded {
_ = try? await loadPresets()
}
// Try to find the active preset
if let activeID = activePresetID(),
let preset = presets.first(where: { $0.id == activeID && $0.deviceClass == .current }) {
return preset
}
// Fall back to default preset
let defaultPreset = LayoutPreset.defaultPreset()
if !presets.contains(where: { $0.id == defaultPreset.id }) {
presets.append(defaultPreset)
try? await savePresetsToDisk()
}
// Set default as active
setActivePresetID(defaultPreset.id)
return defaultPreset
}
/// Returns the active layout and updates cached settings.
func activeLayout() async -> PlayerControlsLayout {
let preset = await activePreset()
let layout = preset.layout
GlobalLayoutSettings.cached = layout.globalSettings
MiniPlayerSettings.cached = layout.effectiveMiniPlayerSettings
return layout
}
// MARK: - NEW Badge Support
/// Returns the last seen button version.
func lastSeenButtonVersion() -> Int {
UserDefaults.standard.integer(forKey: lastSeenButtonVersionKey)
}
/// Updates the last seen button version.
func updateLastSeenButtonVersion(_ version: Int) {
UserDefaults.standard.set(version, forKey: lastSeenButtonVersionKey)
}
/// Returns button types that are new since the last seen version.
func newButtonTypes() -> [ControlButtonType] {
let lastSeen = lastSeenButtonVersion()
return ControlButtonType.allCases.filter { $0.versionAdded > lastSeen }
}
/// Marks all current buttons as seen.
func markAllButtonsAsSeen() {
let maxVersion = ControlButtonType.allCases.map(\.versionAdded).max() ?? 1
updateLastSeenButtonVersion(maxVersion)
}
// MARK: - CloudKit Support
/// Returns all presets for CloudKit sync (filtered by device class).
func presetsForSync() async -> [LayoutPreset] {
if !isLoaded {
_ = try? await loadPresets()
}
// Only sync non-built-in presets for current device class
return presets.filter { !$0.isBuiltIn && $0.deviceClass == .current }
}
/// Imports a preset from CloudKit.
func importPreset(_ preset: LayoutPreset) async throws {
if !isLoaded {
_ = try? await loadPresets()
}
// Only import if device class matches
guard preset.deviceClass == .current else {
LoggingService.shared.info("Skipping preset import - wrong device class: \(preset.deviceClass)")
return
}
if let index = presets.firstIndex(where: { $0.id == preset.id }) {
// Update existing if newer
if preset.updatedAt > presets[index].updatedAt {
presets[index] = preset
}
} else {
// Add new
presets.append(preset)
}
try await savePresetsToDisk()
// Post notification
await MainActor.run {
NotificationCenter.default.post(name: .playerControlsPresetsDidChange, object: nil)
}
}
/// Removes a preset by ID (from CloudKit deletion).
func removePreset(id: UUID) async throws {
if !isLoaded {
_ = try? await loadPresets()
}
guard let preset = presets.first(where: { $0.id == id }) else {
return
}
// Cannot remove built-in presets
if preset.isBuiltIn {
return
}
// If this was the active preset, revert to default
if activePresetID() == id {
let defaultPreset = LayoutPreset.defaultPreset()
setActivePresetID(defaultPreset.id)
LoggingService.shared.info("Reverted to default preset after remote deletion of active preset")
}
presets.removeAll { $0.id == id }
try await savePresetsToDisk()
// Post notification
await MainActor.run {
NotificationCenter.default.post(name: .playerControlsPresetsDidChange, object: nil)
}
}
}
// MARK: - Notifications
extension Notification.Name {
/// Posted when player controls presets change.
static let playerControlsPresetsDidChange = Notification.Name("playerControlsPresetsDidChange")
/// Posted when the active player controls preset changes.
static let playerControlsActivePresetDidChange = Notification.Name("playerControlsActivePresetDidChange")
}