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,64 @@
//
// GesturesSettings.swift
// Yattee
//
// Combined settings for all player gestures.
//
import Foundation
/// Combined settings for player gestures.
struct GesturesSettings: Codable, Hashable, Sendable {
/// Settings for tap gestures.
var tapGestures: TapGesturesSettings
/// Settings for horizontal seek gesture.
var seekGesture: SeekGestureSettings
/// Settings for pinch-to-panscan gesture.
var panscanGesture: PanscanGestureSettings
// MARK: - Initialization
/// Creates combined gestures settings.
/// - Parameters:
/// - tapGestures: Tap gesture settings.
/// - seekGesture: Seek gesture settings.
/// - panscanGesture: Panscan gesture settings.
init(
tapGestures: TapGesturesSettings = .default,
seekGesture: SeekGestureSettings = .default,
panscanGesture: PanscanGestureSettings = .default
) {
self.tapGestures = tapGestures
self.seekGesture = seekGesture
self.panscanGesture = panscanGesture
}
// MARK: - Defaults
/// Default settings with all gestures disabled.
static let `default` = GesturesSettings()
// MARK: - Computed Properties
/// Whether tap gestures are enabled.
var areTapGesturesActive: Bool {
tapGestures.isEnabled
}
/// Whether seek gesture is enabled.
var isSeekGestureActive: Bool {
seekGesture.isEnabled
}
/// Whether panscan gesture is enabled.
var isPanscanGestureActive: Bool {
panscanGesture.isEnabled
}
/// Whether any gestures are effectively enabled.
var hasActiveGestures: Bool {
areTapGesturesActive || isSeekGestureActive || isPanscanGestureActive
}
}

View File

@@ -0,0 +1,37 @@
//
// PanscanGestureSettings.swift
// Yattee
//
// Settings for pinch-to-panscan gesture.
//
import Foundation
/// Settings for the pinch-to-panscan gesture on the player.
struct PanscanGestureSettings: Codable, Hashable, Sendable {
/// Whether the panscan gesture is enabled.
var isEnabled: Bool
/// Whether to snap to 0 (fit) or 1 (fill) when released.
/// If false, the value stays exactly where released (free zoom).
var snapToEnds: Bool
// MARK: - Initialization
/// Creates panscan gesture settings.
/// - Parameters:
/// - isEnabled: Whether enabled (default: true).
/// - snapToEnds: Whether to snap to fit/fill (default: true).
init(
isEnabled: Bool = true,
snapToEnds: Bool = true
) {
self.isEnabled = isEnabled
self.snapToEnds = snapToEnds
}
// MARK: - Defaults
/// Default settings with gesture enabled and snap mode on.
static let `default` = PanscanGestureSettings()
}

View File

@@ -0,0 +1,54 @@
//
// SeekGestureSensitivity.swift
// Yattee
//
// Sensitivity levels for horizontal seek gesture.
//
import Foundation
/// Sensitivity presets for the horizontal seek gesture.
/// Controls how much seeking occurs per screen width of drag.
enum SeekGestureSensitivity: String, Codable, CaseIterable, Hashable, Sendable {
case low
case medium
case high
// MARK: - Seek Configuration
/// Base seconds of seeking per full screen width drag.
/// This value is scaled by video duration using a multiplier.
var baseSecondsPerScreenWidth: Double {
switch self {
case .low: 30
case .medium: 60
case .high: 120
}
}
// MARK: - Display
/// Localized display name for the sensitivity level.
var displayName: String {
switch self {
case .low:
String(localized: "gestures.seek.sensitivity.low", defaultValue: "Low")
case .medium:
String(localized: "gestures.seek.sensitivity.medium", defaultValue: "Medium")
case .high:
String(localized: "gestures.seek.sensitivity.high", defaultValue: "High")
}
}
/// Localized description of what this sensitivity level is best for.
var description: String {
switch self {
case .low:
String(localized: "gestures.seek.sensitivity.low.description", defaultValue: "Precise control")
case .medium:
String(localized: "gestures.seek.sensitivity.medium.description", defaultValue: "Balanced")
case .high:
String(localized: "gestures.seek.sensitivity.high.description", defaultValue: "Fast navigation")
}
}
}

