mirror of
https://github.com/yattee/yattee.git
synced 2026-06-12 01:34:20 +00:00
Yattee v2 rewrite
This commit is contained in:
229
Yattee/Models/PlayerControls/BuiltInPresets.swift
Normal file
229
Yattee/Models/PlayerControls/BuiltInPresets.swift
Normal 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)]
|
||||
}
|
||||
}
|
||||
275
Yattee/Models/PlayerControls/ButtonSettings.swift
Normal file
275
Yattee/Models/PlayerControls/ButtonSettings.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
116
Yattee/Models/PlayerControls/CenterSectionSettings.swift
Normal file
116
Yattee/Models/PlayerControls/CenterSectionSettings.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
103
Yattee/Models/PlayerControls/ControlButtonConfiguration.swift
Normal file
103
Yattee/Models/PlayerControls/ControlButtonConfiguration.swift
Normal 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
|
||||
}
|
||||
}
|
||||
308
Yattee/Models/PlayerControls/ControlButtonType.swift
Normal file
308
Yattee/Models/PlayerControls/ControlButtonType.swift
Normal 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
|
||||
]
|
||||
}
|
||||
}
|
||||
48
Yattee/Models/PlayerControls/DeviceClass.swift
Normal file
48
Yattee/Models/PlayerControls/DeviceClass.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
64
Yattee/Models/PlayerControls/Gestures/GesturesSettings.swift
Normal file
64
Yattee/Models/PlayerControls/Gestures/GesturesSettings.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
202
Yattee/Models/PlayerControls/Gestures/TapGestureAction.swift
Normal file
202
Yattee/Models/PlayerControls/Gestures/TapGestureAction.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
115
Yattee/Models/PlayerControls/Gestures/TapGesturesSettings.swift
Normal file
115
Yattee/Models/PlayerControls/Gestures/TapGesturesSettings.swift
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
90
Yattee/Models/PlayerControls/Gestures/TapZoneLayout.swift
Normal file
90
Yattee/Models/PlayerControls/Gestures/TapZoneLayout.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
65
Yattee/Models/PlayerControls/Gestures/TapZonePosition.swift
Normal file
65
Yattee/Models/PlayerControls/Gestures/TapZonePosition.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
487
Yattee/Models/PlayerControls/GlobalLayoutSettings.swift
Normal file
487
Yattee/Models/PlayerControls/GlobalLayoutSettings.swift
Normal 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()
|
||||
}
|
||||
110
Yattee/Models/PlayerControls/LayoutPreset.swift
Normal file
110
Yattee/Models/PlayerControls/LayoutPreset.swift
Normal 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
|
||||
}
|
||||
}
|
||||
113
Yattee/Models/PlayerControls/LayoutSection.swift
Normal file
113
Yattee/Models/PlayerControls/LayoutSection.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
84
Yattee/Models/PlayerControls/MiniPlayerSettings.swift
Normal file
84
Yattee/Models/PlayerControls/MiniPlayerSettings.swift
Normal 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
|
||||
}
|
||||
176
Yattee/Models/PlayerControls/PlayerControlsLayout.swift
Normal file
176
Yattee/Models/PlayerControls/PlayerControlsLayout.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
173
Yattee/Models/PlayerControls/PlayerPillSettings.swift
Normal file
173
Yattee/Models/PlayerControls/PlayerPillSettings.swift
Normal 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)
|
||||
]
|
||||
)
|
||||
}
|
||||
47
Yattee/Models/PlayerControls/SideSliderType.swift
Normal file
47
Yattee/Models/PlayerControls/SideSliderType.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
50
Yattee/Models/PlayerControls/VisibilityMode.swift
Normal file
50
Yattee/Models/PlayerControls/VisibilityMode.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user