Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent d94a50f8c3
commit 100df744d9
1043 changed files with 163886 additions and 68471 deletions

View File

@@ -0,0 +1,229 @@
//
// BuiltInPresets.swift
// Yattee
//
// Factory methods for built-in layout presets.
//
import Foundation
extension LayoutPreset {
/// Bump this version whenever any built-in preset definition changes.
/// On launch, the app compares this against the last-applied version
/// and replaces stale built-in presets with fresh copies from code.
static let builtInPresetsVersion = 3
// MARK: - Built-in Preset IDs
/// Stable UUIDs for built-in presets to ensure consistency across devices.
private enum BuiltInID {
static let defaultPreset = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!
static let minimalPreset = UUID(uuidString: "00000000-0000-0000-0000-000000000002")!
}
// MARK: - Built-in Presets
/// Default preset with balanced controls for general use.
static func defaultPreset(for deviceClass: DeviceClass = .current) -> LayoutPreset {
// Top buttons: titleAuthor (wideOnly), spacer, orientationLock, close
let topButtons: [ControlButtonConfiguration] = [
ControlButtonConfiguration(
buttonType: .titleAuthor,
visibilityMode: .wideOnly
),
.flexibleSpacer(),
.defaultConfiguration(for: .orientationLock),
.defaultConfiguration(for: .close)
]
// Bottom buttons: time, queue (wideOnly), playNext (wideOnly), spacer, contextMenu (wideOnly), settings, PiP, fullscreen
let bottomButtons: [ControlButtonConfiguration] = [
ControlButtonConfiguration(
buttonType: .timeDisplay,
settings: .timeDisplay(TimeDisplaySettings(format: .currentAndTotal))
),
ControlButtonConfiguration(
buttonType: .queue,
visibilityMode: .wideOnly
),
ControlButtonConfiguration(
buttonType: .playNext,
visibilityMode: .wideOnly
),
.flexibleSpacer(),
ControlButtonConfiguration(
buttonType: .contextMenu,
visibilityMode: .wideOnly
),
.defaultConfiguration(for: .settings),
.defaultConfiguration(for: .pictureInPicture),
.defaultConfiguration(for: .fullscreen)
]
// Center: all enabled, 10s seek, left=volume, right=brightness
let centerSettings = CenterSectionSettings(
showPlayPause: true,
showSeekBackward: true,
showSeekForward: true,
seekBackwardSeconds: 10,
seekForwardSeconds: 10,
leftSlider: .volume,
rightSlider: .brightness
)
// Global: plain style, medium buttons, system font
let globalSettings = GlobalLayoutSettings(
style: .plain,
buttonSize: .medium,
fontStyle: .system,
systemControlsMode: .seek,
systemControlsSeekDuration: .tenSeconds,
volumeMode: .mpv
)
// Gestures: tap 2x1 with seek, seek enabled, panscan with snap
let gesturesSettings = GesturesSettings(
tapGestures: TapGesturesSettings(
isEnabled: true,
layout: .horizontalSplit,
zoneConfigurations: [
TapZoneConfiguration(position: .left, action: .seekBackward(seconds: 10)),
TapZoneConfiguration(position: .right, action: .seekForward(seconds: 10))
]
),
seekGesture: SeekGestureSettings(isEnabled: false),
panscanGesture: PanscanGestureSettings(isEnabled: true, snapToEnds: true)
)
// Player pill: queue, previous, play/pause, next, close
let playerPillSettings = PlayerPillSettings(
visibility: .portraitOnly,
buttons: [
ControlButtonConfiguration(buttonType: .queue),
ControlButtonConfiguration(buttonType: .playPrevious),
ControlButtonConfiguration(buttonType: .playPause),
ControlButtonConfiguration(buttonType: .playNext),
ControlButtonConfiguration(buttonType: .close)
]
)
// Mini player: show video, tap for PiP
let miniPlayerSettings = MiniPlayerSettings()
let layout = PlayerControlsLayout(
topSection: LayoutSection(buttons: topButtons),
centerSettings: centerSettings,
bottomSection: LayoutSection(buttons: bottomButtons),
globalSettings: globalSettings,
progressBarSettings: ProgressBarSettings(),
gesturesSettings: gesturesSettings,
playerPillSettings: playerPillSettings,
miniPlayerSettings: miniPlayerSettings
)
return LayoutPreset(
id: BuiltInID.defaultPreset,
name: String(localized: "controls.preset.default"),
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0),
isBuiltIn: true,
deviceClass: deviceClass,
layout: layout
)
}
/// Minimal preset with stripped-down controls for distraction-free playback.
static func minimalPreset(for deviceClass: DeviceClass = .current) -> LayoutPreset {
let topButtons: [ControlButtonConfiguration] = [
.flexibleSpacer(),
.defaultConfiguration(for: .close)
]
let bottomButtons: [ControlButtonConfiguration] = [
ControlButtonConfiguration(
buttonType: .timeDisplay,
settings: .timeDisplay(TimeDisplaySettings(format: .currentAndTotal))
),
.flexibleSpacer(),
.defaultConfiguration(for: .settings),
.defaultConfiguration(for: .pictureInPicture),
.defaultConfiguration(for: .fullscreen)
]
let centerSettings = CenterSectionSettings(
showPlayPause: true,
showSeekBackward: true,
showSeekForward: true,
seekBackwardSeconds: 10,
seekForwardSeconds: 10,
leftSlider: .disabled,
rightSlider: .disabled
)
let globalSettings = GlobalLayoutSettings(
style: .plain,
buttonSize: .medium,
fontStyle: .system,
systemControlsMode: .seek,
systemControlsSeekDuration: .tenSeconds,
volumeMode: .mpv
)
let gesturesSettings = GesturesSettings(
tapGestures: TapGesturesSettings(
isEnabled: true,
layout: .horizontalSplit,
zoneConfigurations: [
TapZoneConfiguration(position: .left, action: .seekBackward(seconds: 10)),
TapZoneConfiguration(position: .right, action: .seekForward(seconds: 10))
]
),
seekGesture: SeekGestureSettings(isEnabled: false),
panscanGesture: PanscanGestureSettings(isEnabled: true, snapToEnds: true)
)
let playerPillSettings = PlayerPillSettings(
visibility: .never,
buttons: [
ControlButtonConfiguration(buttonType: .queue),
ControlButtonConfiguration(buttonType: .playPrevious),
ControlButtonConfiguration(buttonType: .playPause),
ControlButtonConfiguration(buttonType: .playNext),
ControlButtonConfiguration(buttonType: .close)
]
)
let miniPlayerSettings = MiniPlayerSettings()
let progressBarSettings = ProgressBarSettings(
showChapters: false,
sponsorBlockSettings: SponsorBlockSegmentSettings(showSegments: false)
)
let layout = PlayerControlsLayout(
topSection: LayoutSection(buttons: topButtons),
centerSettings: centerSettings,
bottomSection: LayoutSection(buttons: bottomButtons),
globalSettings: globalSettings,
progressBarSettings: progressBarSettings,
gesturesSettings: gesturesSettings,
playerPillSettings: playerPillSettings,
miniPlayerSettings: miniPlayerSettings
)
return LayoutPreset(
id: BuiltInID.minimalPreset,
name: String(localized: "controls.preset.minimal"),
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0),
isBuiltIn: true,
deviceClass: deviceClass,
layout: layout
)
}
/// All built-in presets for the given device class.
static func allBuiltIn(for deviceClass: DeviceClass = .current) -> [LayoutPreset] {
[defaultPreset(for: deviceClass), minimalPreset(for: deviceClass)]
}
}

View File