View File

@@ -0,0 +1,36 @@
//
// SeekGestureSettings.swift
// Yattee
//
// Settings for horizontal seek gesture.
//
import Foundation
/// Settings for the horizontal drag-to-seek gesture on the player.
struct SeekGestureSettings: Codable, Hashable, Sendable {
/// Whether the seek gesture is enabled.
var isEnabled: Bool
/// Sensitivity level controlling seek speed.
var sensitivity: SeekGestureSensitivity
// MARK: - Initialization
/// Creates seek gesture settings.
/// - Parameters:
/// - isEnabled: Whether enabled (default: false).
/// - sensitivity: Sensitivity level (default: medium).
init(
isEnabled: Bool = false,
sensitivity: SeekGestureSensitivity = .medium
) {
self.isEnabled = isEnabled
self.sensitivity = sensitivity
}
// MARK: - Defaults
/// Default settings with gesture disabled.
static let `default` = SeekGestureSettings()
}

View File

@@ -0,0 +1,202 @@
//
// TapGestureAction.swift
// Yattee
//
// Defines the available actions for tap gestures.
//
import Foundation
/// Action to perform when a tap gesture zone is activated.
enum TapGestureAction: Codable, Hashable, Sendable {
/// Toggle play/pause state.
case togglePlayPause
/// Seek forward by the specified number of seconds.
case seekForward(seconds: Int)
/// Seek backward by the specified number of seconds.
case seekBackward(seconds: Int)
/// Toggle fullscreen mode.
case toggleFullscreen
/// Toggle Picture-in-Picture mode.
case togglePiP
/// Play next item in queue.
case playNext
/// Play previous item in queue.
case playPrevious
/// Cycle through playback speeds.
case cyclePlaybackSpeed
/// Toggle mute state.
case toggleMute
/// Display name for the action.
var displayName: String {
switch self {
case .togglePlayPause:
String(localized: "gestures.action.togglePlayPause", defaultValue: "Toggle Play/Pause")
case .seekForward(let seconds):
String(localized: "gestures.action.seekForward", defaultValue: "Seek Forward") + " \(seconds)s"
case .seekBackward(let seconds):
String(localized: "gestures.action.seekBackward", defaultValue: "Seek Backward") + " \(seconds)s"
case .toggleFullscreen:
String(localized: "gestures.action.toggleFullscreen", defaultValue: "Toggle Fullscreen")
case .togglePiP:
String(localized: "gestures.action.togglePiP", defaultValue: "Toggle PiP")
case .playNext:
String(localized: "gestures.action.playNext", defaultValue: "Play Next")
case .playPrevious:
String(localized: "gestures.action.playPrevious", defaultValue: "Play Previous")
case .cyclePlaybackSpeed:
String(localized: "gestures.action.cyclePlaybackSpeed", defaultValue: "Cycle Playback Speed")
case .toggleMute:
String(localized: "gestures.action.toggleMute", defaultValue: "Toggle Mute")
}
}
/// SF Symbol name for the action icon.
var systemImage: String {
switch self {
case .togglePlayPause:
"playpause.fill"
case .seekForward:
"arrow.trianglehead.clockwise"
case .seekBackward:
"arrow.trianglehead.counterclockwise"
case .toggleFullscreen:
"arrow.up.left.and.arrow.down.right"
case .togglePiP:
"pip"
case .playNext:
"forward.fill"
case .playPrevious:
"backward.fill"
case .cyclePlaybackSpeed:
"gauge.with.dots.needle.67percent"
case .toggleMute:
"speaker.slash.fill"
}
}
/// Base action type for grouping (ignoring associated values).
var actionType: TapGestureActionType {
switch self {
case .togglePlayPause: .togglePlayPause
case .seekForward: .seekForward
case .seekBackward: .seekBackward
case .toggleFullscreen: .toggleFullscreen
case .togglePiP: .togglePiP
case .playNext: .playNext
case .playPrevious: .playPrevious
case .cyclePlaybackSpeed: .cyclePlaybackSpeed
case .toggleMute: .toggleMute
}
}
/// Whether this action requires a seconds parameter.
var requiresSecondsParameter: Bool {
switch self {
case .seekForward, .seekBackward:
true
default:
false
}
}
/// The seek seconds value if applicable.
var seekSeconds: Int? {
switch self {
case .seekForward(let seconds), .seekBackward(let seconds):
seconds
default:
nil
}
}
}
// MARK: - Action Type Enum
/// Base action types without associated values (for UI selection).
enum TapGestureActionType: String, CaseIterable, Identifiable, Sendable {
case togglePlayPause
case seekForward
case seekBackward
case toggleFullscreen
case togglePiP
case playNext
case playPrevious
case cyclePlaybackSpeed
case toggleMute
var id: String { rawValue }
/// Display name for the action type.
var displayName: String {
switch self {
case .togglePlayPause:
String(localized: "gestures.actionType.togglePlayPause", defaultValue: "Toggle Play/Pause")
case .seekForward:
String(localized: "gestures.actionType.seekForward", defaultValue: "Seek Forward")
case .seekBackward:
String(localized: "gestures.actionType.seekBackward", defaultValue: "Seek Backward")
case .toggleFullscreen:
String(localized: "gestures.actionType.toggleFullscreen", defaultValue: "Toggle Fullscreen")
case .togglePiP:
String(localized: "gestures.actionType.togglePiP", defaultValue: "Toggle PiP")
case .playNext:
String(localized: "gestures.actionType.playNext", defaultValue: "Play Next")
case .playPrevious:
String(localized: "gestures.actionType.playPrevious", defaultValue: "Play Previous")
case .cyclePlaybackSpeed:
String(localized: "gestures.actionType.cyclePlaybackSpeed", defaultValue: "Cycle Playback Speed")
case .toggleMute:
String(localized: "gestures.actionType.toggleMute", defaultValue: "Toggle Mute")
}
}
/// SF Symbol name for the action type.
var systemImage: String {
switch self {
case .togglePlayPause: "playpause.fill"
case .seekForward: "arrow.trianglehead.clockwise"
case .seekBackward: "arrow.trianglehead.counterclockwise"
case .toggleFullscreen: "arrow.up.left.and.arrow.down.right"
case .togglePiP: "pip"
case .playNext: "forward.fill"
case .playPrevious: "backward.fill"
case .cyclePlaybackSpeed: "gauge.with.dots.needle.67percent"
case .toggleMute: "speaker.slash.fill"
}
}
/// Whether this action type requires a seconds parameter.
var requiresSecondsParameter: Bool {
switch self {
case .seekForward, .seekBackward:
true
default:
false
}
}
/// Creates a TapGestureAction from this type with default seconds if needed.
func toAction(seconds: Int = 10) -> TapGestureAction {
switch self {
case .togglePlayPause: .togglePlayPause
case .seekForward: .seekForward(seconds: seconds)
case .seekBackward: .seekBackward(seconds: seconds)
case .toggleFullscreen: .toggleFullscreen
case .togglePiP: .togglePiP
case .playNext: .playNext
case .playPrevious: .playPrevious
case .cyclePlaybackSpeed: .cyclePlaybackSpeed
case .toggleMute: .toggleMute
}
}
}

