mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
138
Yattee/Services/PlayerControls/PlayerControlsBackupService.swift
Normal file
138
Yattee/Services/PlayerControls/PlayerControlsBackupService.swift
Normal file
@@ -0,0 +1,138 @@
|
||||
//
|
||||
// PlayerControlsBackupService.swift
|
||||
// Yattee
|
||||
//
|
||||
// Manages backup and recovery of player controls layout presets.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Manages backup and recovery of player controls layout presets.
|
||||
actor PlayerControlsBackupService {
|
||||
// MARK: - Storage
|
||||
|
||||
/// URL for the backup file in Application Support.
|
||||
private var backupFileURL: URL {
|
||||
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
let yatteeDir = appSupport.appendingPathComponent("Yattee", isDirectory: true)
|
||||
return yatteeDir.appendingPathComponent("PlayerControlsBackup.json")
|
||||
}
|
||||
|
||||
private let fileManager = FileManager.default
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
}
|
||||
|
||||
// MARK: - Backup Operations
|
||||
|
||||
/// Creates a backup of the given presets.
|
||||
/// - Parameter presets: The presets to back up.
|
||||
func createBackup(presets: [LayoutPreset]) async throws {
|
||||
// Ensure directory exists
|
||||
let directory = backupFileURL.deletingLastPathComponent()
|
||||
if !fileManager.fileExists(atPath: directory.path) {
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
// Encode and write
|
||||
let data = try encoder.encode(presets)
|
||||
try data.write(to: backupFileURL, options: .atomic)
|
||||
|
||||
LoggingService.shared.info("Created player controls backup with \(presets.count) presets")
|
||||
}
|
||||
|
||||
/// Restores presets from backup.
|
||||
/// - Returns: The restored presets, or nil if no valid backup exists.
|
||||
func restoreFromBackup() async throws -> [LayoutPreset]? {
|
||||
guard fileManager.fileExists(atPath: backupFileURL.path) else {
|
||||
LoggingService.shared.info("No player controls backup file found")
|
||||
return nil
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: backupFileURL)
|
||||
let presets = try decoder.decode([LayoutPreset].self, from: data)
|
||||
|
||||
LoggingService.shared.info("Restored \(presets.count) presets from backup")
|
||||
return presets
|
||||
}
|
||||
|
||||
/// Checks if a valid backup exists.
|
||||
/// - Returns: True if a valid backup file exists.
|
||||
func hasValidBackup() async -> Bool {
|
||||
guard fileManager.fileExists(atPath: backupFileURL.path) else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to decode to verify it's valid
|
||||
do {
|
||||
let data = try Data(contentsOf: backupFileURL)
|
||||
_ = try decoder.decode([LayoutPreset].self, from: data)
|
||||
return true
|
||||
} catch {
|
||||
LoggingService.shared.error("Backup file exists but is invalid: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts to create a recovered preset from corrupted data.
|
||||
/// - Parameter data: The corrupted data.
|
||||
/// - Returns: A recovered preset if partial recovery is possible.
|
||||
func createRecoveredPreset(from data: Data) async -> LayoutPreset? {
|
||||
// Try to extract any valid layout from the data
|
||||
// This is a best-effort recovery attempt
|
||||
|
||||
// First, try decoding as a single preset
|
||||
if let preset = try? decoder.decode(LayoutPreset.self, from: data) {
|
||||
let recovered = preset.duplicate(name: "Recovered \(formattedDate())")
|
||||
LoggingService.shared.info("Recovered single preset from corrupted data")
|
||||
return recovered
|
||||
}
|
||||
|
||||
// Try decoding as an array and take the first valid one
|
||||
if let presets = try? decoder.decode([LayoutPreset].self, from: data),
|
||||
let first = presets.first {
|
||||
let recovered = first.duplicate(name: "Recovered \(formattedDate())")
|
||||
LoggingService.shared.info("Recovered preset from corrupted array data")
|
||||
return recovered
|
||||
}
|
||||
|
||||
// Try decoding just the layout
|
||||
if let layout = try? decoder.decode(PlayerControlsLayout.self, from: data) {
|
||||
let recovered = LayoutPreset(
|
||||
name: "Recovered \(formattedDate())",
|
||||
layout: layout
|
||||
)
|
||||
LoggingService.shared.info("Recovered layout from corrupted data")
|
||||
return recovered
|
||||
}
|
||||
|
||||
LoggingService.shared.error("Failed to recover any preset from corrupted data")
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Deletes the backup file.
|
||||
func deleteBackup() async throws {
|
||||
guard fileManager.fileExists(atPath: backupFileURL.path) else {
|
||||
return
|
||||
}
|
||||
|
||||
try fileManager.removeItem(at: backupFileURL)
|
||||
LoggingService.shared.info("Deleted player controls backup")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func formattedDate() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: Date())
|
||||
}
|
||||
}
|
||||
513
Yattee/Services/PlayerControls/PlayerControlsLayoutService.swift
Normal file
513
Yattee/Services/PlayerControls/PlayerControlsLayoutService.swift
Normal file
@@ -0,0 +1,513 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
/// Returns all presets for the current device class.
|
||||
func allPresets() async -> [LayoutPreset] {
|
||||
if !isLoaded {
|
||||
_ = try? await loadPresets()
|
||||
}
|
||||
return presets.filter { $0.deviceClass == .current }
|
||||
}
|
||||
|
||||
/// Returns a preset by ID.
|
||||
func preset(forID id: UUID) async -> LayoutPreset? {
|
||||
if !isLoaded {
|
||||
_ = try? await loadPresets()
|
||||
}
|
||||
return presets.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Duplicates a preset with a new name.
|
||||
func duplicatePreset(_ preset: LayoutPreset, newName: String) async throws -> LayoutPreset {
|
||||
let duplicate = preset.duplicate(name: newName)
|
||||
try await savePreset(duplicate)
|
||||
return duplicate
|
||||
}
|
||||
|
||||
/// 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: - Validation
|
||||
|
||||
/// Checks if a preset can be deleted.
|
||||
func canDeletePreset(id: UUID) async -> Bool {
|
||||
if !isLoaded {
|
||||
_ = try? await loadPresets()
|
||||
}
|
||||
|
||||
guard let preset = presets.first(where: { $0.id == id }) else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Cannot delete built-in presets
|
||||
if preset.isBuiltIn {
|
||||
return false
|
||||
}
|
||||
|
||||
// Cannot delete active preset
|
||||
if activePresetID() == id {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//
|
||||
// PlayerControlsPresetExportImport.swift
|
||||
// Yattee
|
||||
//
|
||||
// Service for importing and exporting player controls presets to JSON files.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Import Errors
|
||||
|
||||
/// Errors that can occur when importing a player controls preset.
|
||||
enum LayoutPresetImportError: LocalizedError, Equatable, Sendable {
|
||||
/// The file contains invalid or corrupted data.
|
||||
case invalidData
|
||||
|
||||
/// The file is empty.
|
||||
case emptyFile
|
||||
|
||||
/// JSON parsing failed with a specific error.
|
||||
case parsingFailed(String)
|
||||
|
||||
/// The preset was created for a different device class.
|
||||
case wrongDeviceClass(expected: DeviceClass, found: DeviceClass)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidData:
|
||||
return String(localized: "settings.playerControls.import.error.invalidData")
|
||||
case .emptyFile:
|
||||
return String(localized: "settings.playerControls.import.error.emptyFile")
|
||||
case .parsingFailed(let details):
|
||||
return String(localized: "settings.playerControls.import.error.parsingFailed \(details)")
|
||||
case .wrongDeviceClass(_, let found):
|
||||
return String(localized: "settings.playerControls.import.error.wrongDeviceClass \(found.displayName)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Export/Import Service
|
||||
|
||||
/// Service for importing and exporting player controls presets.
|
||||
enum PlayerControlsPresetExportImport {
|
||||
// MARK: - Export
|
||||
|
||||
/// Exports a preset to pretty-printed JSON data.
|
||||
/// - Parameter preset: The preset to export.
|
||||
/// - Returns: JSON data, or nil if encoding failed.
|
||||
static func exportToJSON(_ preset: LayoutPreset) -> Data? {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
|
||||
do {
|
||||
return try encoder.encode(preset)
|
||||
} catch {
|
||||
LoggingService.shared.error(
|
||||
"Failed to encode preset to JSON: \(error.localizedDescription)",
|
||||
category: .general
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates an export filename for a preset.
|
||||
/// Format: yattee-preset-{sanitized-name}-{date}.json
|
||||
/// - Parameter preset: The preset to generate a filename for.
|
||||
/// - Returns: A filename string.
|
||||
static func generateExportFilename(for preset: LayoutPreset) -> String {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd"
|
||||
let dateString = dateFormatter.string(from: Date())
|
||||
|
||||
// Sanitize name for filename (remove special characters, limit length)
|
||||
let sanitizedName = preset.name
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: " ", with: "-")
|
||||
.components(separatedBy: CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")).inverted)
|
||||
.joined()
|
||||
.prefix(20)
|
||||
|
||||
return "yattee-preset-\(sanitizedName)-\(dateString).json"
|
||||
}
|
||||
|
||||
// MARK: - Import
|
||||
|
||||
/// Imports a preset from JSON data.
|
||||
///
|
||||
/// Validates that the preset's device class matches the current device.
|
||||
/// The imported preset will have:
|
||||
/// - A new UUID (to avoid conflicts)
|
||||
/// - `isBuiltIn` set to false
|
||||
/// - `createdAt` and `updatedAt` set to now
|
||||
///
|
||||
/// - Parameter data: JSON data to parse.
|
||||
/// - Returns: A new `LayoutPreset` ready to be saved.
|
||||
/// - Throws: `LayoutPresetImportError` if parsing or validation fails.
|
||||
static func importFromJSON(_ data: Data) throws -> LayoutPreset {
|
||||
guard !data.isEmpty else {
|
||||
throw LayoutPresetImportError.emptyFile
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
|
||||
let importedPreset: LayoutPreset
|
||||
do {
|
||||
importedPreset = try decoder.decode(LayoutPreset.self, from: data)
|
||||
} catch let decodingError as DecodingError {
|
||||
let details = describeDecodingError(decodingError)
|
||||
throw LayoutPresetImportError.parsingFailed(details)
|
||||
} catch {
|
||||
throw LayoutPresetImportError.invalidData
|
||||
}
|
||||
|
||||
// Validate device class matches current device
|
||||
let currentDeviceClass = DeviceClass.current
|
||||
guard importedPreset.deviceClass == currentDeviceClass else {
|
||||
throw LayoutPresetImportError.wrongDeviceClass(
|
||||
expected: currentDeviceClass,
|
||||
found: importedPreset.deviceClass
|
||||
)
|
||||
}
|
||||
|
||||
// Create a new preset with regenerated metadata
|
||||
let now = Date()
|
||||
return LayoutPreset(
|
||||
id: UUID(),
|
||||
name: importedPreset.name,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isBuiltIn: false,
|
||||
deviceClass: currentDeviceClass,
|
||||
layout: importedPreset.layout
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Provides a human-readable description of a decoding error.
|
||||
private static func describeDecodingError(_ error: DecodingError) -> String {
|
||||
switch error {
|
||||
case .typeMismatch(let type, let context):
|
||||
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
|
||||
return "Type mismatch for \(type) at \(path)"
|
||||
case .valueNotFound(let type, let context):
|
||||
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
|
||||
return "Missing value of type \(type) at \(path)"
|
||||
case .keyNotFound(let key, let context):
|
||||
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
|
||||
return "Missing key '\(key.stringValue)' at \(path)"
|
||||
case .dataCorrupted(let context):
|
||||
return context.debugDescription
|
||||
@unknown default:
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
209
Yattee/Services/PlayerControls/PlayerGestureActionHandler.swift
Normal file
209
Yattee/Services/PlayerControls/PlayerGestureActionHandler.swift
Normal file
@@ -0,0 +1,209 @@
|
||||
//
|
||||
// PlayerGestureActionHandler.swift
|
||||
// Yattee
|
||||
//
|
||||
// Handles execution of gesture actions on the player.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
/// Result of executing a tap gesture action.
|
||||
struct TapActionResult: Sendable {
|
||||
/// The action that was executed.
|
||||
let action: TapGestureAction
|
||||
|
||||
/// The zone that was tapped.
|
||||
let position: TapZonePosition
|
||||
|
||||
/// Accumulated seek seconds (for rapid seek taps).
|
||||
let accumulatedSeconds: Int?
|
||||
|
||||
/// New state description (e.g., "1.5x" for speed, "Muted" for mute).
|
||||
let newState: String?
|
||||
}
|
||||
|
||||
/// Actor that handles gesture action execution with seek accumulation.
|
||||
actor PlayerGestureActionHandler {
|
||||
/// Playback speed sequence (YouTube-style).
|
||||
static let playbackSpeedSequence: [Double] = [
|
||||
0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0
|
||||
]
|
||||
|
||||
/// Accumulation window duration in seconds.
|
||||
private let accumulationWindow: TimeInterval = 2.0
|
||||
|
||||
// MARK: - State
|
||||
|
||||
private var accumulatedSeekSeconds: Int = 0
|
||||
private var lastSeekPosition: TapZonePosition?
|
||||
private var lastSeekTime: Date?
|
||||
private var lastSeekDirection: SeekDirection?
|
||||
private var accumulationResetTask: Task<Void, Never>?
|
||||
|
||||
/// Direction of seek for clamping calculations.
|
||||
private enum SeekDirection {
|
||||
case forward
|
||||
case backward
|
||||
}
|
||||
|
||||
// MARK: - Current Player State
|
||||
|
||||
private var currentTime: TimeInterval = 0
|
||||
private var duration: TimeInterval = 0
|
||||
|
||||
// MARK: - Tap Action Handling
|
||||
|
||||
/// Handles a tap gesture action.
|
||||
/// - Parameters:
|
||||
/// - action: The action to execute.
|
||||
/// - position: The zone that was tapped.
|
||||
/// - playerState: Current player state for context.
|
||||
/// - Returns: Result describing what was executed.
|
||||
func handleTapAction(
|
||||
_ action: TapGestureAction,
|
||||
position: TapZonePosition
|
||||
) async -> TapActionResult {
|
||||
switch action {
|
||||
case .seekForward(let seconds), .seekBackward(let seconds):
|
||||
return await handleSeekAction(action, position: position, seconds: seconds)
|
||||
default:
|
||||
return TapActionResult(
|
||||
action: action,
|
||||
position: position,
|
||||
accumulatedSeconds: nil,
|
||||
newState: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSeekAction(
|
||||
_ action: TapGestureAction,
|
||||
position: TapZonePosition,
|
||||
seconds: Int
|
||||
) async -> TapActionResult {
|
||||
let now = Date()
|
||||
|
||||
// Determine seek direction
|
||||
let direction: SeekDirection
|
||||
switch action {
|
||||
case .seekForward:
|
||||
direction = .forward
|
||||
case .seekBackward:
|
||||
direction = .backward
|
||||
default:
|
||||
return TapActionResult(action: action, position: position, accumulatedSeconds: nil, newState: nil)
|
||||
}
|
||||
|
||||
// Calculate max seekable time in this direction
|
||||
let maxSeekable: Int
|
||||
switch direction {
|
||||
case .forward:
|
||||
maxSeekable = max(0, Int(duration - currentTime))
|
||||
case .backward:
|
||||
maxSeekable = max(0, Int(currentTime))
|
||||
}
|
||||
|
||||
// Check if we should accumulate with previous seek (same position AND same direction)
|
||||
let shouldAccumulate = lastSeekTime.map { now.timeIntervalSince($0) < accumulationWindow } ?? false
|
||||
&& lastSeekPosition == position
|
||||
&& lastSeekDirection == direction
|
||||
|
||||
if shouldAccumulate {
|
||||
// Only accumulate if we haven't hit the max
|
||||
if accumulatedSeekSeconds < maxSeekable {
|
||||
accumulatedSeekSeconds = min(accumulatedSeekSeconds + seconds, maxSeekable)
|
||||
}
|
||||
// If already at max, don't increment (stop incrementing behavior)
|
||||
} else {
|
||||
// Start new accumulation (direction changed or new gesture)
|
||||
accumulatedSeekSeconds = min(seconds, maxSeekable)
|
||||
}
|
||||
|
||||
lastSeekPosition = position
|
||||
lastSeekTime = now
|
||||
lastSeekDirection = direction
|
||||
|
||||
// Cancel previous reset task and schedule new one
|
||||
accumulationResetTask?.cancel()
|
||||
accumulationResetTask = Task { [accumulationWindow] in
|
||||
try? await Task.sleep(for: .seconds(accumulationWindow))
|
||||
guard !Task.isCancelled else { return }
|
||||
self.resetAccumulation()
|
||||
}
|
||||
|
||||
return TapActionResult(
|
||||
action: action,
|
||||
position: position,
|
||||
accumulatedSeconds: accumulatedSeekSeconds,
|
||||
newState: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func resetAccumulation() {
|
||||
accumulatedSeekSeconds = 0
|
||||
lastSeekPosition = nil
|
||||
lastSeekTime = nil
|
||||
lastSeekDirection = nil
|
||||
}
|
||||
|
||||
/// Cancels any pending seek accumulation and resets state.
|
||||
/// Call this when switching seek direction or executing a different action.
|
||||
func cancelAccumulation() {
|
||||
accumulationResetTask?.cancel()
|
||||
accumulationResetTask = nil
|
||||
resetAccumulation()
|
||||
}
|
||||
|
||||
/// Returns the current accumulated seek seconds.
|
||||
func currentAccumulatedSeconds() -> Int {
|
||||
accumulatedSeekSeconds
|
||||
}
|
||||
|
||||
// MARK: - Playback Speed Cycling
|
||||
|
||||
/// Returns the next playback speed in the sequence.
|
||||
/// - Parameter currentSpeed: The current playback speed.
|
||||
/// - Returns: The next speed (wraps around).
|
||||
func nextPlaybackSpeed(currentSpeed: Double) -> Double {
|
||||
let sequence = Self.playbackSpeedSequence
|
||||
|
||||
// Find current index
|
||||
if let index = sequence.firstIndex(where: { abs($0 - currentSpeed) < 0.01 }) {
|
||||
let nextIndex = (index + 1) % sequence.count
|
||||
return sequence[nextIndex]
|
||||
}
|
||||
|
||||
// If current speed not in sequence, find closest and go to next
|
||||
let closest = sequence.min { abs($0 - currentSpeed) < abs($1 - currentSpeed) } ?? 1.0
|
||||
if let index = sequence.firstIndex(of: closest) {
|
||||
let nextIndex = (index + 1) % sequence.count
|
||||
return sequence[nextIndex]
|
||||
}
|
||||
|
||||
return 1.0
|
||||
}
|
||||
|
||||
/// Formats a playback speed for display.
|
||||
/// - Parameter speed: The playback speed.
|
||||
/// - Returns: Formatted string (e.g., "1.5x").
|
||||
func formatPlaybackSpeed(_ speed: Double) -> String {
|
||||
if speed == floor(speed) {
|
||||
return String(format: "%.0fx", speed)
|
||||
} else {
|
||||
return String(format: "%.2gx", speed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Player State Updates
|
||||
|
||||
/// Updates the current player state for seek clamping calculations.
|
||||
func updatePlayerState(
|
||||
currentTime: TimeInterval,
|
||||
duration: TimeInterval
|
||||
) {
|
||||
self.currentTime = currentTime
|
||||
self.duration = duration
|
||||
}
|
||||
}
|
||||
#endif
|
||||
136
Yattee/Services/PlayerControls/SeekGestureCalculator.swift
Normal file
136
Yattee/Services/PlayerControls/SeekGestureCalculator.swift
Normal file
@@ -0,0 +1,136 @@
|
||||
//
|
||||
// SeekGestureCalculator.swift
|
||||
// Yattee
|
||||
//
|
||||
// Calculator for horizontal seek gesture recognition and seek delta computation.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Stateless calculator for horizontal seek gesture logic.
|
||||
/// Handles gesture recognition, seek delta calculation, and boundary clamping.
|
||||
enum SeekGestureCalculator {
|
||||
// MARK: - Constants
|
||||
|
||||
/// Minimum horizontal distance (in points) required to recognize the gesture.
|
||||
static let activationThreshold: CGFloat = 20
|
||||
|
||||
/// Maximum angle from horizontal (in degrees) to recognize as horizontal gesture.
|
||||
/// Movements beyond this angle are not recognized, reserving vertical axis for future gestures.
|
||||
static let maxAngleFromHorizontal: Double = 30
|
||||
|
||||
/// Minimum seek delta (in seconds) for the gesture to commit.
|
||||
/// Gestures resulting in less than this are ignored.
|
||||
static let minimumSeekSeconds: Double = 5
|
||||
|
||||
/// Range for duration-based multiplier.
|
||||
static let durationMultiplierRange: ClosedRange<Double> = 0.5...3.0
|
||||
|
||||
/// Reference duration (in seconds) for multiplier calculation.
|
||||
/// A 10-minute video has a multiplier of 1.0.
|
||||
static let referenceDuration: Double = 600
|
||||
|
||||
// MARK: - Gesture Recognition
|
||||
|
||||
/// Determines if a drag translation represents a horizontal movement.
|
||||
/// - Parameter translation: The drag translation (x, y).
|
||||
/// - Returns: `true` if the movement is predominantly horizontal and exceeds the activation threshold.
|
||||
static func isHorizontalMovement(translation: CGSize) -> Bool {
|
||||
let horizontalDistance = abs(translation.width)
|
||||
let verticalDistance = abs(translation.height)
|
||||
|
||||
// Must exceed activation threshold
|
||||
guard horizontalDistance >= activationThreshold else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Calculate angle from horizontal axis
|
||||
// atan2 returns angle in radians, convert to degrees
|
||||
let angleRadians = atan2(verticalDistance, horizontalDistance)
|
||||
let angleDegrees = angleRadians * 180 / .pi
|
||||
|
||||
// Must be within max angle from horizontal
|
||||
return angleDegrees <= maxAngleFromHorizontal
|
||||
}
|
||||
|
||||
// MARK: - Duration Multiplier
|
||||
|
||||
/// Calculates the duration-based multiplier for seek sensitivity.
|
||||
/// Shorter videos get smaller multipliers (more precise), longer videos get larger multipliers (faster seeking).
|
||||
/// - Parameter videoDuration: The video duration in seconds.
|
||||
/// - Returns: A multiplier between 0.5 and 3.0.
|
||||
static func calculateDurationMultiplier(videoDuration: Double) -> Double {
|
||||
guard videoDuration > 0 else { return 1.0 }
|
||||
let rawMultiplier = videoDuration / referenceDuration
|
||||
return rawMultiplier.clamped(to: durationMultiplierRange)
|
||||
}
|
||||
|
||||
// MARK: - Seek Delta Calculation
|
||||
|
||||
/// Calculates the seek delta based on drag distance and video properties.
|
||||
/// - Parameters:
|
||||
/// - dragDistance: Horizontal drag distance in points (positive = forward, negative = backward).
|
||||
/// - screenWidth: Screen width in points for normalization.
|
||||
/// - videoDuration: Video duration in seconds.
|
||||
/// - sensitivity: User's selected sensitivity preset.
|
||||
/// - Returns: Seek delta in seconds, or `nil` if the delta is below minimum threshold.
|
||||
static func calculateSeekDelta(
|
||||
dragDistance: CGFloat,
|
||||
screenWidth: CGFloat,
|
||||
videoDuration: Double,
|
||||
sensitivity: SeekGestureSensitivity
|
||||
) -> Double? {
|
||||
guard screenWidth > 0, videoDuration > 0 else { return nil }
|
||||
|
||||
let baseSeconds = sensitivity.baseSecondsPerScreenWidth
|
||||
let multiplier = calculateDurationMultiplier(videoDuration: videoDuration)
|
||||
let effectiveSecondsPerScreenWidth = baseSeconds * multiplier
|
||||
|
||||
let normalizedDrag = Double(dragDistance / screenWidth)
|
||||
let seekDelta = normalizedDrag * effectiveSecondsPerScreenWidth
|
||||
|
||||
// Apply minimum threshold
|
||||
guard abs(seekDelta) >= minimumSeekSeconds else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return seekDelta
|
||||
}
|
||||
|
||||
// MARK: - Boundary Clamping
|
||||
|
||||
/// Result of clamping a seek time to video boundaries.
|
||||
struct ClampResult: Equatable, Sendable {
|
||||
/// The clamped seek time.
|
||||
let seekTime: Double
|
||||
/// Whether the boundary was hit (start or end).
|
||||
let hitBoundary: Bool
|
||||
}
|
||||
|
||||
/// Clamps a seek operation to valid video boundaries.
|
||||
/// - Parameters:
|
||||
/// - currentTime: Current playback position in seconds.
|
||||
/// - seekDelta: Desired seek delta in seconds (can be negative).
|
||||
/// - duration: Video duration in seconds.
|
||||
/// - Returns: The clamped seek time and whether a boundary was hit.
|
||||
static func clampSeekTime(
|
||||
currentTime: Double,
|
||||
seekDelta: Double,
|
||||
duration: Double
|
||||
) -> ClampResult {
|
||||
let targetTime = currentTime + seekDelta
|
||||
let clampedTime = targetTime.clamped(to: 0...max(0, duration))
|
||||
let hitBoundary = clampedTime != targetTime
|
||||
|
||||
return ClampResult(seekTime: clampedTime, hitBoundary: hitBoundary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Double Extension
|
||||
|
||||
private extension Double {
|
||||
/// Clamps a value to a closed range.
|
||||
func clamped(to range: ClosedRange<Double>) -> Double {
|
||||
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user