@@ -0,0 +1,275 @@
//
// ButtonSettings.swift
// Yattee
//
// Settings types for configurable control buttons.
//
import Foundation
// MARK: - Spacer Settings
/// Configuration for spacer elements in the control layout.
struct SpacerSettings: Codable, Hashable, Sendable {
/// Whether the spacer is flexible (expands to fill available space).
var isFlexible: Bool
/// Fixed width in points when not flexible. Range: 4-100, step: 2.
var fixedWidth: Int
/// Default spacer settings (flexible).
init(isFlexible: Bool = true, fixedWidth: Int = 20) {
self.isFlexible = isFlexible
self.fixedWidth = Self.clampWidth(fixedWidth)
}
/// Clamps width to valid range (4-100) with 2pt step.
static func clampWidth(_ width: Int) -> Int {
let clamped = max(4, min(100, width))
return (clamped / 2) * 2
}
}
// MARK: - Slider Behavior
/// How a slider control (brightness/volume) behaves.
enum SliderBehavior: String, Codable, Hashable, Sendable, CaseIterable {
/// Button and slider are always visible together.
case alwaysVisible
/// Only button is visible; slider expands on tap.
case expandOnTap
/// Slider visible in landscape, expand on tap in portrait.
case autoExpandInLandscape
/// Localized display name.
var displayName: String {
switch self {
case .alwaysVisible:
return String(localized: "controls.slider.alwaysVisible")
case .expandOnTap:
return String(localized: "controls.slider.expandOnTap")
case .autoExpandInLandscape:
return String(localized: "controls.slider.autoExpandInLandscape")
}
}
}
/// Configuration for slider buttons (brightness, volume).
struct SliderSettings: Codable, Hashable, Sendable {
/// How the slider behaves.
var sliderBehavior: SliderBehavior
/// Default slider settings (expand on tap).
init(sliderBehavior: SliderBehavior = .expandOnTap) {
self.sliderBehavior = sliderBehavior
}
}
// MARK: - Seek Direction
/// Direction for seek buttons in horizontal sections.
enum SeekDirection: String, Codable, Hashable, Sendable, CaseIterable {
case backward
case forward
/// Localized display name.
var displayName: String {
switch self {
case .backward:
return String(localized: "controls.seek.backward")
case .forward:
return String(localized: "controls.seek.forward")
}
}
}
// MARK: - Seek Settings
/// Configuration for seek buttons (backward/forward).
struct SeekSettings: Codable, Hashable, Sendable {
/// Number of seconds to seek. Default: 10.
var seconds: Int
/// Direction for horizontal section seek buttons. Default: .forward.
var direction: SeekDirection
/// Seek seconds that have dedicated SF Symbol icons.
private static let validSeekIconValues = [5, 10, 15, 30, 45, 60, 75, 90]
/// SF Symbol name for this seek button based on configured seconds and direction.
/// Uses numbered icon for standard values (5, 10, 15, 30, 45, 60, 75, 90),
/// plain arrow for other values.
var systemImage: String {
let arrowDirection = direction == .backward ? "counterclockwise" : "clockwise"
if Self.validSeekIconValues.contains(seconds) {
return "\(seconds).arrow.trianglehead.\(arrowDirection)"
}
return "arrow.trianglehead.\(arrowDirection)"
}
/// Default seek settings (10 seconds, forward direction).
init(seconds: Int = 10, direction: SeekDirection = .forward) {
self.seconds = max(1, seconds)
self.direction = direction
}
// MARK: - Codable (backward compatibility)
private enum CodingKeys: String, CodingKey {
case seconds
case direction
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
seconds = try container.decode(Int.self, forKey: .seconds)
// Default to .forward for backward compatibility with existing configs
direction = try container.decodeIfPresent(SeekDirection.self, forKey: .direction) ?? .forward
}
}
// MARK: - Title/Author Settings
/// Configuration for the title/author button.
struct TitleAuthorSettings: Codable, Hashable, Sendable {
/// Whether to show the source/author image.
var showSourceImage: Bool
/// Whether to show the video title.
var showTitle: Bool
/// Whether to show the source/author name.
var showSourceName: Bool
/// Default settings (all visible).
init(showSourceImage: Bool = true, showTitle: Bool = true, showSourceName: Bool = true) {
self.showSourceImage = showSourceImage
self.showTitle = showTitle
self.showSourceName = showSourceName
}
}
// MARK: - Time Display Format
/// How the time display shows current/total time.
enum TimeDisplayFormat: String, Codable, Hashable, Sendable, CaseIterable {
/// Shows only current time (e.g., "5:32").
case currentOnly
/// Shows current and total time (e.g., "5:32 / 12:45").
case currentAndTotal
/// Shows current and total, excluding SponsorBlock segments (e.g., "5:32 / 11:20").
case currentAndTotalExcludingSponsor
/// Shows current and remaining time (e.g., "5:32 / -7:13").
case currentAndRemaining
/// Shows current and remaining, excluding SponsorBlock segments.
case currentAndRemainingExcludingSponsor
/// Localized display name.
var displayName: String {
switch self {
case .currentOnly:
return String(localized: "controls.time.currentOnly")
case .currentAndTotal:
return String(localized: "controls.time.currentAndTotal")
case .currentAndTotalExcludingSponsor:
return String(localized: "controls.time.currentAndTotalExcludingSponsor")
case .currentAndRemaining:
return String(localized: "controls.time.currentAndRemaining")
case .currentAndRemainingExcludingSponsor:
return String(localized: "controls.time.currentAndRemainingExcludingSponsor")
}
}
}
/// Configuration for time display.
struct TimeDisplaySettings: Codable, Hashable, Sendable {
/// Format for displaying time.
var format: TimeDisplayFormat
/// Default time display settings.
init(format: TimeDisplayFormat = .currentAndTotal) {
self.format = format
}
}
// MARK: - Button Settings
/// Settings for a configurable control button.
/// Uses associated values for type-specific settings.
enum ButtonSettings: Codable, Hashable, Sendable {
case spacer(SpacerSettings)
case slider(SliderSettings)
case seek(SeekSettings)
case timeDisplay(TimeDisplaySettings)
case titleAuthor(TitleAuthorSettings)
// MARK: - Codable
private enum CodingKeys: String, CodingKey {
case type
case spacer
case slider
case seek
case timeDisplay
case titleAuthor
}
private enum SettingsType: String, Codable {
case spacer
case slider
case seek
case timeDisplay
case titleAuthor
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(SettingsType.self, forKey: .type)
switch type {
case .spacer:
let settings = try container.decode(SpacerSettings.self, forKey: .spacer)
self = .spacer(settings)
case .slider:
let settings = try container.decode(SliderSettings.self, forKey: .slider)
self = .slider(settings)
case .seek:
let settings = try container.decode(SeekSettings.self, forKey: .seek)
self = .seek(settings)
case .timeDisplay:
let settings = try container.decode(TimeDisplaySettings.self, forKey: .timeDisplay)
self = .timeDisplay(settings)
case .titleAuthor:
let settings = try container.decode(TitleAuthorSettings.self, forKey: .titleAuthor)
self = .titleAuthor(settings)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .spacer(let settings):
try container.encode(SettingsType.spacer, forKey: .type)
try container.encode(settings, forKey: .spacer)
case .slider(let settings):
try container.encode(SettingsType.slider, forKey: .type)
try container.encode(settings, forKey: .slider)
case .seek(let settings):
try container.encode(SettingsType.seek, forKey: .type)
try container.encode(settings, forKey: .seek)
case .timeDisplay(let settings):
try container.encode(SettingsType.timeDisplay, forKey: .type)
try container.encode(settings, forKey: .timeDisplay)
case .titleAuthor(let settings):
try container.encode(SettingsType.titleAuthor, forKey: .type)
try container.encode(settings, forKey: .titleAuthor)
}
}
}

View File

