Yattee v2 rewrite

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

View File

@@ -0,0 +1,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
View 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)"
}
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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