mirror of
https://github.com/yattee/yattee.git
synced 2026-09-29 17:33:51 +00:00
Yattee v2 rewrite
This commit is contained in:
69
Yattee/Models/Caption.swift
Normal file
69
Yattee/Models/Caption.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// Caption.swift
|
||||
// Yattee
|
||||
//
|
||||
// Model representing a video caption/subtitle track.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents a caption/subtitle track for a video.
|
||||
struct Caption: Identifiable, Codable, Hashable, Sendable {
|
||||
/// The display label (e.g., "English", "English (auto-generated)")
|
||||
let label: String
|
||||
|
||||
/// The language code (e.g., "en", "de-DE", "es-419")
|
||||
let languageCode: String
|
||||
|
||||
/// The URL to fetch the caption content
|
||||
let url: URL
|
||||
|
||||
/// Whether this is an auto-generated caption
|
||||
var isAutoGenerated: Bool {
|
||||
label.contains("auto-generated")
|
||||
}
|
||||
|
||||
/// The base language code without region (e.g., "en" from "en-US")
|
||||
var baseLanguageCode: String {
|
||||
if let hyphenIndex = languageCode.firstIndex(of: "-") {
|
||||
return String(languageCode[..<hyphenIndex])
|
||||
}
|
||||
return languageCode
|
||||
}
|
||||
|
||||
var id: String {
|
||||
"\(languageCode):\(label)"
|
||||
}
|
||||
|
||||
/// Formatted display name for the caption
|
||||
var displayName: String {
|
||||
// Try to get localized language name (AUTO badge shown separately in UI)
|
||||
if let localizedName = Locale.current.localizedString(forLanguageCode: baseLanguageCode) {
|
||||
return localizedName
|
||||
}
|
||||
// Strip "(auto-generated)" from label if present
|
||||
return label.replacingOccurrences(of: " (auto-generated)", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview Data
|
||||
|
||||
extension Caption {
|
||||
/// A sample English caption for SwiftUI previews.
|
||||
static var preview: Caption {
|
||||
Caption(
|
||||
label: "English",
|
||||
languageCode: "en",
|
||||
url: URL(string: "https://example.com/captions/en.vtt")!
|
||||
)
|
||||
}
|
||||
|
||||
/// A sample auto-generated caption for SwiftUI previews.
|
||||
static var autoGeneratedPreview: Caption {
|
||||
Caption(
|
||||
label: "English (auto-generated)",
|
||||
languageCode: "en",
|
||||
url: URL(string: "https://example.com/captions/en-auto.vtt")!
|
||||
)
|
||||
}
|
||||
}
|
||||
108
Yattee/Models/Channel.swift
Normal file
108
Yattee/Models/Channel.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// Channel.swift
|
||||
// Yattee
|
||||
//
|
||||
// Represents a video channel/author.
|
||||
//
|
||||
|
||||
@preconcurrency import Foundation
|
||||
|
||||
/// Represents a channel from any content source.
|
||||
struct Channel: Identifiable, Codable, Hashable, Sendable {
|
||||
/// Unique identifier for this channel.
|
||||
let id: ChannelID
|
||||
|
||||
/// The channel name.
|
||||
let name: String
|
||||
|
||||
/// Channel description/about text.
|
||||
let description: String?
|
||||
|
||||
/// Subscriber count if available.
|
||||
let subscriberCount: Int?
|
||||
|
||||
/// Total video count if available.
|
||||
let videoCount: Int?
|
||||
|
||||
/// Channel thumbnail/avatar URL.
|
||||
let thumbnailURL: URL?
|
||||
|
||||
/// Channel banner image URL.
|
||||
let bannerURL: URL?
|
||||
|
||||
/// Whether the channel is verified.
|
||||
let isVerified: Bool
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var formattedSubscriberCount: String? {
|
||||
guard let subscriberCount else { return nil }
|
||||
return CountFormatter.compact(subscriberCount)
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
id: ChannelID,
|
||||
name: String,
|
||||
description: String? = nil,
|
||||
subscriberCount: Int? = nil,
|
||||
videoCount: Int? = nil,
|
||||
thumbnailURL: URL? = nil,
|
||||
bannerURL: URL? = nil,
|
||||
isVerified: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.subscriberCount = subscriberCount
|
||||
self.videoCount = videoCount
|
||||
self.thumbnailURL = thumbnailURL
|
||||
self.bannerURL = bannerURL
|
||||
self.isVerified = isVerified
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Channel ID
|
||||
|
||||
/// Unique identifier for a channel, combining source and channel ID.
|
||||
struct ChannelID: Codable, Hashable, Sendable {
|
||||
/// The content source.
|
||||
let source: ContentSource
|
||||
|
||||
/// The channel ID within that source.
|
||||
let channelID: String
|
||||
|
||||
init(source: ContentSource, channelID: String) {
|
||||
self.source = source
|
||||
self.channelID = channelID
|
||||
}
|
||||
|
||||
/// Creates a global channel ID (e.g., YouTube).
|
||||
static func global(_ channelID: String, provider: String = ContentSource.youtubeProvider) -> ChannelID {
|
||||
ChannelID(source: .global(provider: provider), channelID: channelID)
|
||||
}
|
||||
|
||||
/// Creates a federated channel ID (e.g., PeerTube).
|
||||
static func federated(_ channelID: String, provider: String = ContentSource.peertubeProvider, instance: URL) -> ChannelID {
|
||||
ChannelID(source: .federated(provider: provider, instance: instance), channelID: channelID)
|
||||
}
|
||||
|
||||
/// Creates an extracted channel ID for sites supported by yt-dlp.
|
||||
static func extracted(_ channelID: String, extractor: String, originalURL: URL) -> ChannelID {
|
||||
ChannelID(source: .extracted(extractor: extractor, originalURL: originalURL), channelID: channelID)
|
||||
}
|
||||
}
|
||||
|
||||
extension ChannelID: Identifiable {
|
||||
var id: String {
|
||||
switch source {
|
||||
case .global(let provider):
|
||||
return "global:\(provider):\(channelID)"
|
||||
case .federated(let provider, let instance):
|
||||
return "federated:\(provider):\(instance.host ?? ""):\(channelID)"
|
||||
case .extracted(let extractor, _):
|
||||
return "extracted:\(extractor):\(channelID)"
|
||||
}
|
||||
}
|
||||
}
|
||||
59
Yattee/Models/ChannelStripSize.swift
Normal file
59
Yattee/Models/ChannelStripSize.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
// ChannelStripSize.swift
|
||||
// Yattee
|
||||
//
|
||||
// Defines size options for the channel strip in Subscriptions view
|
||||
|
||||
import Foundation
|
||||
|
||||
enum ChannelStripSize: String, CaseIterable, Codable, Hashable, Sendable {
|
||||
case disabled
|
||||
case compact
|
||||
case normal
|
||||
case large
|
||||
|
||||
// Avatar size in points
|
||||
var avatarSize: CGFloat {
|
||||
switch self {
|
||||
case .disabled: return 0
|
||||
case .compact: return 30
|
||||
case .normal: return 44
|
||||
case .large: return 65
|
||||
}
|
||||
}
|
||||
|
||||
// Scaled spacing between avatars (proportional to avatar size)
|
||||
var chipSpacing: CGFloat {
|
||||
switch self {
|
||||
case .disabled: return 0
|
||||
case .compact: return 8
|
||||
case .normal: return 12
|
||||
case .large: return 16
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical padding for the channel strip container
|
||||
var verticalPadding: CGFloat {
|
||||
switch self {
|
||||
case .disabled: return 0
|
||||
case .compact: return 8
|
||||
case .normal: return 12
|
||||
case .large: return 16
|
||||
}
|
||||
}
|
||||
|
||||
// Total height of the channel strip including avatar, padding, and container margin
|
||||
var totalHeight: CGFloat {
|
||||
guard self != .disabled else { return 0 }
|
||||
// avatarSize + vertical padding (top + bottom) + container bottom padding (8)
|
||||
return avatarSize + (verticalPadding * 2) + 8
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .disabled: return String(localized: "Disabled")
|
||||
case .compact: return String(localized: "Compact")
|
||||
case .normal: return String(localized: "Normal")
|
||||
case .large: return String(localized: "Large")
|
||||
}
|
||||
}
|
||||
}
|
||||
99
Yattee/Models/Comment.swift
Normal file
99
Yattee/Models/Comment.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// Comment.swift
|
||||
// Yattee
|
||||
//
|
||||
// Represents a video comment.
|
||||
//
|
||||
|
||||
@preconcurrency import Foundation
|
||||
|
||||
/// Represents a comment on a video.
|
||||
struct Comment: Identifiable, Codable, Hashable, Sendable {
|
||||
/// Unique identifier for this comment.
|
||||
let id: String
|
||||
|
||||
/// The comment author.
|
||||
let author: Author
|
||||
|
||||
/// The comment text content.
|
||||
let content: String
|
||||
|
||||
/// When the comment was posted.
|
||||
let publishedAt: Date?
|
||||
|
||||
/// Human-readable published date.
|
||||
let publishedText: String?
|
||||
|
||||
/// Like count if available.
|
||||
let likeCount: Int?
|
||||
|
||||
/// Whether this comment is pinned.
|
||||
let isPinned: Bool
|
||||
|
||||
/// Whether the author is the video creator.
|
||||
let isCreatorComment: Bool
|
||||
|
||||
/// Whether the channel owner hearted this comment.
|
||||
let hasCreatorHeart: Bool
|
||||
|
||||
/// Number of replies.
|
||||
let replyCount: Int
|
||||
|
||||
/// Continuation token for loading replies.
|
||||
let repliesContinuation: String?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
id: String,
|
||||
author: Author,
|
||||
content: String,
|
||||
publishedAt: Date? = nil,
|
||||
publishedText: String? = nil,
|
||||
likeCount: Int? = nil,
|
||||
isPinned: Bool = false,
|
||||
isCreatorComment: Bool = false,
|
||||
hasCreatorHeart: Bool = false,
|
||||
replyCount: Int = 0,
|
||||
repliesContinuation: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.author = author
|
||||
self.content = content
|
||||
self.publishedAt = publishedAt
|
||||
self.publishedText = publishedText
|
||||
self.likeCount = likeCount
|
||||
self.isPinned = isPinned
|
||||
self.isCreatorComment = isCreatorComment
|
||||
self.hasCreatorHeart = hasCreatorHeart
|
||||
self.replyCount = replyCount
|
||||
self.repliesContinuation = repliesContinuation
|
||||
}
|
||||
|
||||
var formattedLikeCount: String? {
|
||||
guard let likeCount, likeCount > 0 else { return nil }
|
||||
return CountFormatter.compact(likeCount)
|
||||
}
|
||||
|
||||
/// Formatted published date, preferring parsed Date over API-provided text.
|
||||
var formattedPublishedDate: String? {
|
||||
if let publishedAt {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .full
|
||||
return formatter.localizedString(for: publishedAt, relativeTo: Date())
|
||||
}
|
||||
return publishedText
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a page of comments with continuation token for pagination.
|
||||
struct CommentsPage: Sendable {
|
||||
/// The comments on this page.
|
||||
let comments: [Comment]
|
||||
|
||||
/// Continuation token for loading more comments. Nil if no more pages.
|
||||
let continuation: String?
|
||||
|
||||
/// Whether there are more comments to load.
|
||||
var hasMore: Bool { continuation != nil }
|
||||
}
|
||||
179
Yattee/Models/ContentSource.swift
Normal file
179
Yattee/Models/ContentSource.swift
Normal file
@@ -0,0 +1,179 @@
|
||||
//
|
||||
// ContentSource.swift
|
||||
// Yattee
|
||||
//
|
||||
// Defines the source of video content by identity paradigm.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents the source of video content by its identity paradigm.
|
||||
///
|
||||
/// Global content has universally unique IDs that work across any mirror instance.
|
||||
/// Federated content belongs to a specific instance - the ID is only unique within that instance.
|
||||
/// Extracted content comes from external sites via yt-dlp and requires the original URL for re-extraction.
|
||||
enum ContentSource: Codable, Hashable, Sendable {
|
||||
/// Content with globally unique IDs (e.g., YouTube, Dailymotion).
|
||||
/// Works across any mirror instance.
|
||||
case global(provider: String)
|
||||
|
||||
/// Content specific to a federated instance (e.g., PeerTube, Funkwhale).
|
||||
/// The instance URL is part of the video's identity.
|
||||
case federated(provider: String, instance: URL)
|
||||
|
||||
/// Content requiring URL-based extraction (Vimeo, Twitter, TikTok, etc.).
|
||||
/// Original URL preserved for stream re-extraction via yt-dlp.
|
||||
case extracted(extractor: String, originalURL: URL)
|
||||
|
||||
// MARK: - Provider Constants
|
||||
|
||||
static let youtubeProvider = "youtube"
|
||||
static let peertubeProvider = "peertube"
|
||||
|
||||
// MARK: - Display
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .global(let provider):
|
||||
if provider == Self.youtubeProvider {
|
||||
return String(localized: "source.youtube")
|
||||
}
|
||||
return provider.prefix(1).uppercased() + provider.dropFirst()
|
||||
case .federated(_, let instance):
|
||||
return instance.host ?? String(localized: "instances.type.peertube")
|
||||
case .extracted(let extractor, let originalURL):
|
||||
// Capitalize the extractor name, or fall back to URL host
|
||||
let formatted = extractor.replacingOccurrences(of: "_", with: " ")
|
||||
if formatted.isEmpty {
|
||||
return originalURL.host ?? "External"
|
||||
}
|
||||
return formatted.prefix(1).uppercased() + formatted.dropFirst()
|
||||
}
|
||||
}
|
||||
|
||||
var shortName: String {
|
||||
switch self {
|
||||
case .global(let provider):
|
||||
if provider == Self.youtubeProvider {
|
||||
return "YT"
|
||||
}
|
||||
return String(provider.prefix(4)).uppercased()
|
||||
case .federated(_, let instance):
|
||||
return instance.host?.components(separatedBy: ".").first?.prefix(8).description ?? "PT"
|
||||
case .extracted(let extractor, _):
|
||||
// Use first 4 chars of extractor name, uppercased
|
||||
return String(extractor.prefix(4)).uppercased()
|
||||
}
|
||||
}
|
||||
|
||||
var provider: String {
|
||||
switch self {
|
||||
case .global(let provider):
|
||||
return provider
|
||||
case .federated(let provider, _):
|
||||
return provider
|
||||
case .extracted(let extractor, _):
|
||||
return extractor
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codable
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case provider
|
||||
case instance
|
||||
case extractor
|
||||
case originalURL
|
||||
}
|
||||
|
||||
private enum SourceType: String, Codable {
|
||||
case global
|
||||
case federated
|
||||
case extracted
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(SourceType.self, forKey: .type)
|
||||
|
||||
switch type {
|
||||
case .global:
|
||||
let provider = try container.decode(String.self, forKey: .provider)
|
||||
self = .global(provider: provider)
|
||||
case .federated:
|
||||
let provider = try container.decode(String.self, forKey: .provider)
|
||||
let instance = try container.decode(URL.self, forKey: .instance)
|
||||
self = .federated(provider: provider, instance: instance)
|
||||
case .extracted:
|
||||
let extractor = try container.decode(String.self, forKey: .extractor)
|
||||
let originalURL = try container.decode(URL.self, forKey: .originalURL)
|
||||
self = .extracted(extractor: extractor, originalURL: originalURL)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
switch self {
|
||||
case .global(let provider):
|
||||
try container.encode(SourceType.global, forKey: .type)
|
||||
try container.encode(provider, forKey: .provider)
|
||||
case .federated(let provider, let instance):
|
||||
try container.encode(SourceType.federated, forKey: .type)
|
||||
try container.encode(provider, forKey: .provider)
|
||||
try container.encode(instance, forKey: .instance)
|
||||
case .extracted(let extractor, let originalURL):
|
||||
try container.encode(SourceType.extracted, forKey: .type)
|
||||
try container.encode(extractor, forKey: .extractor)
|
||||
try container.encode(originalURL, forKey: .originalURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Identifiable Conformance
|
||||
|
||||
extension ContentSource: Identifiable {
|
||||
var id: String {
|
||||
switch self {
|
||||
case .global(let provider):
|
||||
return "global:\(provider)"
|
||||
case .federated(let provider, let instance):
|
||||
return "federated:\(provider):\(instance.absoluteString)"
|
||||
case .extracted(let extractor, let originalURL):
|
||||
return "extracted:\(extractor):\(originalURL.absoluteString.hashValue)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Comparable
|
||||
|
||||
extension ContentSource: Comparable {
|
||||
static func < (lhs: ContentSource, rhs: ContentSource) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
// Global comes first
|
||||
case (.global, .federated), (.global, .extracted):
|
||||
return true
|
||||
case (.federated, .global), (.extracted, .global):
|
||||
return false
|
||||
// Federated comes before extracted
|
||||
case (.federated, .extracted):
|
||||
return true
|
||||
case (.extracted, .federated):
|
||||
return false
|
||||
// Compare within same type
|
||||
case (.global(let lProvider), .global(let rProvider)):
|
||||
return lProvider < rProvider
|
||||
case (.federated(let lProvider, let lInstance), .federated(let rProvider, let rInstance)):
|
||||
if lProvider != rProvider {
|
||||
return lProvider < rProvider
|
||||
}
|
||||
return lInstance.absoluteString < rInstance.absoluteString
|
||||
case (.extracted(let lExt, let lURL), .extracted(let rExt, let rURL)):
|
||||
if lExt != rExt {
|
||||
return lExt < rExt
|
||||
}
|
||||
return lURL.absoluteString < rURL.absoluteString
|
||||
}
|
||||
}
|
||||
}
|
||||
20
Yattee/Models/GitHubContributor.swift
Normal file
20
Yattee/Models/GitHubContributor.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// GitHubContributor.swift
|
||||
// Yattee
|
||||
//
|
||||
// Data model for GitHub contributor information.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A GitHub repository contributor.
|
||||
struct GitHubContributor: Identifiable, Decodable, Sendable {
|
||||
let id: Int
|
||||
let login: String
|
||||
let avatarUrl: String
|
||||
let htmlUrl: String
|
||||
let contributions: Int
|
||||
|
||||
var avatarURL: URL? { URL(string: avatarUrl) }
|
||||
var profileURL: URL? { URL(string: htmlUrl) }
|
||||
}
|
||||
254
Yattee/Models/Instance.swift
Normal file
254
Yattee/Models/Instance.swift
Normal file
@@ -0,0 +1,254 @@
|
||||
//
|
||||
// Instance.swift
|
||||
// Yattee
|
||||
//
|
||||
// Represents a backend instance (Invidious, Piped, PeerTube, or Yattee Server).
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The type of backend instance.
|
||||
enum InstanceType: String, Codable, CaseIterable, Sendable {
|
||||
case invidious
|
||||
case piped
|
||||
case peertube
|
||||
case yatteeServer
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .invidious: return String(localized: "instances.type.invidious")
|
||||
case .piped: return String(localized: "instances.type.piped")
|
||||
case .peertube: return String(localized: "instances.type.peertube")
|
||||
case .yatteeServer: return String(localized: "instances.type.yatteeServer")
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
"globe"
|
||||
}
|
||||
|
||||
var contentSource: ContentSource {
|
||||
switch self {
|
||||
case .invidious, .piped, .yatteeServer:
|
||||
return .global(provider: ContentSource.youtubeProvider)
|
||||
case .peertube:
|
||||
// For PeerTube, this should be called with the specific instance URL
|
||||
fatalError("Use contentSource(for:) for PeerTube instances")
|
||||
}
|
||||
}
|
||||
|
||||
func contentSource(for url: URL) -> ContentSource {
|
||||
switch self {
|
||||
case .invidious, .piped, .yatteeServer:
|
||||
return .global(provider: ContentSource.youtubeProvider)
|
||||
case .peertube:
|
||||
return .federated(provider: ContentSource.peertubeProvider, instance: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a backend instance configuration.
|
||||
struct Instance: Identifiable, Codable, Hashable, Sendable {
|
||||
/// Unique identifier for this instance configuration.
|
||||
let id: UUID
|
||||
|
||||
/// The type of this instance.
|
||||
let type: InstanceType
|
||||
|
||||
/// The base URL of the instance.
|
||||
let url: URL
|
||||
|
||||
/// Optional user-defined name for this instance.
|
||||
var name: String?
|
||||
|
||||
/// Whether this instance is currently enabled.
|
||||
var isEnabled: Bool
|
||||
|
||||
/// The date this instance was added.
|
||||
let dateAdded: Date
|
||||
|
||||
/// Optional API key if required by the instance.
|
||||
var apiKey: String?
|
||||
|
||||
/// Whether to allow invalid/self-signed SSL certificates.
|
||||
var allowInvalidCertificates: Bool
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
type: InstanceType,
|
||||
url: URL,
|
||||
name: String? = nil,
|
||||
isEnabled: Bool = true,
|
||||
dateAdded: Date = Date(),
|
||||
apiKey: String? = nil,
|
||||
allowInvalidCertificates: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.url = url
|
||||
self.name = name
|
||||
self.isEnabled = isEnabled
|
||||
self.dateAdded = dateAdded
|
||||
self.apiKey = apiKey
|
||||
self.allowInvalidCertificates = allowInvalidCertificates
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var displayName: String {
|
||||
name ?? url.host ?? url.absoluteString
|
||||
}
|
||||
|
||||
var contentSource: ContentSource {
|
||||
type.contentSource(for: url)
|
||||
}
|
||||
|
||||
/// Whether this instance provides YouTube content.
|
||||
var isYouTubeInstance: Bool {
|
||||
type == .invidious || type == .piped || type == .yatteeServer
|
||||
}
|
||||
|
||||
/// Whether this instance is a PeerTube instance.
|
||||
var isPeerTubeInstance: Bool {
|
||||
type == .peertube
|
||||
}
|
||||
|
||||
/// Whether this instance is a Yattee Server instance.
|
||||
var isYatteeServerInstance: Bool {
|
||||
type == .yatteeServer
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Instance Capabilities
|
||||
|
||||
extension Instance {
|
||||
/// Whether this instance supports advanced search filters (sort, date, duration, features).
|
||||
var supportsSearchFilters: Bool {
|
||||
type == .invidious || type == .yatteeServer
|
||||
}
|
||||
|
||||
/// Whether this instance supports user authentication/login.
|
||||
var supportsAuthentication: Bool {
|
||||
type == .invidious || type == .piped
|
||||
}
|
||||
|
||||
/// Whether this instance supports subscription feed.
|
||||
var supportsFeed: Bool {
|
||||
type == .invidious || type == .piped
|
||||
}
|
||||
|
||||
/// Whether this instance supports search suggestions/autocomplete.
|
||||
var supportsSuggestions: Bool {
|
||||
type == .invidious || type == .piped || type == .yatteeServer
|
||||
}
|
||||
|
||||
/// Whether this instance supports the popular videos endpoint.
|
||||
var supportsPopular: Bool {
|
||||
type == .invidious || type == .yatteeServer
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Instance Validation
|
||||
|
||||
extension Instance {
|
||||
/// Validates the instance URL format.
|
||||
static func validateURL(_ urlString: String) -> URL? {
|
||||
guard var components = URLComponents(string: urlString) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Default to HTTPS if no scheme provided, but preserve explicit HTTP
|
||||
// (needed for local/private network servers like yt-dlp server)
|
||||
if components.scheme == nil {
|
||||
components.scheme = "https"
|
||||
}
|
||||
|
||||
// Remove trailing slash from path
|
||||
if components.path.hasSuffix("/") {
|
||||
components.path = String(components.path.dropLast())
|
||||
}
|
||||
|
||||
return components.url
|
||||
}
|
||||
|
||||
/// Checks if a string is an IPv4 or IPv6 address.
|
||||
static func isIPAddress(_ string: String) -> Bool {
|
||||
// IPv4: four groups of 1-3 digits separated by dots
|
||||
let ipv4Pattern = "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"
|
||||
if string.range(of: ipv4Pattern, options: .regularExpression) != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// IPv6: contains colons (simplified check)
|
||||
if string.contains(":") && !string.contains("://") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/// Infers the appropriate scheme for a URL string based on the host.
|
||||
/// - IP addresses default to http:// (common for local servers)
|
||||
/// - Domain names default to https://
|
||||
/// - Explicit schemes are preserved
|
||||
static func inferScheme(for urlString: String) -> String {
|
||||
// Already has a scheme
|
||||
if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") || urlString.hasPrefix("smb://") {
|
||||
if urlString.hasPrefix("http://") { return "http" }
|
||||
if urlString.hasPrefix("https://") { return "https" }
|
||||
if urlString.hasPrefix("smb://") { return "smb" }
|
||||
}
|
||||
|
||||
// Extract host part (before any path or port)
|
||||
let hostPart = urlString
|
||||
.replacingOccurrences(of: "http://", with: "")
|
||||
.replacingOccurrences(of: "https://", with: "")
|
||||
.components(separatedBy: "/").first ?? urlString
|
||||
|
||||
// Remove port if present
|
||||
let hostWithoutPort = hostPart.components(separatedBy: ":").first ?? hostPart
|
||||
|
||||
// IP addresses use http (common for local servers)
|
||||
if isIPAddress(hostWithoutPort) {
|
||||
return "http"
|
||||
}
|
||||
|
||||
// Domain names use https
|
||||
return "https"
|
||||
}
|
||||
|
||||
/// Normalizes a source URL string, applying appropriate scheme and cleaning up the URL.
|
||||
/// - Parameter urlString: The raw URL input from user
|
||||
/// - Returns: A normalized URL or nil if invalid
|
||||
static func normalizeSourceURL(_ urlString: String) -> URL? {
|
||||
var input = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Handle SMB URLs specially
|
||||
if input.lowercased().hasPrefix("smb://") {
|
||||
return URL(string: input)
|
||||
}
|
||||
|
||||
// Add scheme if missing
|
||||
if !input.lowercased().hasPrefix("http://") && !input.lowercased().hasPrefix("https://") {
|
||||
let scheme = inferScheme(for: input)
|
||||
input = "\(scheme)://\(input)"
|
||||
}
|
||||
|
||||
guard var components = URLComponents(string: input) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove trailing slash from path
|
||||
if components.path.hasSuffix("/") {
|
||||
components.path = String(components.path.dropLast())
|
||||
}
|
||||
|
||||
// Strip embedded credentials (security best practice)
|
||||
components.user = nil
|
||||
components.password = nil
|
||||
|
||||
return components.url
|
||||
}
|
||||
}
|
||||
49
Yattee/Models/MediaSources/MediaBrowserSortOrder.swift
Normal file
49
Yattee/Models/MediaSources/MediaBrowserSortOrder.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// MediaBrowserSortOrder.swift
|
||||
// Yattee
|
||||
//
|
||||
// Sort order options for media browser file listing.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum MediaBrowserSortOrder: String, CaseIterable, Identifiable {
|
||||
case name
|
||||
case dateModified
|
||||
case dateCreated
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .name:
|
||||
String(localized: "mediaBrowser.sort.name")
|
||||
case .dateModified:
|
||||
String(localized: "mediaBrowser.sort.dateModified")
|
||||
case .dateCreated:
|
||||
String(localized: "mediaBrowser.sort.dateCreated")
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .name:
|
||||
"textformat"
|
||||
case .dateModified:
|
||||
"clock"
|
||||
case .dateCreated:
|
||||
"calendar"
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns available sort options for a given source type.
|
||||
/// WebDAV and SMB sources don't support creation date, so it's excluded.
|
||||
static func availableOptions(for sourceType: MediaSourceType) -> [MediaBrowserSortOrder] {
|
||||
switch sourceType {
|
||||
case .localFolder:
|
||||
allCases
|
||||
case .webdav, .smb:
|
||||
[.name, .dateModified]
|
||||
}
|
||||
}
|
||||
}
|
||||
384
Yattee/Models/MediaSources/MediaFile.swift
Normal file
384
Yattee/Models/MediaSources/MediaFile.swift
Normal file
@@ -0,0 +1,384 @@
|
||||
//
|
||||
// MediaFile.swift
|
||||
// Yattee
|
||||
//
|
||||
// Represents a file or folder from a media source.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents a file or folder from a media source.
|
||||
struct MediaFile: Identifiable, Hashable, Sendable {
|
||||
/// Unique identifier combining source and path.
|
||||
var id: String { "\(source.id):\(path)" }
|
||||
|
||||
/// The media source this file belongs to.
|
||||
let source: MediaSource
|
||||
|
||||
/// Full path from the source root.
|
||||
let path: String
|
||||
|
||||
/// Display name of the file/folder.
|
||||
let name: String
|
||||
|
||||
/// Whether this is a directory.
|
||||
let isDirectory: Bool
|
||||
|
||||
/// Whether this is an SMB share (top-level directory on SMB server).
|
||||
let isShare: Bool
|
||||
|
||||
/// File size in bytes (nil for directories).
|
||||
let size: Int64?
|
||||
|
||||
/// Last modified date.
|
||||
let modifiedDate: Date?
|
||||
|
||||
/// Creation date.
|
||||
let createdDate: Date?
|
||||
|
||||
/// MIME type if known.
|
||||
let mimeType: String?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
source: MediaSource,
|
||||
path: String,
|
||||
name: String,
|
||||
isDirectory: Bool,
|
||||
isShare: Bool = false,
|
||||
size: Int64? = nil,
|
||||
modifiedDate: Date? = nil,
|
||||
createdDate: Date? = nil,
|
||||
mimeType: String? = nil
|
||||
) {
|
||||
self.source = source
|
||||
self.path = path
|
||||
self.name = name
|
||||
self.isDirectory = isDirectory
|
||||
self.isShare = isShare
|
||||
self.size = size
|
||||
self.modifiedDate = modifiedDate
|
||||
self.createdDate = createdDate
|
||||
self.mimeType = mimeType
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// Full URL to this file.
|
||||
var url: URL {
|
||||
source.url.appendingPathComponent(path)
|
||||
}
|
||||
|
||||
/// File extension (lowercase).
|
||||
var fileExtension: String {
|
||||
(name as NSString).pathExtension.lowercased()
|
||||
}
|
||||
|
||||
/// Whether this file is a video.
|
||||
var isVideo: Bool {
|
||||
guard !isDirectory else { return false }
|
||||
return Self.videoExtensions.contains(fileExtension)
|
||||
}
|
||||
|
||||
/// Whether this file is an audio file.
|
||||
var isAudio: Bool {
|
||||
guard !isDirectory else { return false }
|
||||
return Self.audioExtensions.contains(fileExtension)
|
||||
}
|
||||
|
||||
/// Whether this file is playable media.
|
||||
var isPlayable: Bool {
|
||||
isVideo || isAudio
|
||||
}
|
||||
|
||||
/// Whether this file is a subtitle file.
|
||||
var isSubtitle: Bool {
|
||||
guard !isDirectory else { return false }
|
||||
return Self.subtitleExtensions.contains(fileExtension)
|
||||
}
|
||||
|
||||
/// File name without extension.
|
||||
var baseName: String {
|
||||
(name as NSString).deletingPathExtension
|
||||
}
|
||||
|
||||
/// Formatted file size string.
|
||||
var formattedSize: String? {
|
||||
// Don't show size for directories and shares
|
||||
guard !isDirectory, !isShare else { return nil }
|
||||
guard let size else { return nil }
|
||||
return ByteCountFormatter.string(fromByteCount: size, countStyle: .file)
|
||||
}
|
||||
|
||||
/// System image name for the file type.
|
||||
var systemImage: String {
|
||||
if isShare {
|
||||
return "externaldrive.fill"
|
||||
}
|
||||
if isDirectory {
|
||||
return "folder.fill"
|
||||
}
|
||||
if isVideo {
|
||||
return "film"
|
||||
}
|
||||
if isAudio {
|
||||
return "music.note"
|
||||
}
|
||||
if isSubtitle {
|
||||
return "captions.bubble"
|
||||
}
|
||||
return "doc"
|
||||
}
|
||||
|
||||
// MARK: - Supported Extensions
|
||||
|
||||
static let videoExtensions: Set<String> = [
|
||||
"mp4", "m4v", "mov", "mkv", "avi", "webm", "wmv",
|
||||
"flv", "mpg", "mpeg", "3gp", "3gpp", "ts", "m2ts",
|
||||
"vob", "ogv", "rm", "rmvb", "asf", "divx"
|
||||
]
|
||||
|
||||
static let audioExtensions: Set<String> = [
|
||||
"mp3", "m4a", "aac", "flac", "wav", "ogg", "opus",
|
||||
"wma", "aiff", "alac", "ape"
|
||||
]
|
||||
|
||||
static let subtitleExtensions: Set<String> = [
|
||||
"srt", "vtt", "ass", "ssa", "sub"
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Conversion to Video/Stream
|
||||
|
||||
extension MediaFile {
|
||||
/// Provider name for WebDAV media sources (syncs to iCloud).
|
||||
static let webdavProvider = "media_source_webdav"
|
||||
|
||||
/// Provider name for local folder media sources (device-specific, never syncs).
|
||||
static let localFolderProvider = "media_source_local"
|
||||
|
||||
/// Provider name for SMB media sources (syncs to iCloud).
|
||||
static let smbProvider = "media_source_smb"
|
||||
|
||||
/// Returns the appropriate provider name based on source type.
|
||||
private var providerName: String {
|
||||
switch source.type {
|
||||
case .webdav:
|
||||
return Self.webdavProvider
|
||||
case .localFolder:
|
||||
return Self.localFolderProvider
|
||||
case .smb:
|
||||
return Self.smbProvider
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a Video model for playback.
|
||||
func toVideo() -> Video {
|
||||
Video(
|
||||
id: VideoID(
|
||||
source: .extracted(extractor: providerName, originalURL: url),
|
||||
videoID: id
|
||||
),
|
||||
title: (name as NSString).deletingPathExtension,
|
||||
description: nil,
|
||||
author: Author(id: source.id.uuidString, name: source.name),
|
||||
duration: 0, // Unknown until playback
|
||||
publishedAt: modifiedDate,
|
||||
publishedText: modifiedDate?.formatted(date: .abbreviated, time: .shortened),
|
||||
viewCount: nil,
|
||||
likeCount: nil,
|
||||
thumbnails: [], // No thumbnails for local/WebDAV files
|
||||
isLive: false,
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a Stream for direct playback.
|
||||
/// - Parameter authHeaders: Optional HTTP headers for authentication.
|
||||
func toStream(authHeaders: [String: String]? = nil) -> Stream {
|
||||
Stream(
|
||||
url: url,
|
||||
resolution: nil, // Unknown
|
||||
format: fileExtension,
|
||||
httpHeaders: authHeaders
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview Support
|
||||
|
||||
extension MediaFile {
|
||||
/// Sample media file for previews.
|
||||
static var preview: MediaFile {
|
||||
MediaFile(
|
||||
source: .webdav(name: "My NAS", url: URL(string: "https://nas.local")!),
|
||||
path: "/Movies/Sample Movie.mp4",
|
||||
name: "Sample Movie.mp4",
|
||||
isDirectory: false,
|
||||
size: 1_500_000_000,
|
||||
modifiedDate: Date().addingTimeInterval(-86400 * 7)
|
||||
)
|
||||
}
|
||||
|
||||
/// Sample folder for previews.
|
||||
static var folderPreview: MediaFile {
|
||||
MediaFile(
|
||||
source: .webdav(name: "My NAS", url: URL(string: "https://nas.local")!),
|
||||
path: "/Movies",
|
||||
name: "Movies",
|
||||
isDirectory: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Matching
|
||||
|
||||
extension MediaFile {
|
||||
/// Finds subtitle files in the given list that match this video file.
|
||||
/// - Parameter files: List of files to search for matching subtitles.
|
||||
/// - Returns: Array of Caption objects for matching subtitle files.
|
||||
func findMatchingSubtitles(in files: [MediaFile]) -> [Caption] {
|
||||
guard isVideo else { return [] }
|
||||
|
||||
let videoBaseName = baseName
|
||||
return files
|
||||
.filter { $0.isSubtitle && $0.matchesVideo(baseName: videoBaseName) }
|
||||
.compactMap { $0.toCaption(videoBaseName: videoBaseName) }
|
||||
.sorted { caption1, caption2 in
|
||||
// Sort: exact match (und) first, then alphabetically by language
|
||||
if caption1.languageCode == "und" && caption2.languageCode != "und" {
|
||||
return true
|
||||
}
|
||||
if caption2.languageCode == "und" && caption1.languageCode != "und" {
|
||||
return false
|
||||
}
|
||||
return caption1.displayName.localizedCaseInsensitiveCompare(caption2.displayName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if this subtitle file matches a video with the given base name.
|
||||
private func matchesVideo(baseName videoBaseName: String) -> Bool {
|
||||
let subtitleBase = baseName
|
||||
|
||||
// Exact match: video.srt for video.mp4
|
||||
if subtitleBase == videoBaseName {
|
||||
return true
|
||||
}
|
||||
|
||||
// Language suffix with dot: video.en.srt, video.eng.srt, video.English.srt
|
||||
if subtitleBase.hasPrefix(videoBaseName + ".") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Language suffix with underscore: video_en.srt
|
||||
if subtitleBase.hasPrefix(videoBaseName + "_") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/// Converts this subtitle file to a Caption object.
|
||||
private func toCaption(videoBaseName: String) -> Caption? {
|
||||
guard isSubtitle else { return nil }
|
||||
|
||||
let languageCode = extractLanguageCode(videoBaseName: videoBaseName)
|
||||
let displayName: String
|
||||
|
||||
if languageCode == "und" {
|
||||
// For exact match without language code, use "Default" as label
|
||||
displayName = String(localized: "subtitles.default", defaultValue: "Default")
|
||||
} else if let localizedName = Locale.current.localizedString(forLanguageCode: languageCode) {
|
||||
displayName = localizedName
|
||||
} else {
|
||||
displayName = languageCode
|
||||
}
|
||||
|
||||
return Caption(
|
||||
label: displayName,
|
||||
languageCode: languageCode,
|
||||
url: url
|
||||
)
|
||||
}
|
||||
|
||||
/// Extracts the language code from this subtitle filename.
|
||||
private func extractLanguageCode(videoBaseName: String) -> String {
|
||||
let subtitleBase = baseName
|
||||
|
||||
// Check for pattern: video.en.srt or video_en.srt
|
||||
let dotPrefix = videoBaseName + "."
|
||||
let underscorePrefix = videoBaseName + "_"
|
||||
|
||||
var suffix: String?
|
||||
if subtitleBase.hasPrefix(dotPrefix) {
|
||||
suffix = String(subtitleBase.dropFirst(dotPrefix.count))
|
||||
} else if subtitleBase.hasPrefix(underscorePrefix) {
|
||||
suffix = String(subtitleBase.dropFirst(underscorePrefix.count))
|
||||
}
|
||||
|
||||
guard let suffix, !suffix.isEmpty else {
|
||||
return "und" // undefined - exact match without language
|
||||
}
|
||||
|
||||
// Normalize the language code
|
||||
return Self.normalizeLanguageCode(suffix)
|
||||
}
|
||||
|
||||
/// Normalizes a language code or name to a standard 2-letter code.
|
||||
private static func normalizeLanguageCode(_ input: String) -> String {
|
||||
let lowercased = input.lowercased()
|
||||
|
||||
// Map common 3-letter codes to 2-letter
|
||||
let threeToTwo: [String: String] = [
|
||||
"eng": "en", "deu": "de", "ger": "de", "fra": "fr", "fre": "fr",
|
||||
"spa": "es", "ita": "it", "por": "pt", "rus": "ru", "jpn": "ja",
|
||||
"kor": "ko", "zho": "zh", "chi": "zh", "ara": "ar", "hin": "hi",
|
||||
"pol": "pl", "nld": "nl", "dut": "nl", "swe": "sv", "nor": "no",
|
||||
"dan": "da", "fin": "fi", "tur": "tr", "ces": "cs", "cze": "cs",
|
||||
"hun": "hu", "ell": "el", "gre": "el", "heb": "he", "tha": "th",
|
||||
"vie": "vi", "ukr": "uk", "ron": "ro", "rum": "ro", "bul": "bg",
|
||||
"hrv": "hr", "slk": "sk", "slo": "sk", "slv": "sl", "srp": "sr",
|
||||
"cat": "ca", "eus": "eu", "baq": "eu", "glg": "gl", "ind": "id",
|
||||
"msa": "ms", "may": "ms", "fil": "tl", "tgl": "tl"
|
||||
]
|
||||
|
||||
if let twoLetter = threeToTwo[lowercased] {
|
||||
return twoLetter
|
||||
}
|
||||
|
||||
// Map common full language names to codes
|
||||
let nameToCode: [String: String] = [
|
||||
"english": "en", "german": "de", "deutsch": "de", "french": "fr",
|
||||
"français": "fr", "francais": "fr", "spanish": "es", "español": "es",
|
||||
"espanol": "es", "italian": "it", "italiano": "it", "portuguese": "pt",
|
||||
"português": "pt", "portugues": "pt", "russian": "ru", "русский": "ru",
|
||||
"japanese": "ja", "日本語": "ja", "korean": "ko", "한국어": "ko",
|
||||
"chinese": "zh", "中文": "zh", "arabic": "ar", "العربية": "ar",
|
||||
"hindi": "hi", "हिन्दी": "hi", "polish": "pl", "polski": "pl",
|
||||
"dutch": "nl", "nederlands": "nl", "swedish": "sv", "svenska": "sv",
|
||||
"norwegian": "no", "norsk": "no", "danish": "da", "dansk": "da",
|
||||
"finnish": "fi", "suomi": "fi", "turkish": "tr", "türkçe": "tr",
|
||||
"czech": "cs", "čeština": "cs", "hungarian": "hu", "magyar": "hu",
|
||||
"greek": "el", "ελληνικά": "el", "hebrew": "he", "עברית": "he",
|
||||
"thai": "th", "ไทย": "th", "vietnamese": "vi", "tiếng việt": "vi",
|
||||
"ukrainian": "uk", "українська": "uk", "romanian": "ro", "română": "ro",
|
||||
"bulgarian": "bg", "български": "bg", "croatian": "hr", "hrvatski": "hr",
|
||||
"slovak": "sk", "slovenčina": "sk", "slovenian": "sl", "slovenščina": "sl",
|
||||
"serbian": "sr", "српски": "sr"
|
||||
]
|
||||
|
||||
if let code = nameToCode[lowercased] {
|
||||
return code
|
||||
}
|
||||
|
||||
// If it's already a 2-letter code, return it
|
||||
if input.count == 2 {
|
||||
return lowercased
|
||||
}
|
||||
|
||||
// Unknown - return as-is (could be a valid code we don't recognize)
|
||||
return lowercased
|
||||
}
|
||||
}
|
||||
214
Yattee/Models/MediaSources/MediaSource.swift
Normal file
214
Yattee/Models/MediaSources/MediaSource.swift
Normal file
@@ -0,0 +1,214 @@
|
||||
//
|
||||
// MediaSource.swift
|
||||
// Yattee
|
||||
//
|
||||
// Represents a media source configuration for browsing local or remote files.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The type of media source.
|
||||
enum MediaSourceType: String, Codable, Hashable, Sendable, CaseIterable {
|
||||
case webdav // Remote WebDAV server
|
||||
case localFolder // Local folder (iOS Files app / macOS directory)
|
||||
case smb // SMB/CIFS network share
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .webdav:
|
||||
return String(localized: "sources.type.webdav")
|
||||
case .localFolder:
|
||||
return String(localized: "sources.type.localFolder")
|
||||
case .smb:
|
||||
return String(localized: "sources.type.smb")
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .webdav:
|
||||
return "externaldrive.connected.to.line.below"
|
||||
case .localFolder:
|
||||
return "folder"
|
||||
case .smb:
|
||||
return "externaldrive.connected.to.line.below"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a configured media source for browsing files.
|
||||
struct MediaSource: Identifiable, Codable, Hashable, Sendable {
|
||||
let id: UUID
|
||||
var name: String
|
||||
let type: MediaSourceType
|
||||
let url: URL
|
||||
var isEnabled: Bool
|
||||
let dateAdded: Date
|
||||
|
||||
// WebDAV-specific
|
||||
var username: String?
|
||||
|
||||
// Local folder-specific - security-scoped bookmark data for persistent access
|
||||
var bookmarkData: Data?
|
||||
|
||||
// Whether to allow invalid/self-signed SSL certificates (WebDAV only)
|
||||
var allowInvalidCertificates: Bool
|
||||
|
||||
// SMB-specific
|
||||
var smbWorkgroup: String?
|
||||
var smbProtocolVersion: SMBProtocol?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
type: MediaSourceType,
|
||||
url: URL,
|
||||
isEnabled: Bool = true,
|
||||
dateAdded: Date = Date(),
|
||||
username: String? = nil,
|
||||
bookmarkData: Data? = nil,
|
||||
allowInvalidCertificates: Bool = false,
|
||||
smbWorkgroup: String? = nil,
|
||||
smbProtocolVersion: SMBProtocol? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.url = url
|
||||
self.isEnabled = isEnabled
|
||||
self.dateAdded = dateAdded
|
||||
self.username = username
|
||||
self.bookmarkData = bookmarkData
|
||||
self.allowInvalidCertificates = allowInvalidCertificates
|
||||
self.smbWorkgroup = smbWorkgroup
|
||||
self.smbProtocolVersion = smbProtocolVersion
|
||||
}
|
||||
|
||||
// MARK: - Factory Methods
|
||||
|
||||
/// Creates a WebDAV media source.
|
||||
static func webdav(
|
||||
name: String,
|
||||
url: URL,
|
||||
username: String? = nil,
|
||||
allowInvalidCertificates: Bool = false
|
||||
) -> MediaSource {
|
||||
MediaSource(
|
||||
name: name,
|
||||
type: .webdav,
|
||||
url: url,
|
||||
username: username,
|
||||
allowInvalidCertificates: allowInvalidCertificates
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a local folder media source.
|
||||
static func localFolder(
|
||||
name: String,
|
||||
url: URL,
|
||||
bookmarkData: Data? = nil
|
||||
) -> MediaSource {
|
||||
MediaSource(
|
||||
name: name,
|
||||
type: .localFolder,
|
||||
url: url,
|
||||
bookmarkData: bookmarkData
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates an SMB/CIFS media source.
|
||||
static func smb(
|
||||
name: String,
|
||||
url: URL,
|
||||
username: String? = nil,
|
||||
workgroup: String? = nil,
|
||||
protocolVersion: SMBProtocol? = nil
|
||||
) -> MediaSource {
|
||||
MediaSource(
|
||||
name: name,
|
||||
type: .smb,
|
||||
url: url,
|
||||
username: username,
|
||||
smbWorkgroup: workgroup,
|
||||
smbProtocolVersion: protocolVersion
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// Display string for the source URL.
|
||||
var urlDisplayString: String {
|
||||
switch type {
|
||||
case .webdav:
|
||||
return url.host ?? url.absoluteString
|
||||
case .localFolder:
|
||||
return url.lastPathComponent
|
||||
case .smb:
|
||||
return url.host ?? url.absoluteString
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this source requires authentication.
|
||||
var requiresAuthentication: Bool {
|
||||
(type == .webdav || type == .smb) && username != nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Video Extension for Media Source Detection
|
||||
|
||||
extension Video {
|
||||
/// Whether this video is from a local folder media source (device-specific).
|
||||
var isFromLocalFolder: Bool {
|
||||
if case .extracted(let extractor, _) = id.source {
|
||||
return extractor == MediaFile.localFolderProvider
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Whether this video is from a WebDAV media source.
|
||||
var isFromWebDAV: Bool {
|
||||
if case .extracted(let extractor, _) = id.source {
|
||||
return extractor == MediaFile.webdavProvider
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Whether this video is from an SMB media source.
|
||||
var isFromSMB: Bool {
|
||||
if case .extracted(let extractor, _) = id.source {
|
||||
return extractor == MediaFile.smbProvider
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Whether this video is from any media source (WebDAV, SMB, or local folder).
|
||||
var isFromMediaSource: Bool {
|
||||
if case .extracted(let extractor, _) = id.source {
|
||||
return extractor == MediaFile.webdavProvider
|
||||
|| extractor == MediaFile.localFolderProvider
|
||||
|| extractor == MediaFile.smbProvider
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Returns the media source UUID if this video is from any media source (WebDAV, SMB, or local folder).
|
||||
/// The videoID format is "sourceUUID:path/to/file".
|
||||
var mediaSourceID: UUID? {
|
||||
guard isFromMediaSource else { return nil }
|
||||
// videoID format: "sourceUUID:path"
|
||||
let components = id.videoID.split(separator: ":", maxSplits: 1)
|
||||
guard let uuidString = components.first else { return nil }
|
||||
return UUID(uuidString: String(uuidString))
|
||||
}
|
||||
|
||||
/// Returns the file path if this video is from a media source.
|
||||
/// The videoID format is "sourceUUID:path/to/file".
|
||||
var mediaSourceFilePath: String? {
|
||||
guard isFromMediaSource else { return nil }
|
||||
let components = id.videoID.split(separator: ":", maxSplits: 1)
|
||||
guard components.count == 2 else { return nil }
|
||||
return String(components[1])
|
||||
}
|
||||
}
|
||||
53
Yattee/Models/Navigation/AppTab.swift
Normal file
53
Yattee/Models/Navigation/AppTab.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// AppTab.swift
|
||||
// Yattee
|
||||
//
|
||||
// Main app tab definitions.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum AppTab: String, CaseIterable, Identifiable {
|
||||
case home
|
||||
case subscriptions
|
||||
case search
|
||||
#if os(tvOS)
|
||||
case settings
|
||||
#endif
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .home: return String(localized: "tabs.home")
|
||||
case .subscriptions: return String(localized: "tabs.subscriptions")
|
||||
case .search: return String(localized: "tabs.search")
|
||||
#if os(tvOS)
|
||||
case .settings: return String(localized: "tabs.settings")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .home: return "house.fill"
|
||||
case .subscriptions: return "play.square.stack.fill"
|
||||
case .search: return "magnifyingglass"
|
||||
#if os(tvOS)
|
||||
case .settings: return "gearshape"
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// SidebarItem equivalent for UnifiedTabView navigation.
|
||||
var sidebarItem: SidebarItem {
|
||||
switch self {
|
||||
case .home: return .home
|
||||
case .subscriptions: return .subscriptionsFeed
|
||||
case .search: return .search
|
||||
#if os(tvOS)
|
||||
case .settings: return .settings
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
51
Yattee/Models/Navigation/ChannelTab.swift
Normal file
51
Yattee/Models/Navigation/ChannelTab.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// ChannelTab.swift
|
||||
// Yattee
|
||||
//
|
||||
// Navigation tabs for channel content views.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents different content tabs available on a channel page.
|
||||
enum ChannelTab: String, CaseIterable, Identifiable {
|
||||
case about
|
||||
case videos
|
||||
case playlists
|
||||
case shorts
|
||||
case streams
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Localized title for the tab.
|
||||
var title: String {
|
||||
switch self {
|
||||
case .about:
|
||||
return String(localized: "channel.tab.about")
|
||||
case .videos:
|
||||
return String(localized: "channel.tab.videos")
|
||||
case .playlists:
|
||||
return String(localized: "channel.tab.playlists")
|
||||
case .shorts:
|
||||
return String(localized: "channel.tab.shorts")
|
||||
case .streams:
|
||||
return String(localized: "channel.tab.streams")
|
||||
}
|
||||
}
|
||||
|
||||
/// SF Symbol name for the tab icon.
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .about:
|
||||
return "info.circle.fill"
|
||||
case .videos:
|
||||
return "play.rectangle.fill"
|
||||
case .playlists:
|
||||
return "list.bullet.rectangle.fill"
|
||||
case .shorts:
|
||||
return "bolt.fill"
|
||||
case .streams:
|
||||
return "video.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
33
Yattee/Models/Navigation/HomeTab.swift
Normal file
33
Yattee/Models/Navigation/HomeTab.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// HomeTab.swift
|
||||
// Yattee
|
||||
//
|
||||
// Home tab selection definitions.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Home tab selection.
|
||||
enum HomeTab: String, CaseIterable, Identifiable {
|
||||
case playlists
|
||||
case history
|
||||
case downloads
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .playlists: return String(localized: "home.playlists.title")
|
||||
case .history: return String(localized: "home.history.title")
|
||||
case .downloads: return String(localized: "home.downloads.title")
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .playlists: return "list.bullet.rectangle"
|
||||
case .history: return "clock"
|
||||
case .downloads: return "arrow.down.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
Yattee/Models/Navigation/PlayerInfoTab.swift
Normal file
20
Yattee/Models/Navigation/PlayerInfoTab.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// PlayerInfoTab.swift
|
||||
// Yattee
|
||||
//
|
||||
// Player info tab definitions.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum PlayerInfoTab: String, CaseIterable {
|
||||
case description
|
||||
case comments
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .description: return String(localized: "player.description")
|
||||
case .comments: return String(localized: "player.comments")
|
||||
}
|
||||
}
|
||||
}
|
||||
27
Yattee/Models/Navigation/SearchResultType.swift
Normal file
27
Yattee/Models/Navigation/SearchResultType.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// SearchResultType.swift
|
||||
// Yattee
|
||||
//
|
||||
// Search result type filter definitions.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Search result type filter.
|
||||
enum SearchResultType: String, CaseIterable, Identifiable {
|
||||
case all
|
||||
case videos
|
||||
case channels
|
||||
case playlists
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all: return String(localized: "search.filter.all")
|
||||
case .videos: return String(localized: "search.filter.videos")
|
||||
case .channels: return String(localized: "search.filter.channels")
|
||||
case .playlists: return String(localized: "search.filter.playlists")
|
||||
}
|
||||
}
|
||||
}
|
||||
235
Yattee/Models/Navigation/SidebarItem.swift
Normal file
235
Yattee/Models/Navigation/SidebarItem.swift
Normal file
@@ -0,0 +1,235 @@
|
||||
//
|
||||
// SidebarItem.swift
|
||||
// Yattee
|
||||
//
|
||||
// Represents items that can appear in the sidebar navigation.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents all possible sidebar items for navigation.
|
||||
enum SidebarItem: Hashable, Identifiable {
|
||||
// MARK: - Fixed Navigation Items
|
||||
case home
|
||||
case search
|
||||
case sources
|
||||
case settings
|
||||
case nowPlaying
|
||||
|
||||
// MARK: - Dynamic Channel Items
|
||||
case channel(channelID: String, name: String, source: ContentSource)
|
||||
|
||||
// MARK: - Dynamic Playlist Items
|
||||
case playlist(id: UUID, title: String)
|
||||
|
||||
// MARK: - Dynamic Media Source Items
|
||||
case mediaSource(id: UUID, name: String, type: MediaSourceType)
|
||||
|
||||
// MARK: - Dynamic Instance Items
|
||||
case instance(id: UUID, name: String, type: InstanceType)
|
||||
|
||||
// MARK: - Collection Items
|
||||
case bookmarks
|
||||
case history
|
||||
case downloads
|
||||
case subscriptionsFeed
|
||||
case manageChannels
|
||||
|
||||
// MARK: - Identifiable
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .home:
|
||||
return "home"
|
||||
case .search:
|
||||
return "search"
|
||||
case .sources:
|
||||
return "sources"
|
||||
case .settings:
|
||||
return "settings"
|
||||
case .nowPlaying:
|
||||
return "now-playing"
|
||||
case .channel(let channelID, _, let source):
|
||||
return "channel-\(source.provider)-\(channelID)"
|
||||
case .playlist(let id, _):
|
||||
return "playlist-\(id.uuidString)"
|
||||
case .mediaSource(let id, _, _):
|
||||
return "mediasource-\(id.uuidString)"
|
||||
case .instance(let id, _, _):
|
||||
return "instance-\(id.uuidString)"
|
||||
case .bookmarks:
|
||||
return "bookmarks"
|
||||
case .history:
|
||||
return "history"
|
||||
case .downloads:
|
||||
return "downloads"
|
||||
case .subscriptionsFeed:
|
||||
return "subscriptions-feed"
|
||||
case .manageChannels:
|
||||
return "manage-channels"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Display Properties
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .home:
|
||||
return String(localized: "tabs.home")
|
||||
case .search:
|
||||
return String(localized: "tabs.search")
|
||||
case .sources:
|
||||
return String(localized: "tabs.sources")
|
||||
case .settings:
|
||||
return String(localized: "tabs.settings")
|
||||
case .nowPlaying:
|
||||
return String(localized: "sidebar.nowPlaying")
|
||||
case .channel(_, let name, _):
|
||||
return name
|
||||
case .playlist(_, let title):
|
||||
return title
|
||||
case .mediaSource(_, let name, _):
|
||||
return name
|
||||
case .instance(_, let name, _):
|
||||
return name
|
||||
case .bookmarks:
|
||||
return String(localized: "home.bookmarks.title")
|
||||
case .history:
|
||||
return String(localized: "home.history.title")
|
||||
case .downloads:
|
||||
return String(localized: "home.downloads.title")
|
||||
case .subscriptionsFeed:
|
||||
return String(localized: "home.subscriptions.title")
|
||||
case .manageChannels:
|
||||
return String(localized: "sidebar.manageChannels")
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .home:
|
||||
return "house.fill"
|
||||
case .search:
|
||||
return "magnifyingglass"
|
||||
case .sources:
|
||||
return "server.rack"
|
||||
case .settings:
|
||||
return "gear"
|
||||
case .nowPlaying:
|
||||
return "play.circle.fill"
|
||||
case .channel:
|
||||
return "person.circle"
|
||||
case .playlist:
|
||||
return "list.bullet.rectangle"
|
||||
case .mediaSource(_, _, let type):
|
||||
return type.systemImage
|
||||
case .instance(_, _, let type):
|
||||
return type.systemImage
|
||||
case .bookmarks:
|
||||
return "bookmark.fill"
|
||||
case .history:
|
||||
return "clock"
|
||||
case .downloads:
|
||||
return "arrow.down.circle"
|
||||
case .subscriptionsFeed:
|
||||
return "play.square.stack.fill"
|
||||
case .manageChannels:
|
||||
return "person.2"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
/// Converts this sidebar item to a NavigationDestination for pushing onto the navigation stack.
|
||||
/// Returns nil for items that are root views (home, search) which don't push.
|
||||
func navigationDestination() -> NavigationDestination? {
|
||||
switch self {
|
||||
case .home, .search, .sources, .settings, .nowPlaying:
|
||||
// These are root tabs, not push destinations
|
||||
return nil
|
||||
case .channel(let channelID, _, let source):
|
||||
return .channel(channelID, source)
|
||||
case .playlist(let id, let title):
|
||||
return .playlist(.local(id, title: title))
|
||||
case .mediaSource(let id, _, _):
|
||||
return .mediaSource(id)
|
||||
case .instance:
|
||||
// Instances are root views in the sidebar, not push destinations
|
||||
return nil
|
||||
case .bookmarks:
|
||||
return .bookmarks
|
||||
case .history:
|
||||
return .history
|
||||
case .downloads:
|
||||
return .downloads
|
||||
case .subscriptionsFeed:
|
||||
return .subscriptionsFeed
|
||||
case .manageChannels:
|
||||
return .manageChannels
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Item Categories
|
||||
|
||||
/// Whether this is a fixed navigation item (always visible).
|
||||
var isFixedNavigation: Bool {
|
||||
switch self {
|
||||
case .home, .search, .sources, .settings, .nowPlaying:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is a dynamic channel item.
|
||||
var isChannel: Bool {
|
||||
if case .channel = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// Whether this is a dynamic playlist item.
|
||||
var isPlaylist: Bool {
|
||||
if case .playlist = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// Whether this is a dynamic media source item.
|
||||
var isMediaSource: Bool {
|
||||
if case .mediaSource = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// Whether this is a dynamic instance item.
|
||||
var isInstance: Bool {
|
||||
if case .instance = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Factory Methods
|
||||
|
||||
extension SidebarItem {
|
||||
/// Creates a SidebarItem from a Subscription.
|
||||
static func from(subscription: Subscription) -> SidebarItem {
|
||||
.channel(
|
||||
channelID: subscription.channelID,
|
||||
name: subscription.name,
|
||||
source: subscription.contentSource
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a SidebarItem from a LocalPlaylist.
|
||||
static func from(playlist: LocalPlaylist) -> SidebarItem {
|
||||
.playlist(id: playlist.id, title: playlist.title)
|
||||
}
|
||||
|
||||
/// Creates a SidebarItem from a MediaSource.
|
||||
static func from(mediaSource: MediaSource) -> SidebarItem {
|
||||
.mediaSource(id: mediaSource.id, name: mediaSource.name, type: mediaSource.type)
|
||||
}
|
||||
|
||||
/// Creates a SidebarItem from an Instance.
|
||||
static func from(instance: Instance) -> SidebarItem {
|
||||
.instance(id: instance.id, name: instance.displayName, type: instance.type)
|
||||
}
|
||||
}
|
||||
144
Yattee/Models/Navigation/SidebarMainItem.swift
Normal file
144
Yattee/Models/Navigation/SidebarMainItem.swift
Normal file
@@ -0,0 +1,144 @@
|
||||
//
|
||||
// SidebarMainItem.swift
|
||||
// Yattee
|
||||
//
|
||||
// Configurable sidebar main navigation item definitions.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents a configurable main navigation item in the sidebar.
|
||||
enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||
case search
|
||||
case home
|
||||
case subscriptions
|
||||
case bookmarks
|
||||
case history
|
||||
case downloads
|
||||
case channels
|
||||
case sources
|
||||
case settings
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Default order for sidebar main items.
|
||||
static var defaultOrder: [SidebarMainItem] {
|
||||
[.search, .home, .subscriptions, .bookmarks, .history, .channels, .sources, .downloads, .settings]
|
||||
}
|
||||
|
||||
/// Default visibility (all visible except subscriptions and channels).
|
||||
static var defaultVisibility: [SidebarMainItem: Bool] {
|
||||
[
|
||||
.search: true,
|
||||
.home: true,
|
||||
.subscriptions: false,
|
||||
.bookmarks: false,
|
||||
.history: false,
|
||||
.downloads: true,
|
||||
.channels: false,
|
||||
.sources: true,
|
||||
.settings: true
|
||||
]
|
||||
}
|
||||
|
||||
/// SF Symbol icon name.
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .search: "magnifyingglass"
|
||||
case .home: "house.fill"
|
||||
case .subscriptions: "play.square.stack.fill"
|
||||
case .bookmarks: "bookmark.fill"
|
||||
case .history: "clock"
|
||||
case .downloads: "arrow.down.circle"
|
||||
case .channels: "person.2"
|
||||
case .sources: "server.rack"
|
||||
case .settings: "gear"
|
||||
}
|
||||
}
|
||||
|
||||
/// Localized display title.
|
||||
var localizedTitle: String {
|
||||
switch self {
|
||||
case .search: String(localized: "sidebar.mainItem.search")
|
||||
case .home: String(localized: "sidebar.mainItem.home")
|
||||
case .subscriptions: String(localized: "sidebar.mainItem.subscriptions")
|
||||
case .bookmarks: String(localized: "sidebar.mainItem.bookmarks")
|
||||
case .history: String(localized: "sidebar.mainItem.history")
|
||||
case .downloads: String(localized: "sidebar.mainItem.downloads")
|
||||
case .channels: String(localized: "sidebar.mainItem.channels")
|
||||
case .sources: String(localized: "sidebar.mainItem.sources")
|
||||
case .settings: String(localized: "sidebar.mainItem.settings")
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this item is required and cannot be hidden.
|
||||
var isRequired: Bool {
|
||||
switch self {
|
||||
case .search, .home:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this item is available on the current platform.
|
||||
var isAvailableOnCurrentPlatform: Bool {
|
||||
switch self {
|
||||
case .downloads:
|
||||
#if os(tvOS)
|
||||
return false
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab Value Mappings
|
||||
|
||||
/// Tab value for CompactTabView (String-based).
|
||||
/// Fixed tabs use "home" and "search", configurable tabs use TabBarItem.rawValue.
|
||||
var compactTabValue: String {
|
||||
switch self {
|
||||
case .search: return "search"
|
||||
case .home: return "home"
|
||||
case .subscriptions: return TabBarItem.subscriptions.rawValue
|
||||
case .bookmarks: return TabBarItem.bookmarks.rawValue
|
||||
case .history: return TabBarItem.history.rawValue
|
||||
case .downloads: return TabBarItem.downloads.rawValue
|
||||
case .channels: return TabBarItem.channels.rawValue
|
||||
case .sources: return TabBarItem.sources.rawValue
|
||||
case .settings: return TabBarItem.settings.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
/// SidebarItem value for UnifiedTabView.
|
||||
var sidebarItem: SidebarItem {
|
||||
switch self {
|
||||
case .search: return .search
|
||||
case .home: return .home
|
||||
case .subscriptions: return .subscriptionsFeed
|
||||
case .bookmarks: return .bookmarks
|
||||
case .history: return .history
|
||||
case .downloads: return .downloads
|
||||
case .channels: return .manageChannels
|
||||
case .sources: return .sources
|
||||
case .settings: return .settings
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize from TabBarItem (for reverse mapping).
|
||||
init?(tabBarItem: TabBarItem) {
|
||||
switch tabBarItem {
|
||||
case .subscriptions: self = .subscriptions
|
||||
case .channels: self = .channels
|
||||
case .bookmarks: self = .bookmarks
|
||||
case .playlists: return nil // No direct mapping - playlists isn't a SidebarMainItem
|
||||
case .history: self = .history
|
||||
case .downloads: self = .downloads
|
||||
case .sources: self = .sources
|
||||
case .settings: self = .settings
|
||||
}
|
||||
}
|
||||
}
|
||||
60
Yattee/Models/Navigation/TabBarItem.swift
Normal file
60
Yattee/Models/Navigation/TabBarItem.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// TabBarItem.swift
|
||||
// Yattee
|
||||
//
|
||||
// Configurable tab bar item definitions for compact size class navigation.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents a configurable tab bar item for compact width (iPhone, iPad small window).
|
||||
enum TabBarItem: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||
case subscriptions
|
||||
case channels
|
||||
case bookmarks
|
||||
case playlists
|
||||
case history
|
||||
case downloads
|
||||
case sources
|
||||
case settings
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Default order for tab bar items.
|
||||
static var defaultOrder: [TabBarItem] {
|
||||
[.subscriptions, .channels, .bookmarks, .playlists, .history, .sources, .downloads, .settings]
|
||||
}
|
||||
|
||||
/// Default visibility (only subscriptions visible by default).
|
||||
static var defaultVisibility: [TabBarItem: Bool] {
|
||||
[.subscriptions: false, .channels: false, .bookmarks: false, .playlists: false, .history: false, .downloads: true, .sources: true, .settings: false]
|
||||
}
|
||||
|
||||
/// SF Symbol icon name.
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .subscriptions: "play.square.stack.fill"
|
||||
case .channels: "person.crop.rectangle.stack.fill"
|
||||
case .bookmarks: "bookmark.fill"
|
||||
case .playlists: "list.bullet.rectangle"
|
||||
case .history: "clock"
|
||||
case .downloads: "arrow.down.circle"
|
||||
case .sources: "server.rack"
|
||||
case .settings: "gear"
|
||||
}
|
||||
}
|
||||
|
||||
/// Localized display title.
|
||||
var localizedTitle: String {
|
||||
switch self {
|
||||
case .subscriptions: String(localized: "tabBar.item.subscriptions")
|
||||
case .channels: String(localized: "tabBar.item.channels")
|
||||
case .bookmarks: String(localized: "tabBar.item.bookmarks")
|
||||
case .playlists: String(localized: "tabBar.item.playlists")
|
||||
case .history: String(localized: "tabBar.item.history")
|
||||
case .downloads: String(localized: "tabBar.item.downloads")
|
||||
case .sources: String(localized: "tabBar.item.sources")
|
||||
case .settings: String(localized: "tabBar.item.settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
47
Yattee/Models/PeerTubeDirectoryInstance.swift
Normal file
47
Yattee/Models/PeerTubeDirectoryInstance.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// PeerTubeDirectoryInstance.swift
|
||||
// Yattee
|
||||
//
|
||||
// Model for PeerTube instances from the public directory.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents a PeerTube instance from the public directory at instances.joinpeertube.org.
|
||||
struct PeerTubeDirectoryInstance: Identifiable, Decodable, Sendable {
|
||||
let id: Int
|
||||
let host: String
|
||||
let name: String
|
||||
let shortDescription: String?
|
||||
let version: String?
|
||||
let signupAllowed: Bool
|
||||
let languages: [String]
|
||||
let country: String?
|
||||
let totalUsers: Int
|
||||
let totalVideos: Int
|
||||
let totalLocalVideos: Int?
|
||||
let health: Int?
|
||||
let createdAt: String?
|
||||
|
||||
/// Constructs the full URL for this instance.
|
||||
var url: URL? {
|
||||
URL(string: "https://\(host)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Response wrapper for the PeerTube instances directory API.
|
||||
struct PeerTubeDirectoryResponse: Decodable, Sendable {
|
||||
let total: Int
|
||||
let data: [PeerTubeDirectoryInstance]
|
||||
}
|
||||
|
||||
/// Filters for browsing the PeerTube instance directory.
|
||||
struct PeerTubeDirectoryFilters: Equatable, Sendable {
|
||||
var searchText: String = ""
|
||||
var language: String? = nil
|
||||
var country: String? = nil
|
||||
|
||||
var isDefault: Bool {
|
||||
searchText.isEmpty && language == nil && country == nil
|
||||
}
|
||||
}
|
||||
88
Yattee/Models/Player/QueueSource.swift
Normal file
88
Yattee/Models/Player/QueueSource.swift
Normal file
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// QueueSource.swift
|
||||
// Yattee
|
||||
//
|
||||
// Tracks the origin of queued videos for continuation loading.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Tracks the origin of queued videos for continuation loading.
|
||||
enum QueueSource: Codable, Equatable, Hashable, Sendable {
|
||||
/// Videos from a channel's video list.
|
||||
case channel(channelID: String, source: ContentSource, continuation: String?)
|
||||
|
||||
/// Videos from a playlist.
|
||||
case playlist(playlistID: String, continuation: String?)
|
||||
|
||||
/// Videos from search results.
|
||||
case search(query: String, continuation: String?)
|
||||
|
||||
/// Videos from subscriptions feed.
|
||||
case subscriptions(continuation: String?)
|
||||
|
||||
/// Manually added individual videos (no continuation).
|
||||
case manual
|
||||
|
||||
/// Videos from a media browser folder (WebDAV/SMB/local folder).
|
||||
/// Folder contents are loaded upfront, so no continuation is needed.
|
||||
case mediaBrowser(sourceID: UUID, folderPath: String)
|
||||
|
||||
/// Whether this source supports loading more items.
|
||||
var supportsContinuation: Bool {
|
||||
switch self {
|
||||
case .channel(_, _, let continuation),
|
||||
.playlist(_, let continuation),
|
||||
.search(_, let continuation),
|
||||
.subscriptions(let continuation):
|
||||
return continuation != nil
|
||||
case .manual, .mediaBrowser:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// The continuation token, if available.
|
||||
var continuation: String? {
|
||||
switch self {
|
||||
case .channel(_, _, let continuation),
|
||||
.playlist(_, let continuation),
|
||||
.search(_, let continuation),
|
||||
.subscriptions(let continuation):
|
||||
return continuation
|
||||
case .manual, .mediaBrowser:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a new QueueSource with an updated continuation token.
|
||||
func withContinuation(_ newContinuation: String?) -> QueueSource {
|
||||
switch self {
|
||||
case .channel(let channelID, let source, _):
|
||||
return .channel(channelID: channelID, source: source, continuation: newContinuation)
|
||||
case .playlist(let playlistID, _):
|
||||
return .playlist(playlistID: playlistID, continuation: newContinuation)
|
||||
case .search(let query, _):
|
||||
return .search(query: query, continuation: newContinuation)
|
||||
case .subscriptions:
|
||||
return .subscriptions(continuation: newContinuation)
|
||||
case .manual:
|
||||
return .manual
|
||||
case .mediaBrowser:
|
||||
return self // No continuation for media browser
|
||||
}
|
||||
}
|
||||
|
||||
/// The content source for this queue source, used for instance selection.
|
||||
/// Returns nil for sources that don't use API-based continuation (manual, mediaBrowser).
|
||||
var contentSource: ContentSource? {
|
||||
switch self {
|
||||
case .channel(_, let source, _):
|
||||
return source
|
||||
case .playlist, .search, .subscriptions:
|
||||
// Playlists, search, and subscriptions are YouTube content
|
||||
return .global(provider: ContentSource.youtubeProvider)
|
||||
case .manual, .mediaBrowser:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
93
Yattee/Models/Player/VideoQueueContext.swift
Normal file
93
Yattee/Models/Player/VideoQueueContext.swift
Normal file
@@ -0,0 +1,93 @@
|
||||
//
|
||||
// VideoQueueContext.swift
|
||||
// Yattee
|
||||
//
|
||||
// Context information for video queue when navigating to video details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Closure type for loading more videos via continuation
|
||||
typealias LoadMoreVideosCallback = @Sendable () async throws -> ([Video], String?)
|
||||
|
||||
/// Context information for playing a video with queue support.
|
||||
/// Used when navigating from list views (subscriptions, search, etc.) to video info pages.
|
||||
struct VideoQueueContext {
|
||||
/// The video being viewed
|
||||
let video: Video
|
||||
|
||||
/// Queue source for continuation loading
|
||||
let queueSource: QueueSource?
|
||||
|
||||
/// Display label for the queue source (e.g., "Subscriptions", "Search Results")
|
||||
let sourceLabel: String?
|
||||
|
||||
/// All videos in the current list
|
||||
let videoList: [Video]?
|
||||
|
||||
/// Index of the current video in the list
|
||||
let videoIndex: Int?
|
||||
|
||||
/// Optional start time in seconds
|
||||
let startTime: TimeInterval?
|
||||
|
||||
/// Callback to load more videos when reaching the end of the current list
|
||||
/// Returns new videos and updated continuation token
|
||||
let loadMoreVideos: LoadMoreVideosCallback?
|
||||
|
||||
/// Whether this context has valid queue information
|
||||
var hasQueueInfo: Bool {
|
||||
videoList != nil && videoIndex != nil
|
||||
}
|
||||
|
||||
/// Number of videos that will be queued after the current one
|
||||
var remainingVideosCount: Int {
|
||||
guard let list = videoList, let index = videoIndex else { return 0 }
|
||||
return max(0, list.count - index - 1)
|
||||
}
|
||||
|
||||
/// Whether more videos can be loaded via continuation
|
||||
var canLoadMore: Bool {
|
||||
let hasCallback = loadMoreVideos != nil
|
||||
let supportsContinuation = queueSource?.supportsContinuation == true
|
||||
return hasCallback && supportsContinuation
|
||||
}
|
||||
|
||||
/// Creates a minimal context with just the video (no queue)
|
||||
static func single(_ video: Video, startTime: TimeInterval? = nil) -> VideoQueueContext {
|
||||
VideoQueueContext(
|
||||
video: video,
|
||||
queueSource: nil,
|
||||
sourceLabel: nil,
|
||||
videoList: nil,
|
||||
videoIndex: nil,
|
||||
startTime: startTime,
|
||||
loadMoreVideos: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Equatable & Hashable
|
||||
// Note: Excludes loadMoreVideos callback since closures aren't equatable
|
||||
|
||||
extension VideoQueueContext: Equatable {
|
||||
static func == (lhs: VideoQueueContext, rhs: VideoQueueContext) -> Bool {
|
||||
lhs.video == rhs.video &&
|
||||
lhs.queueSource == rhs.queueSource &&
|
||||
lhs.sourceLabel == rhs.sourceLabel &&
|
||||
lhs.videoList == rhs.videoList &&
|
||||
lhs.videoIndex == rhs.videoIndex &&
|
||||
lhs.startTime == rhs.startTime
|
||||
}
|
||||
}
|
||||
|
||||
extension VideoQueueContext: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(video)
|
||||
hasher.combine(queueSource)
|
||||
hasher.combine(sourceLabel)
|
||||
hasher.combine(videoList)
|
||||
hasher.combine(videoIndex)
|
||||
hasher.combine(startTime)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
115
Yattee/Models/Playlist.swift
Normal file
115
Yattee/Models/Playlist.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// Playlist.swift
|
||||
// Yattee
|
||||
//
|
||||
// Represents a video playlist.
|
||||
//
|
||||
|
||||
@preconcurrency import Foundation
|
||||
|
||||
/// Represents a playlist from any content source.
|
||||
struct Playlist: Identifiable, Codable, Hashable, Sendable {
|
||||
/// Unique identifier for this playlist.
|
||||
let id: PlaylistID
|
||||
|
||||
/// The playlist title.
|
||||
let title: String
|
||||
|
||||
/// Playlist description.
|
||||
let description: String?
|
||||
|
||||
/// The channel/author who created the playlist.
|
||||
let author: Author?
|
||||
|
||||
/// Number of videos in the playlist.
|
||||
let videoCount: Int
|
||||
|
||||
/// Thumbnail URL (usually from first video).
|
||||
let thumbnailURL: URL?
|
||||
|
||||
/// Videos in this playlist (may be partial for large playlists).
|
||||
let videos: [Video]
|
||||
|
||||
/// Whether this is a local/Yattee playlist vs instance playlist.
|
||||
let isLocal: Bool
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// The author name as a string for display.
|
||||
var authorName: String {
|
||||
author?.name ?? ""
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
id: PlaylistID,
|
||||
title: String,
|
||||
description: String? = nil,
|
||||
author: Author? = nil,
|
||||
videoCount: Int = 0,
|
||||
thumbnailURL: URL? = nil,
|
||||
videos: [Video] = [],
|
||||
isLocal: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.author = author
|
||||
self.videoCount = videoCount
|
||||
self.thumbnailURL = thumbnailURL
|
||||
self.videos = videos
|
||||
self.isLocal = isLocal
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Playlist ID
|
||||
|
||||
/// Unique identifier for a playlist.
|
||||
struct PlaylistID: Codable, Hashable, Sendable {
|
||||
/// The content source (nil for local playlists).
|
||||
let source: ContentSource?
|
||||
|
||||
/// The playlist ID.
|
||||
let playlistID: String
|
||||
|
||||
init(source: ContentSource?, playlistID: String) {
|
||||
self.source = source
|
||||
self.playlistID = playlistID
|
||||
}
|
||||
|
||||
/// Creates a global playlist ID (e.g., YouTube).
|
||||
static func global(_ playlistID: String, provider: String = ContentSource.youtubeProvider) -> PlaylistID {
|
||||
PlaylistID(source: .global(provider: provider), playlistID: playlistID)
|
||||
}
|
||||
|
||||
/// Creates a federated playlist ID (e.g., PeerTube).
|
||||
static func federated(_ playlistID: String, provider: String = ContentSource.peertubeProvider, instance: URL) -> PlaylistID {
|
||||
PlaylistID(source: .federated(provider: provider, instance: instance), playlistID: playlistID)
|
||||
}
|
||||
|
||||
/// Creates a local Yattee playlist ID.
|
||||
static func local(_ playlistID: String) -> PlaylistID {
|
||||
PlaylistID(source: nil, playlistID: playlistID)
|
||||
}
|
||||
|
||||
var isLocal: Bool {
|
||||
source == nil
|
||||
}
|
||||
}
|
||||
|
||||
extension PlaylistID: Identifiable {
|
||||
var id: String {
|
||||
guard let source else {
|
||||
return "local:\(playlistID)"
|
||||
}
|
||||
switch source {
|
||||
case .global(let provider):
|
||||
return "global:\(provider):\(playlistID)"
|
||||
case .federated(let provider, let instance):
|
||||
return "federated:\(provider):\(instance.host ?? ""):\(playlistID)"
|
||||
case .extracted(let extractor, _):
|
||||
return "extracted:\(extractor):\(playlistID)"
|
||||
}
|
||||
}
|
||||
}
|
||||
112
Yattee/Models/Search/SearchFilters.swift
Normal file
112
Yattee/Models/Search/SearchFilters.swift
Normal file
@@ -0,0 +1,112 @@
|
||||
//
|
||||
// SearchFilters.swift
|
||||
// Yattee
|
||||
//
|
||||
// Search filter definitions for Invidious API.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Sort options for search results.
|
||||
enum SearchSortOption: String, CaseIterable, Identifiable, Codable {
|
||||
case relevance
|
||||
case rating
|
||||
case date
|
||||
case views
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .relevance: return String(localized: "search.sort.relevance")
|
||||
case .rating: return String(localized: "search.sort.rating")
|
||||
case .date: return String(localized: "search.sort.date")
|
||||
case .views: return String(localized: "search.sort.views")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload date filter for search results.
|
||||
enum SearchDateFilter: String, CaseIterable, Identifiable, Codable {
|
||||
case any = ""
|
||||
case hour
|
||||
case today
|
||||
case week
|
||||
case month
|
||||
case year
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .any: return String(localized: "search.date.any")
|
||||
case .hour: return String(localized: "search.date.hour")
|
||||
case .today: return String(localized: "search.date.today")
|
||||
case .week: return String(localized: "search.date.week")
|
||||
case .month: return String(localized: "search.date.month")
|
||||
case .year: return String(localized: "search.date.year")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Duration filter for search results.
|
||||
enum SearchDurationFilter: String, CaseIterable, Identifiable, Codable {
|
||||
case any = ""
|
||||
case short
|
||||
case medium
|
||||
case long
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .any: return String(localized: "search.duration.any")
|
||||
case .short: return String(localized: "search.duration.short")
|
||||
case .medium: return String(localized: "search.duration.medium")
|
||||
case .long: return String(localized: "search.duration.long")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Content type filter for search results.
|
||||
enum SearchContentType: String, CaseIterable, Identifiable, Codable {
|
||||
case all
|
||||
case video
|
||||
case playlist
|
||||
case channel
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all: return String(localized: "search.type.all")
|
||||
case .video: return String(localized: "search.type.video")
|
||||
case .playlist: return String(localized: "search.type.playlist")
|
||||
case .channel: return String(localized: "search.type.channel")
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .all: return "magnifyingglass"
|
||||
case .video: return "play.rectangle"
|
||||
case .playlist: return "list.bullet.rectangle"
|
||||
case .channel: return "person.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Search filters for Invidious API.
|
||||
struct SearchFilters: Codable, Equatable {
|
||||
var sort: SearchSortOption = .relevance
|
||||
var date: SearchDateFilter = .any
|
||||
var duration: SearchDurationFilter = .any
|
||||
var type: SearchContentType = .video
|
||||
|
||||
static let defaults = SearchFilters()
|
||||
|
||||
/// Check if non-type filters are default (type is controlled separately by chips)
|
||||
var isDefault: Bool {
|
||||
sort == .relevance && date == .any && duration == .any
|
||||
}
|
||||
}
|
||||
54
Yattee/Models/Sources/UnifiedSource.swift
Normal file
54
Yattee/Models/Sources/UnifiedSource.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// UnifiedSource.swift
|
||||
// Yattee
|
||||
//
|
||||
// Unified wrapper for Instance and MediaSource types for display in the Sources settings.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents any source type for unified display in the Sources list.
|
||||
enum UnifiedSource: Identifiable, Hashable, Sendable {
|
||||
case remoteServer(Instance)
|
||||
case fileSource(MediaSource)
|
||||
|
||||
// MARK: - Identifiable
|
||||
|
||||
var id: UUID {
|
||||
switch self {
|
||||
case .remoteServer(let instance):
|
||||
return instance.id
|
||||
case .fileSource(let source):
|
||||
return source.id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Common Properties
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .remoteServer(let instance):
|
||||
return instance.displayName
|
||||
case .fileSource(let source):
|
||||
return source.name
|
||||
}
|
||||
}
|
||||
|
||||
var isEnabled: Bool {
|
||||
switch self {
|
||||
case .remoteServer(let instance):
|
||||
return instance.isEnabled
|
||||
case .fileSource(let source):
|
||||
return source.isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
var urlDisplayString: String {
|
||||
switch self {
|
||||
case .remoteServer(let instance):
|
||||
return instance.url.host ?? instance.url.absoluteString
|
||||
case .fileSource(let source):
|
||||
return source.urlDisplayString
|
||||
}
|
||||
}
|
||||
}
|
||||
17
Yattee/Models/State/CommentsLoadState.swift
Normal file
17
Yattee/Models/State/CommentsLoadState.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// CommentsLoadState.swift
|
||||
// Yattee
|
||||
//
|
||||
// Comments loading state definitions.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum CommentsLoadState: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case loaded
|
||||
case loadingMore
|
||||
case disabled
|
||||
case error
|
||||
}
|
||||
15
Yattee/Models/State/VideoDetailsLoadState.swift
Normal file
15
Yattee/Models/State/VideoDetailsLoadState.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// VideoDetailsLoadState.swift
|
||||
// Yattee
|
||||
//
|
||||
// Video details loading state definitions.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum VideoDetailsLoadState: Equatable {
|
||||
case idle // No video loaded
|
||||
case loading // Fetching full details from API
|
||||
case loaded // Full details available
|
||||
case error // Failed to load details
|
||||
}
|
||||
181
Yattee/Models/Storyboard.swift
Normal file
181
Yattee/Models/Storyboard.swift
Normal file
@@ -0,0 +1,181 @@
|
||||
//
|
||||
// Storyboard.swift
|
||||
// Yattee
|
||||
//
|
||||
// Represents storyboard sprite sheet data for video preview thumbnails.
|
||||
//
|
||||
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
/// Represents a storyboard (sprite sheet) for video preview thumbnails.
|
||||
struct Storyboard: Hashable, Sendable, Codable {
|
||||
/// Proxied URL path (e.g., /api/v1/storyboards/VIDEO_ID?width=160)
|
||||
/// This is the preferred URL as it goes through the instance proxy
|
||||
let proxyUrl: String?
|
||||
|
||||
/// URL template for sprite sheets (contains M$M placeholder for sheet index)
|
||||
/// This is the direct YouTube URL which may be blocked
|
||||
let templateUrl: String
|
||||
|
||||
/// Base URL of the instance (used to make proxyUrl absolute)
|
||||
let instanceBaseURL: URL?
|
||||
|
||||
/// Width of each thumbnail in the grid
|
||||
let width: Int
|
||||
|
||||
/// Height of each thumbnail in the grid
|
||||
let height: Int
|
||||
|
||||
/// Total number of thumbnails across all sheets
|
||||
let count: Int
|
||||
|
||||
/// Milliseconds between each thumbnail
|
||||
let interval: Int
|
||||
|
||||
/// Number of columns in each sprite sheet grid
|
||||
let storyboardWidth: Int
|
||||
|
||||
/// Number of rows in each sprite sheet grid
|
||||
let storyboardHeight: Int
|
||||
|
||||
/// Total number of sprite sheet images
|
||||
let storyboardCount: Int
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// Number of thumbnails per sprite sheet
|
||||
var thumbnailsPerSheet: Int {
|
||||
storyboardWidth * storyboardHeight
|
||||
}
|
||||
|
||||
/// Interval between thumbnails in seconds
|
||||
var intervalSeconds: TimeInterval {
|
||||
TimeInterval(interval) / 1000.0
|
||||
}
|
||||
|
||||
// MARK: - Methods
|
||||
|
||||
/// Returns the URL for a specific sprite sheet index.
|
||||
/// Prefers proxied URL (goes through instance) over direct YouTube URL.
|
||||
/// - Parameter index: The sheet index (0-based)
|
||||
/// - Returns: URL for the sprite sheet, or nil if invalid
|
||||
func sheetURL(for index: Int) -> URL? {
|
||||
guard index >= 0, index < storyboardCount else { return nil }
|
||||
|
||||
// Prefer proxied URL if available (goes through instance, not blocked)
|
||||
if let proxyUrl {
|
||||
// The proxy URL returns the full sprite sheet, append index parameter
|
||||
var urlString = proxyUrl
|
||||
if urlString.contains("?") {
|
||||
urlString += "&storyboard=\(index)"
|
||||
} else {
|
||||
urlString += "?storyboard=\(index)"
|
||||
}
|
||||
|
||||
// Check if proxyUrl is already an absolute URL
|
||||
if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") {
|
||||
return URL(string: urlString)
|
||||
}
|
||||
|
||||
// Construct absolute URL from relative path (relative URLs don't work with URLSession)
|
||||
if let baseURL = instanceBaseURL {
|
||||
var baseString = baseURL.absoluteString
|
||||
if baseString.hasSuffix("/") && urlString.hasPrefix("/") {
|
||||
baseString = String(baseString.dropLast())
|
||||
}
|
||||
let absoluteURLString = baseString + urlString
|
||||
return URL(string: absoluteURLString)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to templateUrl (direct YouTube URL, may be blocked)
|
||||
return directSheetURL(for: index)
|
||||
}
|
||||
|
||||
/// Returns the direct URL for a specific sprite sheet index.
|
||||
/// Uses templateUrl directly, bypassing the proxy. Use for downloads.
|
||||
/// - Parameter index: The sheet index (0-based)
|
||||
/// - Returns: Direct URL for the sprite sheet, or nil if invalid
|
||||
func directSheetURL(for index: Int) -> URL? {
|
||||
guard index >= 0, index < storyboardCount else { return nil }
|
||||
let urlString = templateUrl.replacingOccurrences(of: "M$M", with: "\(index)")
|
||||
return URL(string: urlString)
|
||||
}
|
||||
|
||||
/// Calculates the position of a thumbnail for a given timestamp.
|
||||
/// - Parameter time: The time in seconds
|
||||
/// - Returns: Tuple of (sheetIndex, row, column), or nil if time is out of range
|
||||
func position(for time: TimeInterval) -> (sheetIndex: Int, row: Int, column: Int)? {
|
||||
guard time >= 0, intervalSeconds > 0 else { return nil }
|
||||
|
||||
let thumbnailIndex = Int(time / intervalSeconds)
|
||||
guard thumbnailIndex < count else { return nil }
|
||||
|
||||
let sheetIndex = thumbnailIndex / thumbnailsPerSheet
|
||||
let positionInSheet = thumbnailIndex % thumbnailsPerSheet
|
||||
let row = positionInSheet / storyboardWidth
|
||||
let column = positionInSheet % storyboardWidth
|
||||
|
||||
return (sheetIndex, row, column)
|
||||
}
|
||||
|
||||
/// Calculates the crop rect for extracting a thumbnail at the given time.
|
||||
/// - Parameter time: The time in seconds
|
||||
/// - Returns: CGRect for cropping, or nil if time is out of range
|
||||
func cropRect(for time: TimeInterval) -> CGRect? {
|
||||
guard let position = position(for: time) else { return nil }
|
||||
|
||||
return CGRect(
|
||||
x: CGFloat(position.column * width),
|
||||
y: CGFloat(position.row * height),
|
||||
width: CGFloat(width),
|
||||
height: CGFloat(height)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local Storyboard Support
|
||||
|
||||
extension Storyboard {
|
||||
/// Creates a Storyboard configured for local file access.
|
||||
/// - Parameters:
|
||||
/// - original: The original storyboard with metadata
|
||||
/// - localDirectory: The directory URL containing downloaded sprite sheets
|
||||
/// - Returns: A new Storyboard with file:// URLs for local access
|
||||
static func localStoryboard(from original: Storyboard, localDirectory: URL) -> Storyboard {
|
||||
// Create template URL pointing to local files: sb_M$M.jpg
|
||||
let templatePath = localDirectory.appendingPathComponent("sb_M$M.jpg").absoluteString
|
||||
|
||||
return Storyboard(
|
||||
proxyUrl: nil, // No proxy for local files
|
||||
templateUrl: templatePath,
|
||||
instanceBaseURL: nil,
|
||||
width: original.width,
|
||||
height: original.height,
|
||||
count: original.count,
|
||||
interval: original.interval,
|
||||
storyboardWidth: original.storyboardWidth,
|
||||
storyboardHeight: original.storyboardHeight,
|
||||
storyboardCount: original.storyboardCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Storyboard Selection
|
||||
|
||||
extension Array where Element == Storyboard {
|
||||
/// Selects the preferred storyboard based on desired width.
|
||||
/// Prefers the largest storyboard that doesn't exceed maxWidth.
|
||||
/// - Parameter maxWidth: Maximum preferred width (default 160)
|
||||
/// - Returns: Best matching storyboard, or nil if array is empty
|
||||
func preferred(maxWidth: Int = 160) -> Storyboard? {
|
||||
let suitable = filter { $0.width <= maxWidth }
|
||||
return suitable.max(by: { $0.width < $1.width }) ?? first
|
||||
}
|
||||
|
||||
/// Returns the highest quality storyboard (largest width).
|
||||
func highest() -> Storyboard? {
|
||||
self.max(by: { $0.width < $1.width })
|
||||
}
|
||||
}
|
||||
270
Yattee/Models/Stream.swift
Normal file
270
Yattee/Models/Stream.swift
Normal file
@@ -0,0 +1,270 @@
|
||||
//
|
||||
// Stream.swift
|
||||
// Yattee
|
||||
//
|
||||
// Represents a video/audio stream for playback.
|
||||
//
|
||||
|
||||
@preconcurrency import Foundation
|
||||
|
||||
/// Represents a playable video or audio stream.
|
||||
struct Stream: Identifiable, Codable, Hashable, Sendable {
|
||||
/// Unique identifier combining resolution, fps and format.
|
||||
var id: String {
|
||||
let fpsString = fps.map { "\($0)" } ?? ""
|
||||
return "\(resolution?.description ?? "audio")\(fpsString)-\(format)"
|
||||
}
|
||||
|
||||
/// The stream URL.
|
||||
let url: URL
|
||||
|
||||
/// Stream resolution (nil for audio-only).
|
||||
let resolution: StreamResolution?
|
||||
|
||||
/// Format/codec (mp4, webm, etc.).
|
||||
let format: String
|
||||
|
||||
/// Video codec if applicable.
|
||||
let videoCodec: String?
|
||||
|
||||
/// Audio codec.
|
||||
let audioCodec: String?
|
||||
|
||||
/// Bitrate in bits per second.
|
||||
let bitrate: Int?
|
||||
|
||||
/// File size in bytes if known.
|
||||
let fileSize: Int64?
|
||||
|
||||
/// Whether this is an audio-only stream.
|
||||
let isAudioOnly: Bool
|
||||
|
||||
/// Whether this is a video-only stream (no audio track).
|
||||
/// Note: Some sites (e.g., BitChute) don't provide resolution info.
|
||||
var isVideoOnly: Bool {
|
||||
// HLS/DASH are adaptive formats - codec info is in manifest, not metadata
|
||||
// They should never be classified as video-only based on missing codec info
|
||||
let isAdaptive = format.lowercased().contains("hls") ||
|
||||
format.lowercased().contains("dash") ||
|
||||
format.lowercased().contains("m3u8") ||
|
||||
format.lowercased().contains("mpd")
|
||||
guard !isAdaptive else { return false }
|
||||
return !isAudioOnly && audioCodec == nil
|
||||
}
|
||||
|
||||
/// Whether this stream has both video and audio (muxed).
|
||||
/// Note: Some sites (e.g., BitChute) don't provide resolution info.
|
||||
var isMuxed: Bool {
|
||||
!isAudioOnly && audioCodec != nil
|
||||
}
|
||||
|
||||
/// Whether this is a live stream (HLS/DASH).
|
||||
let isLive: Bool
|
||||
|
||||
/// MIME type.
|
||||
let mimeType: String?
|
||||
|
||||
/// Audio language code (e.g., "en", "es", "ja").
|
||||
let audioLanguage: String?
|
||||
|
||||
/// Audio track name/label.
|
||||
let audioTrackName: String?
|
||||
|
||||
/// Whether this is the original audio track (not dubbed).
|
||||
let isOriginalAudio: Bool
|
||||
|
||||
/// Custom HTTP headers required for streaming (cookies, referer, etc.).
|
||||
let httpHeaders: [String: String]?
|
||||
|
||||
/// Frame rate (fps) if known.
|
||||
let fps: Int?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
resolution: StreamResolution? = nil,
|
||||
format: String,
|
||||
videoCodec: String? = nil,
|
||||
audioCodec: String? = nil,
|
||||
bitrate: Int? = nil,
|
||||
fileSize: Int64? = nil,
|
||||
isAudioOnly: Bool = false,
|
||||
isLive: Bool = false,
|
||||
mimeType: String? = nil,
|
||||
audioLanguage: String? = nil,
|
||||
audioTrackName: String? = nil,
|
||||
isOriginalAudio: Bool = false,
|
||||
httpHeaders: [String: String]? = nil,
|
||||
fps: Int? = nil
|
||||
) {
|
||||
self.url = url
|
||||
self.resolution = resolution
|
||||
self.format = format
|
||||
self.videoCodec = videoCodec
|
||||
self.audioCodec = audioCodec
|
||||
self.bitrate = bitrate
|
||||
self.fileSize = fileSize
|
||||
self.isAudioOnly = isAudioOnly
|
||||
self.isLive = isLive
|
||||
self.mimeType = mimeType
|
||||
self.audioLanguage = audioLanguage
|
||||
self.audioTrackName = audioTrackName
|
||||
self.isOriginalAudio = isOriginalAudio
|
||||
self.httpHeaders = httpHeaders
|
||||
self.fps = fps
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var qualityLabel: String {
|
||||
if isAudioOnly {
|
||||
return "Audio"
|
||||
}
|
||||
guard let resolution else { return "Unknown" }
|
||||
if let fps {
|
||||
return "\(resolution.height)p · \(fps)fps"
|
||||
}
|
||||
return resolution.description
|
||||
}
|
||||
|
||||
var formattedFileSize: String? {
|
||||
guard let fileSize else { return nil }
|
||||
return ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file)
|
||||
}
|
||||
|
||||
/// Whether this stream is likely playable on Apple platforms.
|
||||
var isNativelyPlayable: Bool {
|
||||
// Apple platforms prefer H.264/H.265 in MP4/MOV containers
|
||||
let supportedFormats = ["mp4", "mov", "m4v", "m4a"]
|
||||
let supportedVideoCodecs = ["avc1", "hvc1", "hev1", "h264", "hevc"]
|
||||
|
||||
if isAudioOnly {
|
||||
return true // Most audio codecs are supported
|
||||
}
|
||||
|
||||
let formatSupported = supportedFormats.contains { format.lowercased().contains($0) }
|
||||
let codecSupported = videoCodec.map { codec in
|
||||
supportedVideoCodecs.contains { codec.lowercased().contains($0) }
|
||||
} ?? true
|
||||
|
||||
return formatSupported && codecSupported
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stream Resolution
|
||||
|
||||
struct StreamResolution: Codable, Hashable, Sendable, Comparable, CustomStringConvertible {
|
||||
let width: Int
|
||||
let height: Int
|
||||
|
||||
var description: String {
|
||||
"\(height)p"
|
||||
}
|
||||
|
||||
// Common resolutions
|
||||
static let p360 = StreamResolution(width: 640, height: 360)
|
||||
static let p480 = StreamResolution(width: 854, height: 480)
|
||||
static let p720 = StreamResolution(width: 1280, height: 720)
|
||||
static let p1080 = StreamResolution(width: 1920, height: 1080)
|
||||
static let p1440 = StreamResolution(width: 2560, height: 1440)
|
||||
static let p2160 = StreamResolution(width: 3840, height: 2160)
|
||||
|
||||
static func < (lhs: StreamResolution, rhs: StreamResolution) -> Bool {
|
||||
lhs.height < rhs.height
|
||||
}
|
||||
|
||||
init(width: Int, height: Int) {
|
||||
self.width = width
|
||||
self.height = height
|
||||
}
|
||||
|
||||
init?(heightLabel: String) {
|
||||
let cleaned = heightLabel.replacingOccurrences(of: "p", with: "")
|
||||
guard let height = Int(cleaned) else { return nil }
|
||||
|
||||
// Estimate width from height assuming 16:9
|
||||
let width = (height * 16) / 9
|
||||
self.init(width: width, height: height)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview Data
|
||||
|
||||
extension Stream {
|
||||
/// A sample 1080p muxed stream for SwiftUI previews.
|
||||
static var preview: Stream {
|
||||
Stream(
|
||||
url: URL(string: "https://example.com/video.mp4")!,
|
||||
resolution: .p1080,
|
||||
format: "mp4",
|
||||
videoCodec: "avc1",
|
||||
audioCodec: "mp4a",
|
||||
bitrate: 5_000_000,
|
||||
fileSize: 150_000_000,
|
||||
isAudioOnly: false,
|
||||
isLive: false,
|
||||
mimeType: "video/mp4",
|
||||
fps: 30
|
||||
)
|
||||
}
|
||||
|
||||
/// A sample video-only stream (VP9) for SwiftUI previews.
|
||||
static var videoOnlyPreview: Stream {
|
||||
Stream(
|
||||
url: URL(string: "https://example.com/video-only.webm")!,
|
||||
resolution: .p1080,
|
||||
format: "webm",
|
||||
videoCodec: "vp9",
|
||||
audioCodec: nil,
|
||||
bitrate: 3_000_000,
|
||||
fileSize: 100_000_000,
|
||||
isAudioOnly: false,
|
||||
isLive: false,
|
||||
mimeType: "video/webm",
|
||||
fps: 60
|
||||
)
|
||||
}
|
||||
|
||||
/// A sample audio-only stream for SwiftUI previews.
|
||||
static var audioPreview: Stream {
|
||||
Stream(
|
||||
url: URL(string: "https://example.com/audio.m4a")!,
|
||||
resolution: nil,
|
||||
format: "m4a",
|
||||
videoCodec: nil,
|
||||
audioCodec: "opus",
|
||||
bitrate: 128_000,
|
||||
fileSize: 5_000_000,
|
||||
isAudioOnly: true,
|
||||
isLive: false,
|
||||
mimeType: "audio/mp4",
|
||||
audioLanguage: "en",
|
||||
audioTrackName: "English",
|
||||
isOriginalAudio: true
|
||||
)
|
||||
}
|
||||
|
||||
/// A sample HLS adaptive stream for SwiftUI previews.
|
||||
static var hlsPreview: Stream {
|
||||
Stream(
|
||||
url: URL(string: "https://example.com/master.m3u8")!,
|
||||
resolution: .p1080,
|
||||
format: "hls",
|
||||
isLive: false,
|
||||
mimeType: "application/vnd.apple.mpegurl",
|
||||
fps: 60
|
||||
)
|
||||
}
|
||||
|
||||
/// A sample HLS stream without quality info for SwiftUI previews.
|
||||
static var hlsNoQualityPreview: Stream {
|
||||
Stream(
|
||||
url: URL(string: "https://example.com/master2.m3u8")!,
|
||||
resolution: nil,
|
||||
format: "hls",
|
||||
isLive: false,
|
||||
mimeType: "application/vnd.apple.mpegurl"
|
||||
)
|
||||
}
|
||||
}
|
||||
80
Yattee/Models/SubscriptionAccountType.swift
Normal file
80
Yattee/Models/SubscriptionAccountType.swift
Normal file
@@ -0,0 +1,80 @@
|
||||
//
|
||||
// SubscriptionAccountType.swift
|
||||
// Yattee
|
||||
//
|
||||
// Subscription account type and configuration for managing channel subscriptions.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The type of account used for managing subscriptions.
|
||||
enum SubscriptionAccountType: String, Codable, Sendable, Equatable, CaseIterable {
|
||||
/// Local subscriptions synced via iCloud (requires Yattee Server for feed).
|
||||
case local
|
||||
|
||||
/// Subscriptions managed by an Invidious instance account.
|
||||
case invidious
|
||||
|
||||
/// Subscriptions managed by a Piped instance account.
|
||||
case piped
|
||||
|
||||
/// Display name for the account type.
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .local:
|
||||
return String(localized: "subscriptions.account.local")
|
||||
case .invidious:
|
||||
return String(localized: "subscriptions.account.invidious")
|
||||
case .piped:
|
||||
return String(localized: "subscriptions.account.piped")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a configured subscription account.
|
||||
/// Combines the account type with an optional instance identifier.
|
||||
struct SubscriptionAccount: Codable, Sendable, Equatable, Hashable {
|
||||
/// The type of subscription account.
|
||||
let type: SubscriptionAccountType
|
||||
|
||||
/// The instance UUID for instance-based accounts (Invidious/Piped).
|
||||
/// Nil for local accounts.
|
||||
let instanceID: UUID?
|
||||
|
||||
/// Local subscription account (iCloud-synced, requires Yattee Server).
|
||||
static let local = SubscriptionAccount(type: .local, instanceID: nil)
|
||||
|
||||
/// Creates an Invidious subscription account for the given instance.
|
||||
/// - Parameter instanceID: The UUID of the Invidious instance.
|
||||
/// - Returns: A subscription account configured for Invidious.
|
||||
static func invidious(_ instanceID: UUID) -> SubscriptionAccount {
|
||||
SubscriptionAccount(type: .invidious, instanceID: instanceID)
|
||||
}
|
||||
|
||||
/// Creates a Piped subscription account for the given instance.
|
||||
/// - Parameter instanceID: The UUID of the Piped instance.
|
||||
/// - Returns: A subscription account configured for Piped.
|
||||
static func piped(_ instanceID: UUID) -> SubscriptionAccount {
|
||||
SubscriptionAccount(type: .piped, instanceID: instanceID)
|
||||
}
|
||||
|
||||
/// Whether this account requires an authenticated instance.
|
||||
var requiresAuthentication: Bool {
|
||||
switch type {
|
||||
case .local:
|
||||
return false
|
||||
case .invidious, .piped:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this account requires Yattee Server for feed fetching.
|
||||
var requiresYatteeServer: Bool {
|
||||
switch type {
|
||||
case .local:
|
||||
return true
|
||||
case .invidious, .piped:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
150
Yattee/Models/SubtitleSettings.swift
Normal file
150
Yattee/Models/SubtitleSettings.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// SubtitleSettings.swift
|
||||
// Yattee
|
||||
//
|
||||
// Settings for subtitle appearance in MPV player.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Subtitle Font
|
||||
|
||||
/// Font options for subtitles.
|
||||
enum SubtitleFont: String, Codable, Hashable, Sendable, CaseIterable {
|
||||
case sansSerif
|
||||
case serif
|
||||
case monospaced
|
||||
|
||||
/// Display name for the UI.
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .sansSerif:
|
||||
return String(localized: "settings.subtitles.font.sansSerif")
|
||||
case .serif:
|
||||
return String(localized: "settings.subtitles.font.serif")
|
||||
case .monospaced:
|
||||
return String(localized: "settings.subtitles.font.monospaced")
|
||||
}
|
||||
}
|
||||
|
||||
/// MPV font name for this font option.
|
||||
var mpvFontName: String {
|
||||
switch self {
|
||||
case .sansSerif:
|
||||
return "sans-serif"
|
||||
case .serif:
|
||||
return "serif"
|
||||
case .monospaced:
|
||||
return "monospace"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Subtitle Settings
|
||||
|
||||
/// Settings for subtitle appearance in MPV.
|
||||
struct SubtitleSettings: Codable, Hashable, Sendable {
|
||||
/// Font for subtitles.
|
||||
var font: SubtitleFont
|
||||
|
||||
/// Font size for subtitles (in points, 20-100).
|
||||
var fontSize: Int
|
||||
|
||||
/// Text color for subtitles.
|
||||
var textColor: CodableColor
|
||||
|
||||
/// Border (outline) color for subtitles.
|
||||
var borderColor: CodableColor
|
||||
|
||||
/// Border size (0-5).
|
||||
var borderSize: Double
|
||||
|
||||
/// Background color for subtitles (when showBackground is enabled).
|
||||
var backgroundColor: CodableColor
|
||||
|
||||
/// Whether to show a background box behind subtitles.
|
||||
var showBackground: Bool
|
||||
|
||||
/// Whether text is bold.
|
||||
var isBold: Bool
|
||||
|
||||
/// Whether text is italic.
|
||||
var isItalic: Bool
|
||||
|
||||
/// Bottom margin as percentage (0-50).
|
||||
var bottomMargin: Int
|
||||
|
||||
/// Creates subtitle settings with default values.
|
||||
init(
|
||||
font: SubtitleFont = .sansSerif,
|
||||
fontSize: Int = 60,
|
||||
textColor: CodableColor = CodableColor(red: 1, green: 1, blue: 1),
|
||||
borderColor: CodableColor = CodableColor(red: 0, green: 0, blue: 0),
|
||||
borderSize: Double = 2.5,
|
||||
backgroundColor: CodableColor = CodableColor(red: 0, green: 0, blue: 0, opacity: 0.75),
|
||||
showBackground: Bool = false,
|
||||
isBold: Bool = false,
|
||||
isItalic: Bool = false,
|
||||
bottomMargin: Int = 30
|
||||
) {
|
||||
self.font = font
|
||||
self.fontSize = fontSize
|
||||
self.textColor = textColor
|
||||
self.borderColor = borderColor
|
||||
self.borderSize = borderSize
|
||||
self.backgroundColor = backgroundColor
|
||||
self.showBackground = showBackground
|
||||
self.isBold = isBold
|
||||
self.isItalic = isItalic
|
||||
self.bottomMargin = bottomMargin
|
||||
}
|
||||
|
||||
/// Default subtitle settings.
|
||||
static let `default` = SubtitleSettings()
|
||||
|
||||
/// Converts settings to MPV options dictionary.
|
||||
/// - Returns: Dictionary of MPV option names to values.
|
||||
func mpvOptions() -> [String: String] {
|
||||
var options: [String: String] = [:]
|
||||
|
||||
// Font
|
||||
options["sub-font"] = font.mpvFontName
|
||||
options["sub-font-size"] = String(fontSize)
|
||||
|
||||
// Text color (BBGGRR format for MPV with alpha as separate component)
|
||||
options["sub-color"] = mpvColorString(textColor)
|
||||
|
||||
// Border
|
||||
options["sub-border-color"] = mpvColorString(borderColor)
|
||||
options["sub-border-size"] = String(format: "%.1f", borderSize)
|
||||
|
||||
// Background
|
||||
if showBackground {
|
||||
options["sub-back-color"] = mpvColorString(backgroundColor)
|
||||
options["sub-border-style"] = "background-box"
|
||||
} else {
|
||||
options["sub-back-color"] = "#00000000" // Transparent
|
||||
options["sub-border-style"] = "outline-and-shadow" // Default style
|
||||
}
|
||||
|
||||
// Style
|
||||
options["sub-bold"] = isBold ? "yes" : "no"
|
||||
options["sub-italic"] = isItalic ? "yes" : "no"
|
||||
|
||||
// Position
|
||||
options["sub-margin-y"] = String(bottomMargin)
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
/// Converts CodableColor to MPV color string format (#AARRGGBB).
|
||||
private func mpvColorString(_ color: CodableColor) -> String {
|
||||
let r = Int(color.red * 255)
|
||||
let g = Int(color.green * 255)
|
||||
let b = Int(color.blue * 255)
|
||||
let a = Int(color.opacity * 255)
|
||||
return String(format: "#%02X%02X%02X%02X", a, r, g, b)
|
||||
}
|
||||
}
|
||||
129
Yattee/Models/Toast.swift
Normal file
129
Yattee/Models/Toast.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
//
|
||||
// Toast.swift
|
||||
// Yattee
|
||||
//
|
||||
// Data models for toast notification system.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Toast Scope
|
||||
|
||||
/// Defines where a toast should be displayed.
|
||||
enum ToastScope: Hashable, Sendable {
|
||||
case main // Main browsing window (subscriptions, search, etc.)
|
||||
case player // Player window/sheet
|
||||
}
|
||||
|
||||
// MARK: - Toast Category
|
||||
|
||||
/// Categories for toast notifications.
|
||||
/// Same category toasts replace existing ones (no stacking within category).
|
||||
enum ToastCategory: Hashable, Sendable {
|
||||
case loading // "Loading video..."
|
||||
case retry // "Retrying stream..."
|
||||
case remoteControl // "Receiving remote commands..."
|
||||
case sponsorBlock // "Skipping sponsor"
|
||||
case playerStatus // Generic player status messages
|
||||
case download // Download-related messages
|
||||
case error // Error messages
|
||||
case success // Success messages
|
||||
case info // General info messages
|
||||
|
||||
/// Display priority (higher = shown above others in stack)
|
||||
var priority: Int {
|
||||
switch self {
|
||||
case .error: return 100
|
||||
case .sponsorBlock: return 90
|
||||
case .retry: return 80
|
||||
case .loading: return 70
|
||||
case .remoteControl: return 60
|
||||
case .playerStatus: return 50
|
||||
case .download: return 40
|
||||
case .success: return 30
|
||||
case .info: return 20
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toast Action
|
||||
|
||||
/// An action button that can be displayed on a toast.
|
||||
struct ToastAction: Sendable {
|
||||
let label: String
|
||||
let systemImage: String?
|
||||
let handler: @Sendable @MainActor () async -> Void
|
||||
|
||||
init(
|
||||
label: String,
|
||||
systemImage: String? = nil,
|
||||
handler: @escaping @Sendable @MainActor () async -> Void
|
||||
) {
|
||||
self.label = label
|
||||
self.systemImage = systemImage
|
||||
self.handler = handler
|
||||
}
|
||||
}
|
||||
|
||||
extension ToastAction: Equatable {
|
||||
static func == (lhs: ToastAction, rhs: ToastAction) -> Bool {
|
||||
// Compare by label since handlers can't be compared
|
||||
lhs.label == rhs.label && lhs.systemImage == rhs.systemImage
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toast
|
||||
|
||||
/// A toast notification message.
|
||||
struct Toast: Identifiable, Equatable, Sendable {
|
||||
let id: UUID
|
||||
/// The scopes where this toast can be displayed.
|
||||
let scopes: Set<ToastScope>
|
||||
/// The resolved scope where this toast is actually displayed.
|
||||
/// Set by ToastManager when the toast is shown, based on current UI state.
|
||||
var activeScope: ToastScope
|
||||
let category: ToastCategory
|
||||
var title: String
|
||||
var subtitle: String?
|
||||
var icon: String?
|
||||
var iconColor: Color?
|
||||
let action: ToastAction?
|
||||
let autoDismissDelay: TimeInterval
|
||||
/// If true, toast won't auto-dismiss until explicitly dismissed or updated
|
||||
let isPersistent: Bool
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
scopes: Set<ToastScope> = [.main, .player],
|
||||
activeScope: ToastScope = .main,
|
||||
category: ToastCategory,
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
icon: String? = nil,
|
||||
iconColor: Color? = nil,
|
||||
action: ToastAction? = nil,
|
||||
autoDismissDelay: TimeInterval = 3.0,
|
||||
isPersistent: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.scopes = scopes
|
||||
self.activeScope = activeScope
|
||||
self.category = category
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.icon = icon
|
||||
self.iconColor = iconColor
|
||||
self.action = action
|
||||
self.autoDismissDelay = autoDismissDelay
|
||||
self.isPersistent = isPersistent
|
||||
}
|
||||
|
||||
static func == (lhs: Toast, rhs: Toast) -> Bool {
|
||||
lhs.id == rhs.id &&
|
||||
lhs.title == rhs.title &&
|
||||
lhs.subtitle == rhs.subtitle &&
|
||||
lhs.icon == rhs.icon &&
|
||||
lhs.iconColor == rhs.iconColor
|
||||
}
|
||||
}
|
||||
45
Yattee/Models/TranslationContributor.swift
Normal file
45
Yattee/Models/TranslationContributor.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// TranslationContributor.swift
|
||||
// Yattee
|
||||
//
|
||||
// Data model for Weblate translation contributor information.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// An aggregated translation contributor from Weblate.
|
||||
struct TranslationContributor: Identifiable, Sendable {
|
||||
let gravatarHash: String
|
||||
let username: String
|
||||
let fullName: String
|
||||
/// Per-language contribution counts
|
||||
let languageContributions: [String: Int]
|
||||
|
||||
var id: String { gravatarHash }
|
||||
|
||||
var totalContributions: Int {
|
||||
languageContributions.values.reduce(0, +)
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
fullName.isEmpty ? username : fullName
|
||||
}
|
||||
|
||||
var gravatarURL: URL? {
|
||||
URL(string: "https://www.gravatar.com/avatar/\(gravatarHash)?d=identicon&s=200")
|
||||
}
|
||||
|
||||
/// Returns top languages with counts formatted as "Polish (530), German (355), ..."
|
||||
func languageSummary(maxLanguages: Int = 3) -> String {
|
||||
let sorted = languageContributions
|
||||
.sorted { $0.value > $1.value }
|
||||
.prefix(maxLanguages)
|
||||
.map { "\($0.key) (\($0.value))" }
|
||||
|
||||
var result = sorted.joined(separator: ", ")
|
||||
if languageContributions.count > maxLanguages {
|
||||
result += ", ..."
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
378
Yattee/Models/Video.swift
Normal file
378
Yattee/Models/Video.swift
Normal file
@@ -0,0 +1,378 @@
|
||||
//
|
||||
// Video.swift
|
||||
// Yattee
|
||||
//
|
||||
// Core video model representing a video from any source.
|
||||
//
|
||||
|
||||
@preconcurrency import Foundation
|
||||
|
||||
/// Represents a video from any content source.
|
||||
struct Video: Identifiable, Codable, Sendable {
|
||||
/// Unique identifier combining source and video ID.
|
||||
let id: VideoID
|
||||
|
||||
/// The video title.
|
||||
let title: String
|
||||
|
||||
/// The video description (may be truncated in list views).
|
||||
let description: String?
|
||||
|
||||
/// The channel/author information.
|
||||
let author: Author
|
||||
|
||||
/// Duration in seconds.
|
||||
let duration: TimeInterval
|
||||
|
||||
/// When the video was published.
|
||||
let publishedAt: Date?
|
||||
|
||||
/// Human-readable published date from the API.
|
||||
let publishedText: String?
|
||||
|
||||
/// View count if available.
|
||||
let viewCount: Int?
|
||||
|
||||
/// Like count if available.
|
||||
let likeCount: Int?
|
||||
|
||||
/// Available thumbnails.
|
||||
let thumbnails: [Thumbnail]
|
||||
|
||||
/// Whether this is a live stream.
|
||||
let isLive: Bool
|
||||
|
||||
/// Whether this is an upcoming premiere/stream.
|
||||
let isUpcoming: Bool
|
||||
|
||||
/// Scheduled start time for upcoming content.
|
||||
let scheduledStartTime: Date?
|
||||
|
||||
/// Related/recommended videos (populated from video details API).
|
||||
let relatedVideos: [Video]?
|
||||
|
||||
// MARK: - Explicit Initializer
|
||||
|
||||
init(
|
||||
id: VideoID,
|
||||
title: String,
|
||||
description: String?,
|
||||
author: Author,
|
||||
duration: TimeInterval,
|
||||
publishedAt: Date?,
|
||||
publishedText: String?,
|
||||
viewCount: Int?,
|
||||
likeCount: Int?,
|
||||
thumbnails: [Thumbnail],
|
||||
isLive: Bool,
|
||||
isUpcoming: Bool,
|
||||
scheduledStartTime: Date?,
|
||||
relatedVideos: [Video]? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.author = author
|
||||
self.duration = duration
|
||||
self.publishedAt = publishedAt
|
||||
self.publishedText = publishedText
|
||||
self.viewCount = viewCount
|
||||
self.likeCount = likeCount
|
||||
self.thumbnails = thumbnails
|
||||
self.isLive = isLive
|
||||
self.isUpcoming = isUpcoming
|
||||
self.scheduledStartTime = scheduledStartTime
|
||||
self.relatedVideos = relatedVideos
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var bestThumbnail: Thumbnail? {
|
||||
thumbnails.sorted { $0.quality > $1.quality }.first
|
||||
}
|
||||
|
||||
var formattedDuration: String {
|
||||
guard !isLive else { return "LIVE" }
|
||||
guard duration > 0 else { return "" }
|
||||
|
||||
let hours = Int(duration) / 3600
|
||||
let minutes = (Int(duration) % 3600) / 60
|
||||
let seconds = Int(duration) % 60
|
||||
|
||||
if hours > 0 {
|
||||
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
||||
} else {
|
||||
return String(format: "%d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
var formattedViewCount: String? {
|
||||
guard let viewCount else { return nil }
|
||||
return CountFormatter.compact(viewCount)
|
||||
}
|
||||
|
||||
/// Formatted published date, preferring parsed Date over API-provided text.
|
||||
var formattedPublishedDate: String? {
|
||||
if let publishedAt {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .full
|
||||
return formatter.localizedString(for: publishedAt, relativeTo: Date())
|
||||
}
|
||||
return publishedText
|
||||
}
|
||||
|
||||
/// URL for sharing this video.
|
||||
var shareURL: URL {
|
||||
switch id.source {
|
||||
case .global(let provider):
|
||||
if provider == ContentSource.youtubeProvider {
|
||||
return URL(string: "https://www.youtube.com/watch?v=\(id.videoID)")!
|
||||
}
|
||||
// Future: handle other global providers
|
||||
return URL(string: "https://www.youtube.com/watch?v=\(id.videoID)")!
|
||||
case .federated(let provider, let instance):
|
||||
if provider == ContentSource.peertubeProvider {
|
||||
return URL(string: "\(instance.absoluteString)/w/\(id.videoID)")!
|
||||
}
|
||||
// Future: handle other federated providers
|
||||
return instance.appendingPathComponent("videos/\(id.videoID)")
|
||||
case .extracted(_, let originalURL):
|
||||
return originalURL
|
||||
}
|
||||
}
|
||||
|
||||
/// The ContentSource for the author's channel.
|
||||
/// For federated content, the author may be on a different instance than the video.
|
||||
var authorSource: ContentSource {
|
||||
switch id.source {
|
||||
case .global:
|
||||
return id.source
|
||||
case .federated(let provider, let videoInstance):
|
||||
// For PeerTube, the author might be on a different federated instance
|
||||
if let authorInstance = author.instance, authorInstance.host != videoInstance.host {
|
||||
return .federated(provider: provider, instance: authorInstance)
|
||||
}
|
||||
return id.source
|
||||
case .extracted:
|
||||
return id.source
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this video supports comments via the API.
|
||||
/// Currently only YouTube videos support comments through Invidious.
|
||||
var supportsComments: Bool {
|
||||
if case .global(let provider) = id.source {
|
||||
return provider == ContentSource.youtubeProvider
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Whether this video supports detailed statistics (views, likes, date) via API.
|
||||
/// Extracted videos (yt-dlp sources) don't have API stats available.
|
||||
var supportsAPIStats: Bool {
|
||||
switch id.source {
|
||||
case .global, .federated:
|
||||
return true
|
||||
case .extracted:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Video ID
|
||||
|
||||
/// Unique identifier for a video, combining source and video ID.
|
||||
struct VideoID: Codable, Hashable, Sendable {
|
||||
/// The content source.
|
||||
let source: ContentSource
|
||||
|
||||
/// The video ID within that source.
|
||||
let videoID: String
|
||||
|
||||
/// For PeerTube, videos can be identified by UUID as well.
|
||||
let uuid: String?
|
||||
|
||||
init(source: ContentSource, videoID: String, uuid: String? = nil) {
|
||||
self.source = source
|
||||
self.videoID = videoID
|
||||
self.uuid = uuid
|
||||
}
|
||||
|
||||
/// Creates a global video ID (e.g., YouTube).
|
||||
static func global(_ videoID: String, provider: String = ContentSource.youtubeProvider) -> VideoID {
|
||||
VideoID(source: .global(provider: provider), videoID: videoID)
|
||||
}
|
||||
|
||||
/// Creates a federated video ID (e.g., PeerTube).
|
||||
static func federated(_ videoID: String, provider: String = ContentSource.peertubeProvider, instance: URL, uuid: String? = nil) -> VideoID {
|
||||
VideoID(source: .federated(provider: provider, instance: instance), videoID: videoID, uuid: uuid)
|
||||
}
|
||||
|
||||
/// Creates an extracted video ID for sites supported by yt-dlp.
|
||||
static func extracted(_ videoID: String, extractor: String, originalURL: URL) -> VideoID {
|
||||
VideoID(source: .extracted(extractor: extractor, originalURL: originalURL), videoID: videoID)
|
||||
}
|
||||
}
|
||||
|
||||
extension VideoID: Identifiable {
|
||||
var id: String {
|
||||
switch source {
|
||||
case .global(let provider):
|
||||
return "global:\(provider):\(videoID)"
|
||||
case .federated(let provider, let instance):
|
||||
return "federated:\(provider):\(instance.host ?? ""):\(videoID)"
|
||||
case .extracted(let extractor, _):
|
||||
return "extracted:\(extractor):\(videoID)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this video is from an SMB media source.
|
||||
var isSMBSource: Bool {
|
||||
if case .extracted(let extractor, _) = source {
|
||||
return extractor == MediaFile.smbProvider
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Author
|
||||
|
||||
/// Represents a video author/channel.
|
||||
struct Author: Codable, Hashable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let thumbnailURL: URL?
|
||||
let subscriberCount: Int?
|
||||
|
||||
/// For PeerTube, the instance where this author is hosted.
|
||||
let instance: URL?
|
||||
|
||||
/// For external sources, the channel/author URL for navigation.
|
||||
let url: URL?
|
||||
|
||||
/// Whether real channel info was returned from the server (vs fallback to extractor name).
|
||||
let hasRealChannelInfo: Bool
|
||||
|
||||
init(id: String, name: String, thumbnailURL: URL? = nil, subscriberCount: Int? = nil, instance: URL? = nil, url: URL? = nil, hasRealChannelInfo: Bool = true) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.thumbnailURL = thumbnailURL
|
||||
self.subscriberCount = subscriberCount
|
||||
self.instance = instance
|
||||
self.url = url
|
||||
self.hasRealChannelInfo = hasRealChannelInfo
|
||||
}
|
||||
|
||||
var formattedSubscriberCount: String? {
|
||||
guard let subscriberCount else { return nil }
|
||||
return CountFormatter.compact(subscriberCount) + " " + String(localized: "channel.subscribers")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Thumbnail
|
||||
|
||||
/// Represents a video thumbnail.
|
||||
struct Thumbnail: Codable, Hashable, Sendable {
|
||||
let url: URL
|
||||
let quality: Quality
|
||||
let width: Int?
|
||||
let height: Int?
|
||||
|
||||
enum Quality: String, Codable, Comparable, Sendable {
|
||||
case `default`
|
||||
case medium
|
||||
case high
|
||||
case standard
|
||||
case maxres
|
||||
|
||||
static func < (lhs: Quality, rhs: Quality) -> Bool {
|
||||
let order: [Quality] = [.default, .medium, .high, .standard, .maxres]
|
||||
guard let lIndex = order.firstIndex(of: lhs),
|
||||
let rIndex = order.firstIndex(of: rhs) else {
|
||||
return false
|
||||
}
|
||||
return lIndex < rIndex
|
||||
}
|
||||
}
|
||||
|
||||
init(url: URL, quality: Quality = .default, width: Int? = nil, height: Int? = nil) {
|
||||
self.url = url
|
||||
self.quality = quality
|
||||
self.width = width
|
||||
self.height = height
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview Support
|
||||
|
||||
extension Video {
|
||||
/// A sample video for SwiftUI previews.
|
||||
static var preview: Video {
|
||||
Video(
|
||||
id: .global("dQw4w9WgXcQ"),
|
||||
title: "Sample Video Title",
|
||||
description: "This is a sample video description for preview purposes.",
|
||||
author: Author(id: "UC123", name: "Sample Channel"),
|
||||
duration: 212,
|
||||
publishedAt: Date().addingTimeInterval(-86400 * 3),
|
||||
publishedText: "3 days ago",
|
||||
viewCount: 1_234_567,
|
||||
likeCount: 50_000,
|
||||
thumbnails: [
|
||||
Thumbnail(
|
||||
url: URL(string: "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg")!,
|
||||
quality: .maxres,
|
||||
width: 1280,
|
||||
height: 720
|
||||
)
|
||||
],
|
||||
isLive: false,
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// A sample live video for SwiftUI previews.
|
||||
static var livePreview: Video {
|
||||
Video(
|
||||
id: .global("live123"),
|
||||
title: "Live Stream Example",
|
||||
description: "This is a live stream.",
|
||||
author: Author(id: "UC456", name: "Live Channel"),
|
||||
duration: 0,
|
||||
publishedAt: Date(),
|
||||
publishedText: "Started streaming",
|
||||
viewCount: 5_432,
|
||||
likeCount: nil,
|
||||
thumbnails: [
|
||||
Thumbnail(
|
||||
url: URL(string: "https://i.ytimg.com/vi/live123/maxresdefault.jpg")!,
|
||||
quality: .maxres
|
||||
)
|
||||
],
|
||||
isLive: true,
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Equatable & Hashable
|
||||
|
||||
extension Video: Equatable {
|
||||
/// Custom Equatable implementation that only compares by ID for performance.
|
||||
/// Two videos with the same ID are considered equal during SwiftUI diffing.
|
||||
/// This avoids expensive comparisons of all properties (including nested Author and [Thumbnail]).
|
||||
static func == (lhs: Video, rhs: Video) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
extension Video: Hashable {
|
||||
/// Custom Hashable implementation consistent with Equatable.
|
||||
/// Only hashes the ID since equality is determined by ID alone.
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
133
Yattee/Models/VideoListLayout.swift
Normal file
133
Yattee/Models/VideoListLayout.swift
Normal file
@@ -0,0 +1,133 @@
|
||||
//
|
||||
// VideoListLayout.swift
|
||||
// Yattee
|
||||
//
|
||||
// Layout options for video listing views.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Layout type for video lists.
|
||||
enum VideoListLayout: String, CaseIterable {
|
||||
case list
|
||||
case grid
|
||||
|
||||
var displayName: LocalizedStringKey {
|
||||
switch self {
|
||||
case .list: "viewOptions.layout.list"
|
||||
case .grid: "viewOptions.layout.grid"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .list: "list.bullet"
|
||||
case .grid: "square.grid.2x2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Grid Layout Helpers
|
||||
|
||||
/// Constants for grid layout calculations.
|
||||
enum GridConstants {
|
||||
/// Minimum width for a video card thumbnail to remain usable.
|
||||
static let minCardWidth: CGFloat = {
|
||||
#if os(tvOS)
|
||||
200
|
||||
#else
|
||||
100
|
||||
#endif
|
||||
}()
|
||||
|
||||
/// Spacing between grid items.
|
||||
static let spacing: CGFloat = {
|
||||
#if os(tvOS)
|
||||
32
|
||||
#else
|
||||
12
|
||||
#endif
|
||||
}()
|
||||
|
||||
/// Horizontal padding for the grid container.
|
||||
static let horizontalPadding: CGFloat = 32
|
||||
|
||||
/// Maximum allowed columns (to prevent excessive density).
|
||||
static let maxAllowedColumns = 6
|
||||
|
||||
/// Threshold for compact card styling (columns >= this use compact mode).
|
||||
static let compactThreshold = 3
|
||||
}
|
||||
|
||||
/// Calculates the maximum number of grid columns that fit within a given width.
|
||||
/// - Parameters:
|
||||
/// - width: Available container width
|
||||
/// - minCardWidth: Minimum width per card (defaults to GridConstants.minCardWidth)
|
||||
/// - spacing: Spacing between cards (defaults to GridConstants.spacing)
|
||||
/// - Returns: Maximum columns that fit, clamped between 1 and maxAllowedColumns
|
||||
func maxGridColumns(
|
||||
forWidth width: CGFloat,
|
||||
minCardWidth: CGFloat = GridConstants.minCardWidth,
|
||||
spacing: CGFloat = GridConstants.spacing
|
||||
) -> Int {
|
||||
let availableWidth = width - GridConstants.horizontalPadding
|
||||
// Formula: availableWidth = (columns * minCardWidth) + ((columns - 1) * spacing)
|
||||
// Solving for columns: columns = (availableWidth + spacing) / (minCardWidth + spacing)
|
||||
let maxColumns = Int((availableWidth + spacing) / (minCardWidth + spacing))
|
||||
return max(1, min(maxColumns, GridConstants.maxAllowedColumns))
|
||||
}
|
||||
|
||||
/// Creates grid columns for a LazyVGrid with the specified count.
|
||||
/// - Parameter count: Number of columns
|
||||
/// - Returns: Array of flexible GridItems with top alignment
|
||||
func makeGridColumns(count: Int) -> [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: GridConstants.spacing, alignment: .top), count: max(1, count))
|
||||
}
|
||||
|
||||
// MARK: - Grid Layout Configuration
|
||||
|
||||
/// Encapsulates grid layout calculations for views with grid/list layouts.
|
||||
///
|
||||
/// Use this to eliminate repeated computed properties across grid-enabled views.
|
||||
/// Create an instance with the current view width and user-selected column count,
|
||||
/// then use the computed properties for layout decisions.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// @State private var viewWidth: CGFloat = 0
|
||||
/// @AppStorage("myView.gridColumns") private var gridColumns = 2
|
||||
///
|
||||
/// private var gridConfig: GridLayoutConfiguration {
|
||||
/// GridLayoutConfiguration(viewWidth: viewWidth, gridColumns: gridColumns)
|
||||
/// }
|
||||
///
|
||||
/// // Then use:
|
||||
/// // gridConfig.effectiveColumns - actual column count
|
||||
/// // gridConfig.isCompactCards - whether to use compact card styling
|
||||
/// // gridConfig.columns - GridItem array for LazyVGrid
|
||||
/// // gridConfig.maxColumns - for ViewOptionsSheet
|
||||
/// ```
|
||||
struct GridLayoutConfiguration {
|
||||
let viewWidth: CGFloat
|
||||
let gridColumns: Int
|
||||
|
||||
/// Maximum columns that fit in the current width.
|
||||
var maxColumns: Int {
|
||||
maxGridColumns(forWidth: viewWidth)
|
||||
}
|
||||
|
||||
/// Effective column count, clamped to valid range.
|
||||
var effectiveColumns: Int {
|
||||
min(max(1, gridColumns), max(1, maxColumns))
|
||||
}
|
||||
|
||||
/// Whether cards should use compact styling (3+ columns).
|
||||
var isCompactCards: Bool {
|
||||
effectiveColumns >= GridConstants.compactThreshold
|
||||
}
|
||||
|
||||
/// GridItem array for LazyVGrid columns parameter.
|
||||
var columns: [GridItem] {
|
||||
makeGridColumns(count: effectiveColumns)
|
||||
}
|
||||
}
|
||||
23
Yattee/Models/VideoListStyle.swift
Normal file
23
Yattee/Models/VideoListStyle.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// VideoListStyle.swift
|
||||
// Yattee
|
||||
//
|
||||
// List style options for video list views.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Style type for list layout in video views.
|
||||
enum VideoListStyle: String, CaseIterable, Codable {
|
||||
/// Inset style with card background, padding, and rounded corners
|
||||
case inset
|
||||
/// Plain style without card background, traditional list appearance
|
||||
case plain
|
||||
|
||||
var displayName: LocalizedStringKey {
|
||||
switch self {
|
||||
case .inset: "viewOptions.listStyle.inset"
|
||||
case .plain: "viewOptions.listStyle.plain"
|
||||
}
|
||||
}
|
||||
}
|
||||
64
Yattee/Models/VideoRowStyle.swift
Normal file
64
Yattee/Models/VideoRowStyle.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// VideoRowStyle.swift
|
||||
// Yattee
|
||||
//
|
||||
// Row style configuration for VideoRowView.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Row style for VideoRowView.
|
||||
enum VideoRowStyle: String {
|
||||
/// Large size with 160x90 thumbnail (220x124 on tvOS) and 3-line titles.
|
||||
case large
|
||||
/// Regular size with 120x68 thumbnail (180x101 on tvOS) and 2-line titles.
|
||||
case regular
|
||||
/// Compact size with 70x39 thumbnail (120x68 on tvOS), 1-line titles, hidden metadata, and duration shown inline.
|
||||
case compact
|
||||
|
||||
var thumbnailWidth: CGFloat {
|
||||
switch self {
|
||||
case .large:
|
||||
#if os(tvOS)
|
||||
return 220
|
||||
#else
|
||||
return 160
|
||||
#endif
|
||||
case .regular:
|
||||
#if os(tvOS)
|
||||
return 180
|
||||
#else
|
||||
return 120
|
||||
#endif
|
||||
case .compact:
|
||||
#if os(tvOS)
|
||||
return 120
|
||||
#else
|
||||
return 70
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
var thumbnailHeight: CGFloat {
|
||||
switch self {
|
||||
case .large:
|
||||
#if os(tvOS)
|
||||
return 124
|
||||
#else
|
||||
return 90
|
||||
#endif
|
||||
case .regular:
|
||||
#if os(tvOS)
|
||||
return 101
|
||||
#else
|
||||
return 68
|
||||
#endif
|
||||
case .compact:
|
||||
#if os(tvOS)
|
||||
return 68
|
||||
#else
|
||||
return 39
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user