@@ -0,0 +1,116 @@
//
// CenterSectionSettings.swift
// Yattee
//
// Settings for the center section of player controls (play/pause, seek buttons).
//
import Foundation
/// Configuration for the center section of player controls.
/// Unlike top/bottom sections, center uses simple toggles rather than drag-and-drop.
struct CenterSectionSettings: Codable, Hashable, Sendable {
/// Whether to show the play/pause button.
var showPlayPause: Bool
/// Whether to show the seek backward button.
var showSeekBackward: Bool
/// Whether to show the seek forward button.
var showSeekForward: Bool
/// Number of seconds for the seek backward button.
var seekBackwardSeconds: Int
/// Number of seconds for the seek forward button.
var seekForwardSeconds: Int
/// Type of slider to show on the left edge of the player (iOS only).
var leftSlider: SideSliderType
/// Type of slider to show on the right edge of the player (iOS only).
var rightSlider: SideSliderType
// MARK: - Initialization
/// Creates center section settings.
/// - Parameters:
/// - showPlayPause: Show play/pause button. Defaults to true.
/// - showSeekBackward: Show seek backward button. Defaults to true.
/// - showSeekForward: Show seek forward button. Defaults to true.
/// - seekBackwardSeconds: Seconds to seek backward. Defaults to 10.
/// - seekForwardSeconds: Seconds to seek forward. Defaults to 10.
/// - leftSlider: Type of slider on left edge. Defaults to disabled.
/// - rightSlider: Type of slider on right edge. Defaults to disabled.
init(
showPlayPause: Bool = true,
showSeekBackward: Bool = true,
showSeekForward: Bool = true,
seekBackwardSeconds: Int = 10,
seekForwardSeconds: Int = 10,
leftSlider: SideSliderType = .disabled,
rightSlider: SideSliderType = .disabled
) {
self.showPlayPause = showPlayPause
self.showSeekBackward = showSeekBackward
self.showSeekForward = showSeekForward
self.seekBackwardSeconds = max(1, seekBackwardSeconds)
self.seekForwardSeconds = max(1, seekForwardSeconds)
self.leftSlider = leftSlider
self.rightSlider = rightSlider
}
// MARK: - Codable
private enum CodingKeys: String, CodingKey {
case showPlayPause
case showSeekBackward
case showSeekForward
case seekBackwardSeconds
case seekForwardSeconds
case leftSlider
case rightSlider
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
showPlayPause = try container.decode(Bool.self, forKey: .showPlayPause)
showSeekBackward = try container.decode(Bool.self, forKey: .showSeekBackward)
showSeekForward = try container.decode(Bool.self, forKey: .showSeekForward)
seekBackwardSeconds = try container.decode(Int.self, forKey: .seekBackwardSeconds)
seekForwardSeconds = try container.decode(Int.self, forKey: .seekForwardSeconds)
// New properties with defaults for backward compatibility
leftSlider = try container.decodeIfPresent(SideSliderType.self, forKey: .leftSlider) ?? .disabled
rightSlider = try container.decodeIfPresent(SideSliderType.self, forKey: .rightSlider) ?? .disabled
}
// MARK: - Defaults
/// Default center section settings.
static let `default` = CenterSectionSettings()
// MARK: - SF Symbol Names
/// Seek seconds that have dedicated SF Symbol icons.
private static let validSeekIconValues = [5, 10, 15, 30, 45, 60, 75, 90]
/// SF Symbol name for the seek backward button based on configured seconds.
/// Uses numbered icon for standard values (5, 10, 15, 30, 45, 60, 75, 90),
/// plain arrow for other values.
var seekBackwardSystemImage: String {
if Self.validSeekIconValues.contains(seekBackwardSeconds) {
return "\(seekBackwardSeconds).arrow.trianglehead.counterclockwise"
}
return "arrow.trianglehead.counterclockwise"
}
/// SF Symbol name for the seek forward button based on configured seconds.
/// Uses numbered icon for standard values (5, 10, 15, 30, 45, 60, 75, 90),
/// plain arrow for other values.
var seekForwardSystemImage: String {
if Self.validSeekIconValues.contains(seekForwardSeconds) {
return "\(seekForwardSeconds).arrow.trianglehead.clockwise"
}
return "arrow.trianglehead.clockwise"
}
}

View File

@@ -0,0 +1,103 @@
//
// ControlButtonConfiguration.swift
// Yattee
//
// Configuration for a single control button in the player layout.
//
import Foundation
/// Configuration for a single control button, including its type, visibility, and settings.
struct ControlButtonConfiguration: Identifiable, Codable, Hashable, Sendable {
/// Unique identifier for this button configuration.
let id: UUID
/// The type of control button.
let buttonType: ControlButtonType
/// When this button should be visible based on orientation.
var visibilityMode: VisibilityMode
/// Type-specific settings for this button (if applicable).
var settings: ButtonSettings?
// MARK: - Initialization
/// Creates a new button configuration.
/// - Parameters:
/// - id: Unique identifier. Defaults to a new UUID.
/// - buttonType: The type of control button.
/// - visibilityMode: When to show the button. Defaults to `.both`.
/// - settings: Type-specific settings. Defaults to the button type's default settings.
init(
id: UUID = UUID(),
buttonType: ControlButtonType,
visibilityMode: VisibilityMode = .both,
settings: ButtonSettings? = nil
) {
self.id = id
self.buttonType = buttonType
self.visibilityMode = visibilityMode
self.settings = settings ?? buttonType.defaultSettings
}
// MARK: - Factory Methods
/// Creates a default configuration for the given button type.
/// - Parameter type: The button type.
/// - Returns: A new configuration with default settings.
static func defaultConfiguration(for type: ControlButtonType) -> ControlButtonConfiguration {
ControlButtonConfiguration(buttonType: type)
}
/// Creates a configuration for a flexible spacer.
/// - Returns: A spacer configuration with flexible width.
static func flexibleSpacer() -> ControlButtonConfiguration {
ControlButtonConfiguration(
buttonType: .spacer,
settings: .spacer(SpacerSettings(isFlexible: true))
)
}
/// Creates a configuration for a fixed-width spacer.
/// - Parameter width: The fixed width in points.
/// - Returns: A spacer configuration with fixed width.
static func fixedSpacer(width: Int) -> ControlButtonConfiguration {
ControlButtonConfiguration(
buttonType: .spacer,
settings: .spacer(SpacerSettings(isFlexible: false, fixedWidth: width))
)
}
// MARK: - Convenience Accessors
/// Returns the spacer settings if this is a spacer button.
var spacerSettings: SpacerSettings? {
guard case .spacer(let settings) = settings else { return nil }
return settings
}
/// Returns the slider settings if this is a slider button.
var sliderSettings: SliderSettings? {
guard case .slider(let settings) = settings else { return nil }
return settings
}
/// Returns the seek settings if this is a seek button.
var seekSettings: SeekSettings? {
guard case .seek(let settings) = settings else { return nil }
return settings
}
/// Returns the time display settings if this is a time display.
var timeDisplaySettings: TimeDisplaySettings? {
guard case .timeDisplay(let settings) = settings else { return nil }
return settings
}
/// Returns the title/author settings if this is a title/author button.
var titleAuthorSettings: TitleAuthorSettings? {
guard case .titleAuthor(let settings) = settings else { return nil }
return settings
}
}

View File