View File

@@ -0,0 +1,115 @@
//
// TapGesturesSettings.swift
// Yattee
//
// Settings for tap gesture recognition and behavior.
//
import Foundation
/// Settings for tap gestures on the player.
struct TapGesturesSettings: Codable, Hashable, Sendable {
/// Whether tap gestures are enabled.
var isEnabled: Bool
/// The zone layout to use.
var layout: TapZoneLayout
/// Configuration for each zone in the layout.
var zoneConfigurations: [TapZoneConfiguration]
/// Double-tap timing window in milliseconds.
var doubleTapInterval: Int
// MARK: - Initialization
/// Creates tap gestures settings.
/// - Parameters:
/// - isEnabled: Whether enabled (default: false).
/// - layout: Zone layout (default: horizontalSplit).
/// - zoneConfigurations: Zone configurations.
/// - doubleTapInterval: Double-tap timing in ms (default: 300).
init(
isEnabled: Bool = false,
layout: TapZoneLayout = .horizontalSplit,
zoneConfigurations: [TapZoneConfiguration]? = nil,
doubleTapInterval: Int = 300
) {
self.isEnabled = isEnabled
self.layout = layout
self.zoneConfigurations = zoneConfigurations ?? Self.defaultConfigurations(for: layout)
self.doubleTapInterval = doubleTapInterval
}
// MARK: - Defaults
/// Default settings with gestures disabled.
static let `default` = TapGesturesSettings()
/// Creates default zone configurations for a layout.
/// - Parameter layout: The zone layout.
/// - Returns: Default configurations with sensible actions.
static func defaultConfigurations(for layout: TapZoneLayout) -> [TapZoneConfiguration] {
layout.positions.map { position in
TapZoneConfiguration(
position: position,
action: defaultAction(for: position)
)
}
}
/// Returns the default action for a zone position.
private static func defaultAction(for position: TapZonePosition) -> TapGestureAction {
switch position {
case .full:
.togglePlayPause
case .left, .leftThird, .topLeft, .bottomLeft:
.seekBackward(seconds: 10)
case .right, .rightThird, .topRight, .bottomRight:
.seekForward(seconds: 10)
case .top:
.togglePlayPause
case .bottom:
.togglePlayPause
case .center:
.togglePlayPause
}
}
// MARK: - Helpers
/// Returns the configuration for a specific position.
/// - Parameter position: The zone position.
/// - Returns: The configuration, or nil if not found.
func configuration(for position: TapZonePosition) -> TapZoneConfiguration? {
zoneConfigurations.first { $0.position == position }
}
/// Updates the configuration for a zone, or adds it if not present.
/// - Parameter config: The updated configuration.
/// - Returns: Updated settings.
func withUpdatedConfiguration(_ config: TapZoneConfiguration) -> TapGesturesSettings {
var settings = self
if let index = settings.zoneConfigurations.firstIndex(where: { $0.position == config.position }) {
settings.zoneConfigurations[index] = config
} else {
settings.zoneConfigurations.append(config)
}
return settings
}
/// Creates settings with a new layout, generating default configurations.
/// - Parameter newLayout: The new layout.
/// - Returns: Updated settings with new layout and configurations.
func withLayout(_ newLayout: TapZoneLayout) -> TapGesturesSettings {
var settings = self
settings.layout = newLayout
settings.zoneConfigurations = Self.defaultConfigurations(for: newLayout)
return settings
}
// MARK: - Validation
/// Double-tap interval range in milliseconds.
static let doubleTapIntervalRange = 150...600
}

