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,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())
}
}

View 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")
}

View File

@@ -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
}
}
}

View 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

View 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)
}
}