@@ -0,0 +1,308 @@
//
// ControlButtonType.swift
// Yattee
//
// Defines all available control button types for player customization.
//
import Foundation
/// All available control button types for player layout customization.
enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
case close
case airplay
case mpvDebug
case brightness
case volume
case pictureInPicture
case fullscreen
case playbackSpeed
case videoTrack
case audioTrack
case captions
case chapters
case share
case addToPlaylist
case contextMenu
case spacer
case timeDisplay
case seekBackward
case seekForward
case playPause
case orientationLock
case panelToggle
case playPrevious
case playNext
case queue
case settings
case controlsLock
case titleAuthor
case panscan
case autoPlayNext
case seek
// MARK: - Version Tracking
/// The app version when this button was added.
/// Used for showing "NEW" badges on recently added buttons.
var versionAdded: Int {
// No need to add versions yet
return 1
}
// MARK: - Display Properties
/// Localized display name for the button.
var displayName: String {
switch self {
case .close:
return String(localized: "controls.button.close")
case .airplay:
return String(localized: "controls.button.airplay")
case .mpvDebug:
return String(localized: "controls.button.debug")
case .brightness:
return String(localized: "controls.button.brightness")
case .volume:
return String(localized: "controls.button.volume")
case .pictureInPicture:
return String(localized: "controls.button.pip")
case .fullscreen:
return String(localized: "controls.button.fullscreen")
case .playbackSpeed:
return String(localized: "controls.button.speed")
case .videoTrack:
return String(localized: "controls.button.videoTrack")
case .audioTrack:
return String(localized: "controls.button.audioTrack")
case .captions:
return String(localized: "controls.button.captions")
case .chapters:
return String(localized: "controls.button.chapters")
case .share:
return String(localized: "controls.button.share")
case .addToPlaylist:
return String(localized: "controls.button.addToPlaylist")
case .contextMenu:
return String(localized: "controls.button.more")
case .spacer:
return String(localized: "controls.button.spacer")
case .timeDisplay:
return String(localized: "controls.button.time")
case .seekBackward:
return String(localized: "controls.button.seekBackward")
case .seekForward:
return String(localized: "controls.button.seekForward")
case .playPause:
return String(localized: "controls.button.playPause")
case .orientationLock:
return String(localized: "controls.button.orientationLock")
case .panelToggle:
return String(localized: "controls.button.panelToggle")
case .playPrevious:
return String(localized: "controls.button.playPrevious")
case .playNext:
return String(localized: "controls.button.playNext")
case .queue:
return String(localized: "controls.button.queue")
case .settings:
return String(localized: "controls.button.settings")
case .controlsLock:
return String(localized: "controls.button.controlsLock")
case .titleAuthor:
return String(localized: "controls.button.titleAuthor")
case .panscan:
return String(localized: "controls.button.panscan")
case .autoPlayNext:
return String(localized: "controls.button.autoPlayNext")
case .seek:
return String(localized: "controls.button.seek")
}
}
/// SF Symbol name for the button icon.
var systemImage: String {
switch self {
case .close:
return "xmark"
case .airplay:
return "airplayaudio"
case .mpvDebug:
return "info.circle"
case .brightness:
return "sun.max.fill"
case .volume:
return "speaker.wave.2.fill"
case .pictureInPicture:
return "pip.enter"
case .fullscreen:
return "arrow.up.left.and.arrow.down.right"
case .playbackSpeed:
return "gauge.with.needle"
case .videoTrack:
return "film"
case .audioTrack:
return "waveform"
case .captions:
return "captions.bubble"
case .chapters:
return "list.bullet.rectangle"
case .share:
return "square.and.arrow.up"
case .addToPlaylist:
return "text.badge.plus"
case .contextMenu:
return "ellipsis"
case .spacer:
return "arrow.left.and.right"
case .timeDisplay:
return "clock"
case .seekBackward:
return "10.arrow.trianglehead.counterclockwise"
case .seekForward:
return "10.arrow.trianglehead.clockwise"
case .playPause:
return "play.fill"
case .orientationLock:
return "lock.rotation"
case .panelToggle:
return "sidebar.trailing"
case .playPrevious:
return "backward.fill"
case .playNext:
return "forward.fill"
case .queue:
return "list.bullet"
case .settings:
return "gearshape"
case .controlsLock:
return "lock"
case .titleAuthor:
return "text.below.photo"
case .panscan:
return "arrow.left.and.right.square"
case .autoPlayNext:
return "play.square.stack.fill"
case .seek:
return "goforward.10" // Default icon, actual icon is determined by settings
}
}
// MARK: - Configuration
/// Whether this button type has configurable settings.
var hasSettings: Bool {
switch self {
case .spacer, .brightness, .volume, .seekBackward, .seekForward, .timeDisplay, .titleAuthor, .seek:
return true
default:
return false
}
}
/// Default settings for this button type, if applicable.
var defaultSettings: ButtonSettings? {
switch self {
case .spacer:
return .spacer(SpacerSettings())
case .brightness, .volume:
return .slider(SliderSettings())
case .seekBackward, .seekForward:
return .seek(SeekSettings())
case .timeDisplay:
return .timeDisplay(TimeDisplaySettings())
case .titleAuthor:
return .titleAuthor(TitleAuthorSettings())
case .seek:
return .seek(SeekSettings())
default:
return nil
}
}
// MARK: - Section Availability
/// Button types available for top/bottom sections.
static var availableForHorizontalSections: [ControlButtonType] {
[
.spacer,
.timeDisplay,
.titleAuthor,
.playPrevious,
.playPause,
.playNext,
.seek,
.queue,
.close,
.brightness,
.volume,
.pictureInPicture,
.fullscreen,
.orientationLock,
.controlsLock,
.settings,
.videoTrack,
.audioTrack,
.captions,
.chapters,
.playbackSpeed,
.addToPlaylist,
.contextMenu,
.share,
.panelToggle,
.panscan,
.autoPlayNext,
.airplay,
.mpvDebug
]
}
/// Button types for center section (play/pause, seek).
static var availableForCenterSection: [ControlButtonType] {
[.playPause, .seekBackward, .seekForward]
}
/// Button types available for the player pill (curated subset).
static var availableForPill: [ControlButtonType] {
[
// Transport
.playPause,
.playPrevious,
.playNext,
.seek,
// Queue & Playlist
.queue,
.addToPlaylist,
// Player Actions
.close,
.share,
.airplay,
.pictureInPicture,
// Utility
.orientationLock,
.playbackSpeed,
.fullscreen
]
}
/// Button types available for the mini player (curated subset for compact UI).
static var availableForMiniPlayer: [ControlButtonType] {
[
// Transport
.playPause,
.playPrevious,
.playNext,
.seek,
// Queue & Actions
.queue,
.close,
// Player Actions
.share,
.addToPlaylist,
.airplay,
.pictureInPicture,
// Utility
.playbackSpeed
]
}
}

View File

@@ -0,0 +1,48 @@
//
// DeviceClass.swift
// Yattee
//
// Device class for platform-specific layout sync.
//
import Foundation
/// Device class used for platform-specific layout sync.
/// Layouts only sync between devices of the same class.
enum DeviceClass: String, Codable, Hashable, Sendable {
/// iPhone and iPad share iOS class.
case iOS
/// macOS devices.
case macOS
/// Apple TV devices.
case tvOS
// MARK: - Current Device
/// The device class for the current platform.
static var current: DeviceClass {
#if os(iOS)
return .iOS
#elseif os(macOS)
return .macOS
#elseif os(tvOS)
return .tvOS
#endif
}
// MARK: - Display
/// Localized display name for the device class.
var displayName: String {
switch self {
case .iOS:
return "iOS"
case .macOS:
return "macOS"
case .tvOS:
return "tvOS"
}
}
}

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

View File