View File

@@ -0,0 +1,42 @@
//
// TapZoneConfiguration.swift
// Yattee
//
// Configuration for a single tap zone's action.
//
import Foundation
/// Configuration for a tap zone, mapping a position to an action.
struct TapZoneConfiguration: Codable, Hashable, Sendable, Identifiable {
/// Unique identifier for this configuration.
var id: UUID
/// The position of this zone within the layout.
var position: TapZonePosition
/// The action to perform when this zone is double-tapped.
var action: TapGestureAction
/// Creates a new tap zone configuration.
/// - Parameters:
/// - id: Unique identifier (defaults to new UUID).
/// - position: Zone position.
/// - action: Action to perform.
init(
id: UUID = UUID(),
position: TapZonePosition,
action: TapGestureAction
) {
self.id = id
self.position = position
self.action = action
}
/// Creates a configuration with a new action, preserving the ID.
/// - Parameter newAction: The new action.
/// - Returns: Updated configuration.
func withAction(_ newAction: TapGestureAction) -> TapZoneConfiguration {
TapZoneConfiguration(id: id, position: position, action: newAction)
}
}

View File

@@ -0,0 +1,90 @@
//
// TapZoneLayout.swift
// Yattee
//
// Defines the available tap zone layouts for gesture recognition.
//
import Foundation
/// Layout options for tap gesture zones on the player.
enum TapZoneLayout: String, Codable, CaseIterable, Sendable, Identifiable {
/// Single full-screen zone.
case single
/// Left and right zones (vertical split).
case horizontalSplit
/// Top and bottom zones (horizontal split).
case verticalSplit
/// Three vertical columns: left, center, right.
case threeColumns
/// Four quadrants: top-left, top-right, bottom-left, bottom-right.
case quadrants
var id: String { rawValue }
/// Number of zones in this layout.
var zoneCount: Int {
switch self {
case .single:
1
case .horizontalSplit, .verticalSplit:
2
case .threeColumns:
3
case .quadrants:
4
}
}
/// The zone positions available in this layout.
var positions: [TapZonePosition] {
switch self {
case .single:
[.full]
case .horizontalSplit:
[.left, .right]
case .verticalSplit:
[.top, .bottom]
case .threeColumns:
[.leftThird, .center, .rightThird]
case .quadrants:
[.topLeft, .topRight, .bottomLeft, .bottomRight]
}
}
/// Display name for the layout.
var displayName: String {
switch self {
case .single:
String(localized: "gestures.layout.single", defaultValue: "Single Zone")
case .horizontalSplit:
String(localized: "gestures.layout.horizontalSplit", defaultValue: "Left / Right")
case .verticalSplit:
String(localized: "gestures.layout.verticalSplit", defaultValue: "Top / Bottom")
case .threeColumns:
String(localized: "gestures.layout.threeColumns", defaultValue: "Three Columns")
case .quadrants:
String(localized: "gestures.layout.quadrants", defaultValue: "Four Quadrants")
}
}
/// Short description showing zone arrangement.
var layoutDescription: String {
switch self {
case .single:
"1"
case .horizontalSplit:
"2x1"
case .verticalSplit:
"1x2"
case .threeColumns:
"1x3"
case .quadrants:
"2x2"
}
}
}