@@ -0,0 +1,487 @@
//
// GlobalLayoutSettings.swift
// Yattee
//
// Global settings that apply to all control buttons in the player layout.
//
import Foundation
import SwiftUI
/// Theme options for player controls appearance.
enum ControlsTheme: String, Codable, Hashable, Sendable, CaseIterable {
/// Follow system appearance
case system
/// Always use light appearance
case light
/// Always use dark appearance
case dark
/// Localized display name.
var displayName: String {
switch self {
case .system:
return String(localized: "controls.theme.system")
case .light:
return String(localized: "controls.theme.light")
case .dark:
return String(localized: "controls.theme.dark")
}
}
/// Returns the ColorScheme to apply, or nil for system.
var colorScheme: ColorScheme? {
switch self {
case .system:
return nil
case .light:
return .light
case .dark:
return .dark
}
}
}
/// Size options for control buttons.
enum ButtonSize: String, Codable, Hashable, Sendable, CaseIterable {
case small
case medium
case large
/// The point size for buttons at this size.
var pointSize: CGFloat {
switch self {
case .small:
return 36
case .medium:
return 44
case .large:
return 52
}
}
/// The icon size relative to button size.
var iconSize: CGFloat {
switch self {
case .small:
return 18
case .medium:
return 22
case .large:
return 26
}
}
/// Localized display name.
var displayName: String {
switch self {
case .small:
return String(localized: "controls.size.small")
case .medium:
return String(localized: "controls.size.medium")
case .large:
return String(localized: "controls.size.large")
}
}
}
/// Background style options for control buttons.
enum ButtonBackgroundStyle: String, Codable, Hashable, Sendable, CaseIterable {
/// No background (default, current behavior)
case none
/// Clear glass - subtle circular glass background
case clearGlass
/// Regular glass - more visible circular glass background
case regularGlass
/// Localized display name.
var displayName: String {
switch self {
case .none:
return String(localized: "controls.buttonBackground.none")
case .clearGlass:
return String(localized: "controls.buttonBackground.clearGlass")
case .regularGlass:
return String(localized: "controls.buttonBackground.regularGlass")
}
}
/// The GlassStyle to use for this background style.
var glassStyle: GlassStyle? {
switch self {
case .none:
return nil
case .clearGlass:
return .clear
case .regularGlass:
return .regular
}
}
}
/// Overall style for player controls appearance.
enum ControlsStyle: String, Codable, Hashable, Sendable, CaseIterable {
/// No background, follows system theme
case plain
/// Regular glass background, always dark theme
case glass
/// Localized display name.
var displayName: String {
switch self {
case .plain:
return String(localized: "controls.style.plain")
case .glass:
return String(localized: "controls.style.glass")
}
}
/// The theme to apply for this style.
var theme: ControlsTheme {
switch self {
case .plain:
return .system
case .glass:
return .dark
}
}
/// The button background to apply for this style.
var buttonBackground: ButtonBackgroundStyle {
switch self {
case .plain:
return .none
case .glass:
return .regularGlass
}
}
}
// MARK: - Codable Color
/// A color stored as RGBA components for Codable support.
struct CodableColor: Codable, Hashable, Sendable {
var red: Double
var green: Double
var blue: Double
var opacity: Double
/// Creates a CodableColor from RGBA components.
/// - Parameters:
/// - red: Red component (0-1).
/// - green: Green component (0-1).
/// - blue: Blue component (0-1).
/// - opacity: Opacity component (0-1). Defaults to 1.
init(red: Double, green: Double, blue: Double, opacity: Double = 1.0) {
self.red = red
self.green = green
self.blue = blue
self.opacity = opacity
}
/// Creates a CodableColor from a SwiftUI Color.
/// - Parameter color: The SwiftUI Color to convert.
init(_ color: Color) {
let resolved = color.resolve(in: EnvironmentValues())
self.red = Double(resolved.red)
self.green = Double(resolved.green)
self.blue = Double(resolved.blue)
self.opacity = Double(resolved.opacity)
}
/// Converts to a SwiftUI Color.
var color: Color {
Color(red: red, green: green, blue: blue, opacity: opacity)
}
}
// MARK: - SponsorBlock Segment Settings
/// Settings for a single SponsorBlock category's appearance on the progress bar.
struct SponsorBlockCategorySettings: Codable, Hashable, Sendable {
/// Whether this category is visible on the progress bar.
var isVisible: Bool
/// The color to use for this category's segments.
var color: CodableColor
/// Creates category settings.
/// - Parameters:
/// - isVisible: Whether the category is visible. Defaults to `true`.
/// - color: The color for segments. Defaults to gray.
init(isVisible: Bool = true, color: CodableColor = CodableColor(red: 0.5, green: 0.5, blue: 0.5)) {
self.isVisible = isVisible
self.color = color
}
}
/// Settings for SponsorBlock segment display on the progress bar.
struct SponsorBlockSegmentSettings: Codable, Hashable, Sendable {
/// Global toggle for showing sponsor segments on the progress bar.
var showSegments: Bool
/// Per-category settings, keyed by SponsorBlockCategory raw value.
var categorySettings: [String: SponsorBlockCategorySettings]
/// Creates sponsor block segment settings.
/// - Parameters:
/// - showSegments: Whether to show segments. Defaults to `true`.
/// - categorySettings: Per-category settings. Defaults to default colors for all categories.
init(
showSegments: Bool = true,
categorySettings: [String: SponsorBlockCategorySettings]? = nil
) {
self.showSegments = showSegments
self.categorySettings = categorySettings ?? Self.defaultCategorySettings
}
/// Default settings with all categories visible using their default overlay colors.
static let `default` = SponsorBlockSegmentSettings()
/// Default category settings using default colors for each category.
/// Colors match the SponsorBlockCategory.overlayColor values.
static var defaultCategorySettings: [String: SponsorBlockCategorySettings] {
[
"sponsor": SponsorBlockCategorySettings(isVisible: true, color: CodableColor(.green)),
"selfpromo": SponsorBlockCategorySettings(isVisible: true, color: CodableColor(.yellow)),
"interaction": SponsorBlockCategorySettings(isVisible: true, color: CodableColor(.purple)),
"intro": SponsorBlockCategorySettings(isVisible: true, color: CodableColor(.cyan)),
"outro": SponsorBlockCategorySettings(isVisible: true, color: CodableColor(.blue)),
"preview": SponsorBlockCategorySettings(isVisible: true, color: CodableColor(.indigo)),
"music_offtopic": SponsorBlockCategorySettings(isVisible: true, color: CodableColor(.orange)),
"filler": SponsorBlockCategorySettings(isVisible: true, color: CodableColor(.gray)),
"poi_highlight": SponsorBlockCategorySettings(isVisible: true, color: CodableColor(.pink)),
]
}
/// Gets settings for a specific category key, returning defaults if not found.
/// - Parameter categoryKey: The category raw value key.
/// - Returns: The category settings.
func settings(forKey categoryKey: String) -> SponsorBlockCategorySettings {
categorySettings[categoryKey] ?? Self.defaultCategorySettings[categoryKey]
?? SponsorBlockCategorySettings()
}
/// Returns a copy with updated settings for a specific category key.
/// - Parameters:
/// - categoryKey: The category raw value key.
/// - settings: The new settings.
/// - Returns: Updated SponsorBlockSegmentSettings.
func withUpdatedSettings(
forKey categoryKey: String,
_ settings: SponsorBlockCategorySettings
) -> SponsorBlockSegmentSettings {
var newCategorySettings = categorySettings
newCategorySettings[categoryKey] = settings
return SponsorBlockSegmentSettings(
showSegments: showSegments,
categorySettings: newCategorySettings
)
}
}
// MARK: - SponsorBlockCategory Integration
extension SponsorBlockSegmentSettings {
/// Gets settings for a specific category, returning defaults if not found.
/// - Parameter category: The category to get settings for.
/// - Returns: The category settings.
func settings(for category: SponsorBlockCategory) -> SponsorBlockCategorySettings {
settings(forKey: category.rawValue)
}
/// Returns a copy with updated settings for a specific category.
/// - Parameters:
/// - category: The category to update.
/// - settings: The new settings.
/// - Returns: Updated SponsorBlockSegmentSettings.
func withUpdatedSettings(
for category: SponsorBlockCategory,
_ settings: SponsorBlockCategorySettings
) -> SponsorBlockSegmentSettings {
withUpdatedSettings(forKey: category.rawValue, settings)
}
}
// MARK: - Font Style
/// Font style options for player controls text.
enum ControlsFontStyle: String, Codable, Hashable, Sendable, CaseIterable {
/// System default font with monospaced digits.
case system
/// Fully monospaced font.
case monospaced
/// Rounded system font with monospaced digits.
case rounded
/// Localized display name.
var displayName: String {
switch self {
case .system:
return String(localized: "controls.fontStyle.system")
case .monospaced:
return String(localized: "controls.fontStyle.monospaced")
case .rounded:
return String(localized: "controls.fontStyle.rounded")
}
}
/// Returns a Font for the given text style.
func font(_ style: Font.TextStyle = .caption) -> Font {
switch self {
case .system:
return .system(style).monospacedDigit()
case .monospaced:
return .system(style, design: .monospaced)
case .rounded:
return .system(style, design: .rounded).monospacedDigit()
}
}
/// Returns a Font for the given size and weight.
func font(size: CGFloat, weight: Font.Weight = .regular) -> Font {
switch self {
case .system:
return .system(size: size, weight: weight).monospacedDigit()
case .monospaced:
return .system(size: size, weight: weight, design: .monospaced)
case .rounded:
return .system(size: size, weight: weight, design: .rounded).monospacedDigit()
}
}
}
/// Settings for the progress bar appearance.
struct ProgressBarSettings: Codable, Hashable, Sendable {
/// Color for the played portion of the progress bar.
var playedColor: CodableColor
/// Whether to show chapter markers on the progress bar.
var showChapters: Bool
/// Settings for SponsorBlock segment display.
var sponsorBlockSettings: SponsorBlockSegmentSettings
/// Creates progress bar settings.
/// - Parameters:
/// - playedColor: Color for the played portion. Defaults to red.
/// - showChapters: Whether to show chapter markers. Defaults to `true`.
/// - sponsorBlockSettings: Settings for SponsorBlock segments. Defaults to `.default`.
init(
playedColor: CodableColor = CodableColor(.red),
showChapters: Bool = true,
sponsorBlockSettings: SponsorBlockSegmentSettings = .default
) {
self.playedColor = playedColor
self.showChapters = showChapters
self.sponsorBlockSettings = sponsorBlockSettings
}
/// Default progress bar settings.
static let `default` = ProgressBarSettings()
}
/// Global settings that apply to all control buttons.
struct GlobalLayoutSettings: Codable, Hashable, Sendable {
/// Overall style for player controls.
var style: ControlsStyle
/// Size of control buttons.
var buttonSize: ButtonSize
/// Font style for text elements in player controls.
var fontStyle: ControlsFontStyle
/// Opacity of the controls background fade (0-1).
var controlsFadeOpacity: Double
/// Mode for system control buttons (Control Center, Lock Screen).
var systemControlsMode: SystemControlsMode
/// Duration for seek operations when systemControlsMode is .seek.
var systemControlsSeekDuration: SystemControlsSeekDuration
/// How volume is controlled during playback.
var volumeMode: VolumeMode
/// Theme derived from style (for backwards compatibility).
var theme: ControlsTheme { style.theme }
/// Button background derived from style (for backwards compatibility).
var buttonBackground: ButtonBackgroundStyle { style.buttonBackground }
/// Cached settings for instant access (avoids flash on view recreation).
/// Updated whenever settings are loaded from the active preset.
nonisolated(unsafe) static var cached: GlobalLayoutSettings = .default
// MARK: - Codable
private enum CodingKeys: String, CodingKey {
case style
case buttonSize
case buttonBackground // Legacy, not used for encoding
case theme // Legacy, not used for encoding
case fontStyle
case controlsFadeOpacity
case systemControlsMode
case systemControlsSeekDuration
case volumeMode
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
style = try container.decodeIfPresent(ControlsStyle.self, forKey: .style) ?? .glass
buttonSize = try container.decodeIfPresent(ButtonSize.self, forKey: .buttonSize) ?? .medium
fontStyle = try container.decodeIfPresent(ControlsFontStyle.self, forKey: .fontStyle) ?? .system
controlsFadeOpacity = try container.decodeIfPresent(Double.self, forKey: .controlsFadeOpacity) ?? 0.5
systemControlsMode = try container.decodeIfPresent(SystemControlsMode.self, forKey: .systemControlsMode) ?? .seek
systemControlsSeekDuration = try container.decodeIfPresent(SystemControlsSeekDuration.self, forKey: .systemControlsSeekDuration) ?? .tenSeconds
volumeMode = try container.decodeIfPresent(VolumeMode.self, forKey: .volumeMode) ?? .mpv
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(style, forKey: .style)
try container.encode(buttonSize, forKey: .buttonSize)
try container.encode(fontStyle, forKey: .fontStyle)
try container.encode(controlsFadeOpacity, forKey: .controlsFadeOpacity)
try container.encode(systemControlsMode, forKey: .systemControlsMode)
try container.encode(systemControlsSeekDuration, forKey: .systemControlsSeekDuration)
try container.encode(volumeMode, forKey: .volumeMode)
}
// MARK: - Initialization
/// Creates global layout settings.
init(
style: ControlsStyle = .glass,
buttonSize: ButtonSize = .medium,
fontStyle: ControlsFontStyle = .system,
controlsFadeOpacity: Double = 0.5,
systemControlsMode: SystemControlsMode = .seek,
systemControlsSeekDuration: SystemControlsSeekDuration = .tenSeconds,
volumeMode: VolumeMode = .mpv
) {
self.style = style
self.buttonSize = buttonSize
self.fontStyle = fontStyle
self.controlsFadeOpacity = controlsFadeOpacity
self.systemControlsMode = systemControlsMode
self.systemControlsSeekDuration = systemControlsSeekDuration
self.volumeMode = volumeMode
}
// MARK: - Defaults
/// Default global layout settings.
static let `default` = GlobalLayoutSettings()
}

View File

@@ -0,0 +1,110 @@
//
// LayoutPreset.swift
// Yattee
//
// A named preset containing a complete player controls layout.
//
import Foundation
/// A named preset containing a complete player controls layout.
struct LayoutPreset: Identifiable, Codable, Hashable, Sendable {
/// Unique identifier for this preset.
let id: UUID
/// User-visible name for the preset. Maximum 30 characters.
var name: String
/// When this preset was created.
let createdAt: Date
/// When this preset was last modified.
var updatedAt: Date
/// Whether this is a built-in preset (read-only).
let isBuiltIn: Bool
/// The device class this preset is for.
let deviceClass: DeviceClass
/// The complete player controls layout.
var layout: PlayerControlsLayout
// MARK: - Constants
/// Maximum length for preset names.
static let maxNameLength = 30
// MARK: - Initialization
/// Creates a new layout preset.
/// - Parameters:
/// - id: Unique identifier. Defaults to a new UUID.
/// - name: Preset name. Truncated to 30 characters.
/// - createdAt: Creation date. Defaults to now.
/// - updatedAt: Last modified date. Defaults to now.
/// - isBuiltIn: Whether this is a built-in preset.
/// - deviceClass: Device class for this preset. Defaults to current.
/// - layout: The player controls layout.
init(
id: UUID = UUID(),
name: String,
createdAt: Date = Date(),
updatedAt: Date = Date(),
isBuiltIn: Bool = false,
deviceClass: DeviceClass = .current,
layout: PlayerControlsLayout
) {
self.id = id
self.name = String(name.prefix(Self.maxNameLength))
self.createdAt = createdAt
self.updatedAt = updatedAt
self.isBuiltIn = isBuiltIn
self.deviceClass = deviceClass
self.layout = layout
}
// MARK: - Mutation
/// Creates a copy of this preset with updated layout and timestamp.
/// - Parameter layout: The new layout.
/// - Returns: A new preset with the updated layout.
func withUpdatedLayout(_ layout: PlayerControlsLayout) -> LayoutPreset {
var updated = self
updated.layout = layout
updated.updatedAt = Date()
return updated
}
/// Creates a copy of this preset with a new name.
/// - Parameter name: The new name.
/// - Returns: A new preset with the updated name.
func renamed(to name: String) -> LayoutPreset {
var updated = self
updated.name = String(name.prefix(Self.maxNameLength))
updated.updatedAt = Date()
return updated
}
/// Creates a duplicate of this preset as a custom (non-built-in) preset.
/// - Parameter name: Name for the duplicate.
/// - Returns: A new custom preset with the same layout.
func duplicate(name: String) -> LayoutPreset {
LayoutPreset(
name: name,
isBuiltIn: false,
deviceClass: deviceClass,
layout: layout
)
}
}
// MARK: - Equatable
extension LayoutPreset: Equatable {
static func == (lhs: LayoutPreset, rhs: LayoutPreset) -> Bool {
lhs.id == rhs.id &&
lhs.name == rhs.name &&
lhs.updatedAt == rhs.updatedAt
}
}

View File

@@ -0,0 +1,113 @@
//
// LayoutSection.swift
// Yattee
//
// Represents a section of control buttons in the player layout.
//
import Foundation
/// Identifies a section in the player controls layout.
enum LayoutSectionType: String, Codable, Hashable, Sendable {
case top
case bottom
}
/// A section containing an ordered list of control buttons.
struct LayoutSection: Codable, Hashable, Sendable {
/// The ordered list of button configurations in this section.
var buttons: [ControlButtonConfiguration]
// MARK: - Initialization
/// Creates a new layout section.
/// - Parameter buttons: The buttons in this section.
init(buttons: [ControlButtonConfiguration] = []) {
self.buttons = buttons
}
// MARK: - Mutation Helpers
/// Adds a button to the end of the section.
/// - Parameter button: The button configuration to add.
mutating func add(button: ControlButtonConfiguration) {
buttons.append(button)
}
/// Adds a button with the given type to the end of the section.
/// - Parameter type: The button type to add with default configuration.
mutating func add(buttonType type: ControlButtonType) {
buttons.append(.defaultConfiguration(for: type))
}
/// Removes a button at the specified index.
/// - Parameter index: The index of the button to remove.
mutating func remove(at index: Int) {
guard buttons.indices.contains(index) else { return }
buttons.remove(at: index)
}
/// Removes a button with the specified ID.
/// - Parameter id: The ID of the button to remove.
mutating func remove(id: UUID) {
buttons.removeAll { $0.id == id }
}
/// Moves a button from one position to another.
/// - Parameters:
/// - source: The current index of the button.
/// - destination: The target index for the button.
mutating func move(from source: Int, to destination: Int) {
guard buttons.indices.contains(source) else { return }
let button = buttons.remove(at: source)
let targetIndex = destination > source ? destination - 1 : destination
let clampedIndex = max(0, min(buttons.count, targetIndex))
buttons.insert(button, at: clampedIndex)
}
/// Moves buttons from source indices to a destination index.
/// Compatible with SwiftUI's `onMove` modifier.
/// - Parameters:
/// - source: The indices of buttons to move.
/// - destination: The target index.
mutating func move(fromOffsets source: IndexSet, toOffset destination: Int) {
// Implement IndexSet-based move manually to avoid SwiftUI dependency
let itemsToMove = source.map { buttons[$0] }
var newButtons = buttons.enumerated().filter { !source.contains($0.offset) }.map { $0.element }
// Adjust destination for removed items
let adjustedDestination = source.filter { $0 < destination }.count
let insertIndex = max(0, min(newButtons.count, destination - adjustedDestination))
newButtons.insert(contentsOf: itemsToMove, at: insertIndex)
buttons = newButtons
}
/// Updates a button configuration.
/// - Parameter button: The updated button configuration.
mutating func update(button: ControlButtonConfiguration) {
guard let index = buttons.firstIndex(where: { $0.id == button.id }) else { return }
buttons[index] = button
}
// MARK: - Query Helpers
/// Returns the button types currently in this section.
var buttonTypes: [ControlButtonType] {
buttons.map(\.buttonType)
}
/// Checks if a button type is already in this section.
/// - Parameter type: The button type to check.
/// - Returns: True if the type is already present.
func contains(buttonType type: ControlButtonType) -> Bool {
buttons.contains { $0.buttonType == type }
}
/// Returns buttons filtered by visibility for the given layout state.
/// - Parameter isWideLayout: Whether the current layout is wide/landscape.
/// - Returns: Buttons that should be visible.
func visibleButtons(isWideLayout: Bool) -> [ControlButtonConfiguration] {
buttons.filter { $0.visibilityMode.isVisible(isWideLayout: isWideLayout) }
}
}