View File

@@ -0,0 +1,65 @@
//
// TapZonePosition.swift
// Yattee
//
// Defines the position identifiers for tap zones.
//
import Foundation
/// Position identifier for a tap zone within a layout.
enum TapZonePosition: String, Codable, CaseIterable, Sendable, Identifiable {
// Single layout
case full
// Horizontal split (2x1)
case left
case right
// Vertical split (1x2)
case top
case bottom
// Three columns (1x3)
case leftThird
case center
case rightThird
// Quadrants (2x2)
case topLeft
case topRight
case bottomLeft
case bottomRight
var id: String { rawValue }
/// Display name for the zone position.
var displayName: String {
switch self {
case .full:
String(localized: "gestures.zone.full", defaultValue: "Tap Zone")
case .left:
String(localized: "gestures.zone.left", defaultValue: "Left")
case .right:
String(localized: "gestures.zone.right", defaultValue: "Right")
case .top:
String(localized: "gestures.zone.top", defaultValue: "Top")
case .bottom:
String(localized: "gestures.zone.bottom", defaultValue: "Bottom")
case .leftThird:
String(localized: "gestures.zone.leftThird", defaultValue: "Left")
case .center:
String(localized: "gestures.zone.center", defaultValue: "Center")
case .rightThird:
String(localized: "gestures.zone.rightThird", defaultValue: "Right")
case .topLeft:
String(localized: "gestures.zone.topLeft", defaultValue: "Top-Left")
case .topRight:
String(localized: "gestures.zone.topRight", defaultValue: "Top-Right")
case .bottomLeft:
String(localized: "gestures.zone.bottomLeft", defaultValue: "Bottom-Left")
case .bottomRight:
String(localized: "gestures.zone.bottomRight", defaultValue: "Bottom-Right")
}
}
}