View File

@@ -0,0 +1,84 @@
//
// MiniPlayerSettings.swift
// Yattee
//
// Settings for the mini player component.
//
import Foundation
import SwiftUI
// MARK: - MiniPlayerSettings
/// Complete settings for the mini player component.
/// Note: Minimize behavior is intentionally NOT stored here as it's a system UI setting
/// that configures the tab bar and needs to be available synchronously at view creation time.
/// The minimize behavior remains in SettingsManager for the tab bar to access directly.
struct MiniPlayerSettings: Codable, Hashable, Sendable {
/// Whether to show video preview in the mini player.
var showVideo: Bool
/// Action to perform when tapping on the video preview.
var videoTapAction: MiniPlayerVideoTapAction
/// Ordered list of buttons to display in the mini player.
var buttons: [ControlButtonConfiguration]
// MARK: - Initialization
init(
showVideo: Bool = true,
videoTapAction: MiniPlayerVideoTapAction = .startPiP,
buttons: [ControlButtonConfiguration] = MiniPlayerSettings.defaultButtons
) {
self.showVideo = showVideo
self.videoTapAction = videoTapAction
self.buttons = buttons
}
// MARK: - Mutation Helpers
/// Adds a button of the given type to the mini player.
/// - Parameter buttonType: The type of button to add.
mutating func add(buttonType: ControlButtonType) {
let config = ControlButtonConfiguration(buttonType: buttonType)
buttons.append(config)
}
/// Removes the button at the given index.
/// - Parameter index: The index of the button to remove.
mutating func remove(at index: Int) {
guard buttons.indices.contains(index) else { return }
buttons.remove(at: index)
}
/// Moves buttons within the list.
/// - Parameters:
/// - source: Source indices to move from.
/// - destination: Destination index to move to.
mutating func move(fromOffsets source: IndexSet, toOffset destination: Int) {
buttons.move(fromOffsets: source, toOffset: destination)
}
/// Updates a button configuration by matching ID.
/// - Parameter configuration: The updated configuration.
mutating func update(_ configuration: ControlButtonConfiguration) {
guard let index = buttons.firstIndex(where: { $0.id == configuration.id }) else { return }
buttons[index] = configuration
}
// MARK: - Default Configuration
/// Default buttons for the mini player: play/pause and play next.
private static let defaultButtons: [ControlButtonConfiguration] = [
ControlButtonConfiguration(buttonType: .playPause),
ControlButtonConfiguration(buttonType: .playNext)
]
/// Default mini player settings.
static let `default` = MiniPlayerSettings()
/// Cached settings for instant access (avoids flash on view recreation).
/// Updated whenever settings are loaded from the active preset.
nonisolated(unsafe) static var cached: MiniPlayerSettings = .default
}

View File

@@ -0,0 +1,176 @@
//
// PlayerControlsLayout.swift
// Yattee
//
// Complete layout configuration for player controls.
//
import Foundation
/// Complete layout configuration for player controls, including all sections.
struct PlayerControlsLayout: Codable, Hashable, Sendable {
/// Configuration for the top row of buttons.
var topSection: LayoutSection
/// Configuration for the center section (play/pause, seek).
var centerSettings: CenterSectionSettings
/// Configuration for the bottom row of buttons.
var bottomSection: LayoutSection
/// Global settings applied to all buttons.
var globalSettings: GlobalLayoutSettings
/// Progress bar appearance settings.
var progressBarSettings: ProgressBarSettings
/// Gesture settings (iOS only). Optional for backward compatibility.
var gesturesSettings: GesturesSettings?
/// Player pill settings. Optional for backward compatibility.
var playerPillSettings: PlayerPillSettings?
/// Mini player settings. Optional for backward compatibility.
var miniPlayerSettings: MiniPlayerSettings?
/// Wide layout panel alignment. Optional for backward compatibility.
/// When nil, uses the global setting from SettingsManager.
var wideLayoutPanelAlignment: FloatingPanelSide?
// MARK: - Initialization
/// Creates a complete player controls layout.
/// - Parameters:
/// - topSection: Top section configuration.
/// - centerSettings: Center section settings.
/// - bottomSection: Bottom section configuration.
/// - globalSettings: Global settings.
/// - progressBarSettings: Progress bar appearance settings.
/// - gesturesSettings: Gesture settings (iOS only).
/// - playerPillSettings: Player pill settings.
/// - miniPlayerSettings: Mini player settings.
init(
topSection: LayoutSection = LayoutSection(),
centerSettings: CenterSectionSettings = .default,
bottomSection: LayoutSection = LayoutSection(),
globalSettings: GlobalLayoutSettings = .default,
progressBarSettings: ProgressBarSettings = .default,
gesturesSettings: GesturesSettings? = nil,
playerPillSettings: PlayerPillSettings? = nil,
miniPlayerSettings: MiniPlayerSettings? = nil
) {
self.topSection = topSection
self.centerSettings = centerSettings
self.bottomSection = bottomSection
self.globalSettings = globalSettings
self.progressBarSettings = progressBarSettings
self.gesturesSettings = gesturesSettings
self.playerPillSettings = playerPillSettings
self.miniPlayerSettings = miniPlayerSettings
}
// MARK: - Gesture Settings
/// Returns the effective gestures settings, using defaults if not set.
var effectiveGesturesSettings: GesturesSettings {
gesturesSettings ?? .default
}
// MARK: - Player Pill Settings
/// Returns the effective player pill settings, using defaults if not set.
var effectivePlayerPillSettings: PlayerPillSettings {
playerPillSettings ?? .default
}
// MARK: - Mini Player Settings
/// Returns the effective mini player settings, using defaults if not set.
var effectiveMiniPlayerSettings: MiniPlayerSettings {
miniPlayerSettings ?? .default
}
// MARK: - Wide Layout Panel Settings
/// Returns the effective wide layout panel alignment, using right as default if not set.
var effectiveWideLayoutPanelAlignment: FloatingPanelSide {
wideLayoutPanelAlignment ?? .left
}
// MARK: - Defaults
/// Default layout matching the current hardcoded player controls.
static let `default`: PlayerControlsLayout = {
// Top section: spacer, brightness (widescreen), volume, airplay, debug, close
let topButtons: [ControlButtonConfiguration] = [
.flexibleSpacer(),
ControlButtonConfiguration(
buttonType: .brightness,
visibilityMode: .wideOnly,
settings: .slider(SliderSettings(sliderBehavior: .alwaysVisible))
),
ControlButtonConfiguration(
buttonType: .volume,
settings: .slider(SliderSettings(sliderBehavior: .alwaysVisible))
),
.defaultConfiguration(for: .airplay),
.defaultConfiguration(for: .mpvDebug),
.defaultConfiguration(for: .close)
]
// Bottom section: time, queue (widescreen), playNext, spacer, orientationLock (widescreen), contextMenu (widescreen), settings, pip, panelToggle (widescreen), fullscreen
let bottomButtons: [ControlButtonConfiguration] = [
ControlButtonConfiguration(
buttonType: .timeDisplay,
settings: .timeDisplay(TimeDisplaySettings(format: .currentAndTotal))
),
ControlButtonConfiguration(
buttonType: .queue,
visibilityMode: .wideOnly
),
.defaultConfiguration(for: .playNext),
.flexibleSpacer(),
ControlButtonConfiguration(
buttonType: .orientationLock,
visibilityMode: .wideOnly
),
ControlButtonConfiguration(
buttonType: .contextMenu,
visibilityMode: .wideOnly
),
.defaultConfiguration(for: .settings),
.defaultConfiguration(for: .pictureInPicture),
ControlButtonConfiguration(
buttonType: .panelToggle,
visibilityMode: .wideOnly
),
.defaultConfiguration(for: .fullscreen)
]
return PlayerControlsLayout(
topSection: LayoutSection(buttons: topButtons),
centerSettings: CenterSectionSettings(),
bottomSection: LayoutSection(buttons: bottomButtons),
globalSettings: GlobalLayoutSettings(),
progressBarSettings: ProgressBarSettings()
)
}()
// MARK: - Helpers
/// Returns all button types currently used in top and bottom sections.
var usedButtonTypes: Set<ControlButtonType> {
let topTypes = topSection.buttons.map(\.buttonType)
let bottomTypes = bottomSection.buttons.map(\.buttonType)
return Set(topTypes + bottomTypes)
}
/// Returns button types available to add (not already used, excluding spacer which can be duplicated).
var availableButtonTypes: [ControlButtonType] {
let used = usedButtonTypes
return ControlButtonType.availableForHorizontalSections.filter { type in
type == .spacer || !used.contains(type)
}
}
}

View File

@@ -0,0 +1,173 @@
//
// PlayerPillSettings.swift
// Yattee
//
// Settings for the player pill component (visibility and buttons).
//
import Foundation
import SwiftUI
// MARK: - CommentsPillMode
/// Controls how the comments pill is displayed in the player.
enum CommentsPillMode: String, Codable, Hashable, Sendable, CaseIterable {
case pill // Default - shows expanded pill, collapses on scroll
case button // Always show collapsed (button-only)
case disabled // No button/pill visible, no API query
/// Localized display name for settings UI.
var displayName: String {
switch self {
case .pill:
return String(localized: "commentsPill.mode.pill")
case .button:
return String(localized: "commentsPill.mode.button")
case .disabled:
return String(localized: "commentsPill.mode.disabled")
}
}
/// Whether comments should be loaded from the API.
var shouldLoadComments: Bool {
self != .disabled
}
/// Whether the comments pill should always be collapsed (button mode).
var alwaysCollapsed: Bool {
self == .button
}
}
// MARK: - PillVisibility
/// Controls when the player pill is visible based on orientation.
enum PillVisibility: String, Codable, Hashable, Sendable, CaseIterable {
case portraitOnly // Default - shown in portrait orientation only
case landscapeOnly // Shown in wide/landscape orientation only
case both // Shown in all orientations
case never // Pill disabled
/// Localized display name for settings UI.
var displayName: String {
switch self {
case .portraitOnly:
return String(localized: "pill.visibility.portraitOnly")
case .landscapeOnly:
return String(localized: "pill.visibility.landscapeOnly")
case .both:
return String(localized: "pill.visibility.both")
case .never:
return String(localized: "pill.visibility.never")
}
}
/// Returns whether the pill should be visible for the given layout context.
/// - Parameter isWideLayout: True if in wide/landscape layout, false for portrait.
/// - Returns: Whether the pill should be shown.
func isVisible(isWideLayout: Bool) -> Bool {
switch self {
case .portraitOnly:
return !isWideLayout
case .landscapeOnly:
return isWideLayout
case .both:
return true
case .never:
return false
}
}
}
// MARK: - PlayerPillSettings
/// Complete settings for the player pill component.
struct PlayerPillSettings: Codable, Hashable, Sendable {
/// When to show the pill.
var visibility: PillVisibility
/// Ordered list of buttons to display in the pill.
var buttons: [ControlButtonConfiguration]
/// How the comments pill should be displayed (optional for backward compatibility).
var commentsPillMode: CommentsPillMode?
// MARK: - Initialization
init(
visibility: PillVisibility = .portraitOnly,
buttons: [ControlButtonConfiguration] = [],
commentsPillMode: CommentsPillMode? = nil
) {
self.visibility = visibility
self.buttons = buttons
self.commentsPillMode = commentsPillMode
}
// MARK: - Computed Properties
/// Returns the effective comments pill mode, defaulting to `.pill` when nil.
var effectiveCommentsPillMode: CommentsPillMode {
commentsPillMode ?? .pill
}
/// Whether comments should be loaded from the API.
var shouldLoadComments: Bool {
effectiveCommentsPillMode.shouldLoadComments
}
/// Whether the comments pill should always be shown collapsed.
var isCommentsPillAlwaysCollapsed: Bool {
effectiveCommentsPillMode.alwaysCollapsed
}
/// Whether the comments pill should be visible at all.
var shouldShowCommentsPill: Bool {
effectiveCommentsPillMode != .disabled
}
// MARK: - Mutation Helpers
/// Adds a button of the given type to the pill.
/// - Parameter buttonType: The type of button to add.
mutating func add(buttonType: ControlButtonType) {
let config = ControlButtonConfiguration(buttonType: buttonType)
buttons.append(config)
}
/// Removes the button at the given index.
/// - Parameter index: The index of the button to remove.
mutating func remove(at index: Int) {
guard buttons.indices.contains(index) else { return }
buttons.remove(at: index)
}
/// Moves buttons within the list.
/// - Parameters:
/// - source: Source indices to move from.
/// - destination: Destination index to move to.
mutating func move(fromOffsets source: IndexSet, toOffset destination: Int) {
buttons.move(fromOffsets: source, toOffset: destination)
}
/// Updates a button configuration by matching ID.
/// - Parameter configuration: The updated configuration.
mutating func update(_ configuration: ControlButtonConfiguration) {
guard let index = buttons.firstIndex(where: { $0.id == configuration.id }) else { return }
buttons[index] = configuration
}
// MARK: - Default Configuration
/// Default player pill settings matching the original queue pill behavior.
static let `default` = PlayerPillSettings(
visibility: .portraitOnly,
buttons: [
ControlButtonConfiguration(buttonType: .queue),
ControlButtonConfiguration(buttonType: .playPrevious),
ControlButtonConfiguration(buttonType: .playPause),
ControlButtonConfiguration(buttonType: .playNext),
ControlButtonConfiguration(buttonType: .close)
]
)
}

View File

@@ -0,0 +1,47 @@
//
// SideSliderType.swift
// Yattee
//
// Type of control for vertical side sliders in player controls.
//
import Foundation
/// Type of control for vertical side sliders in player.
/// Used for configuring left and right edge sliders in center controls settings.
enum SideSliderType: String, Codable, Hashable, Sendable, CaseIterable {
/// No slider shown on this side.
case disabled
/// Volume control slider.
case volume
/// Screen brightness control slider.
case brightness
// MARK: - Display Properties
/// Localized display name for the slider type.
var displayName: String {
switch self {
case .disabled:
String(localized: "sideSlider.disabled")
case .volume:
String(localized: "sideSlider.volume")
case .brightness:
String(localized: "sideSlider.brightness")
}
}
/// SF Symbol icon for the slider type, or nil if disabled.
var systemImage: String? {
switch self {
case .disabled:
nil
case .volume:
"speaker.wave.2.fill"
case .brightness:
"sun.max.fill"
}
}
}

View File

@@ -0,0 +1,50 @@
//
// VisibilityMode.swift
// Yattee
//
// Defines visibility modes for control buttons based on device orientation.
//
import Foundation
/// Controls when a button is visible based on device orientation.
enum VisibilityMode: String, Codable, Hashable, Sendable, CaseIterable {
/// Button is visible in both portrait and landscape orientations.
case both
/// Button is only visible in portrait orientation.
case portraitOnly
/// Button is only visible in landscape/wide orientation.
case wideOnly
// MARK: - Display
/// Localized display name for the visibility mode.
var displayName: String {
switch self {
case .both:
return String(localized: "controls.visibility.both")
case .portraitOnly:
return String(localized: "controls.visibility.portraitOnly")
case .wideOnly:
return String(localized: "controls.visibility.wideOnly")
}
}
// MARK: - Visibility Check
/// Returns whether the button should be visible for the given layout state.
/// - Parameter isWideLayout: True if the current layout is wide/landscape.
/// - Returns: True if the button should be visible.
func isVisible(isWideLayout: Bool) -> Bool {
switch self {
case .both:
return true
case .portraitOnly:
return !isWideLayout
case .wideOnly:
return isWideLayout
}
}
}