mirror of
https://github.com/yattee/yattee.git
synced 2026-04-10 09:36:58 +00:00
Yattee v2 rewrite
This commit is contained in:
79
Yattee/Utilities/AvatarURLBuilder.swift
Normal file
79
Yattee/Utilities/AvatarURLBuilder.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// AvatarURLBuilder.swift
|
||||
// Yattee
|
||||
//
|
||||
// Utility for constructing channel avatar URLs with Yattee Server fallback.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Nuke
|
||||
|
||||
/// Utility for constructing channel avatar URLs with Yattee Server fallback
|
||||
enum AvatarURLBuilder {
|
||||
|
||||
/// Available avatar sizes on Yattee Server
|
||||
private static let availableSizes = [32, 48, 76, 100, 176, 512]
|
||||
|
||||
/// Constructs the effective avatar URL for a channel
|
||||
/// - Parameters:
|
||||
/// - channelID: The channel ID
|
||||
/// - directURL: Direct avatar URL if available (from API response)
|
||||
/// - serverURL: Yattee Server base URL
|
||||
/// - size: Desired size in points (will be doubled for retina and rounded to nearest available)
|
||||
/// - Returns: URL to use for avatar, or nil if unavailable
|
||||
static func avatarURL(
|
||||
channelID: String,
|
||||
directURL: URL?,
|
||||
serverURL: URL?,
|
||||
size: Int
|
||||
) -> URL? {
|
||||
// Check if this is a YouTube channel (UC prefix or @handle)
|
||||
let isYouTubeChannel = channelID.hasPrefix("UC") || channelID.hasPrefix("@")
|
||||
|
||||
// Priority 1: For YouTube channels, prefer Yattee Server (more reliable, avoids stale URLs from iCloud sync)
|
||||
if isYouTubeChannel, let serverURL = serverURL {
|
||||
return buildServerAvatarURL(serverURL: serverURL, channelID: channelID, size: size)
|
||||
}
|
||||
|
||||
// Priority 2: Use direct URL for non-YouTube channels or when server unavailable
|
||||
if let directURL = directURL {
|
||||
return directURL
|
||||
}
|
||||
|
||||
// Priority 3: Try server as last resort (for YouTube without direct URL)
|
||||
if let serverURL = serverURL {
|
||||
return buildServerAvatarURL(serverURL: serverURL, channelID: channelID, size: size)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Builds the Yattee Server avatar URL for a channel
|
||||
private static func buildServerAvatarURL(serverURL: URL, channelID: String, size: Int) -> URL {
|
||||
// Calculate retina size and round to nearest available
|
||||
let retinaSize = size * 2
|
||||
let roundedSize = availableSizes
|
||||
.min { abs($0 - retinaSize) < abs($1 - retinaSize) } ?? 176
|
||||
|
||||
return serverURL
|
||||
.appendingPathComponent("api/v1/channels")
|
||||
.appendingPathComponent(channelID)
|
||||
.appendingPathComponent("avatar")
|
||||
.appendingPathComponent("\(roundedSize).jpg")
|
||||
}
|
||||
|
||||
/// Creates an ImageRequest with auth header for Yattee Server avatar URLs
|
||||
/// - Parameters:
|
||||
/// - url: The avatar URL
|
||||
/// - authHeader: Optional Basic Auth header for Yattee Server
|
||||
/// - Returns: ImageRequest configured with auth if needed, or nil if URL is nil
|
||||
static func imageRequest(url: URL?, authHeader: String?) -> ImageRequest? {
|
||||
guard let url else { return nil }
|
||||
var request = URLRequest(url: url)
|
||||
// Only add auth header for Yattee Server avatar URLs
|
||||
if let authHeader, url.path.contains("/api/v1/channels/") && url.path.contains("/avatar/") {
|
||||
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
return ImageRequest(urlRequest: request)
|
||||
}
|
||||
}
|
||||
307
Yattee/Utilities/ChapterParser.swift
Normal file
307
Yattee/Utilities/ChapterParser.swift
Normal file
@@ -0,0 +1,307 @@
|
||||
//
|
||||
// ChapterParser.swift
|
||||
// Yattee
|
||||
//
|
||||
// Parses video chapters from description text.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Parses video chapters from description text using timestamp detection.
|
||||
struct ChapterParser: Sendable {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
/// Characters that may prefix a timestamp (to be stripped).
|
||||
private static let prefixCharacters = CharacterSet(charactersIn: "▶►•-*→➤")
|
||||
|
||||
/// Characters that may separate timestamp from title (to be stripped).
|
||||
private static let separatorCharacters = CharacterSet(charactersIn: "-|:–—")
|
||||
|
||||
/// Regex pattern for strict timestamp matching.
|
||||
/// Matches: M:SS, MM:SS, MMM:SS (for long videos), H:MM:SS, HH:MM:SS
|
||||
/// Does not match timestamps in brackets/parentheses (handled by caller).
|
||||
private static let timestampPattern = #"^(\d{1,3}):(\d{2})(?::(\d{2}))?$"#
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Parses chapters from a video description.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - description: The video description text (may be nil).
|
||||
/// - videoDuration: The video duration in seconds. Must be > 0.
|
||||
/// - introTitle: Title for synthetic intro chapter if first chapter doesn't start at 0:00.
|
||||
/// - Returns: Array of chapters, or empty array if parsing fails or no valid chapters found.
|
||||
static func parse(
|
||||
description: String?,
|
||||
videoDuration: TimeInterval,
|
||||
introTitle: String = "Intro"
|
||||
) -> [VideoChapter] {
|
||||
// Validate inputs
|
||||
guard let description, !description.isEmpty else { return [] }
|
||||
guard videoDuration > 0 else { return [] }
|
||||
|
||||
// Extract raw chapters from description
|
||||
let rawChapters = extractChapterBlock(from: description)
|
||||
|
||||
// Filter out chapters beyond video duration
|
||||
let validChapters = rawChapters.filter { $0.startTime < videoDuration }
|
||||
|
||||
// Need minimum 2 chapters
|
||||
guard validChapters.count >= 2 else { return [] }
|
||||
|
||||
// Sort chronologically
|
||||
let sorted = validChapters.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// Merge duplicate timestamps
|
||||
let merged = mergeDuplicateTimestamps(sorted)
|
||||
|
||||
// Insert synthetic intro if needed
|
||||
let withIntro = insertSyntheticIntro(merged, introTitle: introTitle)
|
||||
|
||||
// Convert to VideoChapter with end times
|
||||
return buildVideoChapters(from: withIntro, videoDuration: videoDuration)
|
||||
}
|
||||
|
||||
// MARK: - Private Parsing Methods
|
||||
|
||||
/// Extracts the first contiguous block of chapter lines from the description.
|
||||
private static func extractChapterBlock(from description: String) -> [(startTime: TimeInterval, title: String)] {
|
||||
let lines = description.components(separatedBy: .newlines)
|
||||
var chapters: [(startTime: TimeInterval, title: String)] = []
|
||||
var inBlock = false
|
||||
|
||||
for line in lines {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
// Empty lines don't break the block
|
||||
if trimmed.isEmpty {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to parse as chapter line
|
||||
if let chapter = parseChapterLine(trimmed) {
|
||||
inBlock = true
|
||||
chapters.append(chapter)
|
||||
} else if inBlock {
|
||||
// Check if this looks like a timestamp line (even if invalid)
|
||||
// Lines that look like they could be timestamps continue the block
|
||||
// Lines that clearly aren't timestamps break the block
|
||||
if looksLikeTimestampLine(trimmed) {
|
||||
// Invalid timestamp format (brackets, no title, etc.) - skip but continue block
|
||||
continue
|
||||
} else {
|
||||
// Clearly not a timestamp line - break the block
|
||||
break
|
||||
}
|
||||
}
|
||||
// If not in block yet and line isn't a chapter, keep looking
|
||||
}
|
||||
|
||||
return chapters
|
||||
}
|
||||
|
||||
/// Checks if a line looks like it could be a timestamp line.
|
||||
/// Returns true for lines that start with timestamp-like patterns,
|
||||
/// even if they would be rejected for other reasons (brackets, no title, etc.).
|
||||
private static func looksLikeTimestampLine(_ line: String) -> Bool {
|
||||
var working = line
|
||||
|
||||
// Strip brackets/parentheses to check what's inside
|
||||
if working.hasPrefix("[") || working.hasPrefix("(") {
|
||||
working = String(working.dropFirst())
|
||||
}
|
||||
|
||||
// Strip prefix characters
|
||||
working = stripPrefixes(working)
|
||||
|
||||
// Check if it starts with a digit (potential timestamp)
|
||||
guard let first = working.first, first.isNumber else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Look for timestamp pattern (digits, colons)
|
||||
let timestampChars = CharacterSet(charactersIn: "0123456789:")
|
||||
let prefix = working.prefix(while: { char in
|
||||
char.unicodeScalars.allSatisfy { timestampChars.contains($0) }
|
||||
})
|
||||
|
||||
// Must have at least one colon to look like a timestamp
|
||||
return prefix.contains(":")
|
||||
}
|
||||
|
||||
/// Parses a single line as a chapter entry.
|
||||
///
|
||||
/// - Parameter line: A trimmed line from the description.
|
||||
/// - Returns: A tuple of (startTime, title) if valid, nil otherwise.
|
||||
private static func parseChapterLine(_ line: String) -> (startTime: TimeInterval, title: String)? {
|
||||
var working = line
|
||||
|
||||
// Skip if wrapped in brackets or parentheses
|
||||
if working.hasPrefix("[") || working.hasPrefix("(") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Strip prefix characters
|
||||
working = stripPrefixes(working)
|
||||
|
||||
// Find the timestamp at the start
|
||||
guard let (timestamp, remainingAfterTimestamp) = extractLeadingTimestamp(from: working) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Strip separators from the title
|
||||
let title = stripSeparators(remainingAfterTimestamp).trimmingCharacters(in: .whitespaces)
|
||||
|
||||
// Skip if no title
|
||||
guard !title.isEmpty else { return nil }
|
||||
|
||||
return (timestamp, title)
|
||||
}
|
||||
|
||||
/// Strips known prefix characters from the start of a string.
|
||||
private static func stripPrefixes(_ string: String) -> String {
|
||||
var result = string
|
||||
while let first = result.unicodeScalars.first,
|
||||
prefixCharacters.contains(first) {
|
||||
result = String(result.dropFirst())
|
||||
result = result.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Strips known separator characters from the start of a string.
|
||||
private static func stripSeparators(_ string: String) -> String {
|
||||
var result = string.trimmingCharacters(in: .whitespaces)
|
||||
while let first = result.unicodeScalars.first,
|
||||
separatorCharacters.contains(first) {
|
||||
result = String(result.dropFirst())
|
||||
result = result.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Extracts a timestamp from the start of a string.
|
||||
///
|
||||
/// - Parameter string: The string to parse.
|
||||
/// - Returns: A tuple of (timestamp in seconds, remaining string after timestamp) if found.
|
||||
private static func extractLeadingTimestamp(from string: String) -> (TimeInterval, String)? {
|
||||
// Find where the timestamp ends (first space or separator after digits/colons)
|
||||
let timestampEndIndex = string.firstIndex { char in
|
||||
char == " " || char == "\t" || char == "-" || char == "|" || char == "–" || char == "—"
|
||||
} ?? string.endIndex
|
||||
|
||||
let potentialTimestamp = String(string[..<timestampEndIndex])
|
||||
let remaining = String(string[timestampEndIndex...])
|
||||
|
||||
guard let seconds = parseTimestamp(potentialTimestamp) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (seconds, remaining)
|
||||
}
|
||||
|
||||
/// Parses a timestamp string into seconds.
|
||||
///
|
||||
/// Supported formats: M:SS, MM:SS, H:MM:SS, HH:MM:SS
|
||||
///
|
||||
/// - Parameter timestamp: The timestamp string (e.g., "1:23:45" or "5:30").
|
||||
/// - Returns: The time in seconds, or nil if invalid format.
|
||||
private static func parseTimestamp(_ timestamp: String) -> TimeInterval? {
|
||||
guard let regex = try? NSRegularExpression(pattern: timestampPattern) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let range = NSRange(timestamp.startIndex..., in: timestamp)
|
||||
guard let match = regex.firstMatch(in: timestamp, range: range) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract capture groups
|
||||
guard let firstRange = Range(match.range(at: 1), in: timestamp),
|
||||
let secondRange = Range(match.range(at: 2), in: timestamp) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let first = Int(timestamp[firstRange]) ?? 0
|
||||
let second = Int(timestamp[secondRange]) ?? 0
|
||||
|
||||
// Check if third group (seconds in H:MM:SS format) exists
|
||||
if match.range(at: 3).location != NSNotFound,
|
||||
let thirdRange = Range(match.range(at: 3), in: timestamp) {
|
||||
// H:MM:SS or HH:MM:SS format
|
||||
let hours = first
|
||||
let minutes = second
|
||||
let seconds = Int(timestamp[thirdRange]) ?? 0
|
||||
|
||||
// Validate ranges
|
||||
guard minutes < 60, seconds < 60 else { return nil }
|
||||
|
||||
return TimeInterval(hours * 3600 + minutes * 60 + seconds)
|
||||
} else {
|
||||
// M:SS or MM:SS format
|
||||
let minutes = first
|
||||
let seconds = second
|
||||
|
||||
// Validate seconds range
|
||||
guard seconds < 60 else { return nil }
|
||||
|
||||
return TimeInterval(minutes * 60 + seconds)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Post-Processing Methods
|
||||
|
||||
/// Merges chapters with duplicate timestamps by combining their titles.
|
||||
private static func mergeDuplicateTimestamps(
|
||||
_ chapters: [(startTime: TimeInterval, title: String)]
|
||||
) -> [(startTime: TimeInterval, title: String)] {
|
||||
var result: [(startTime: TimeInterval, title: String)] = []
|
||||
|
||||
for chapter in chapters {
|
||||
if let lastIndex = result.lastIndex(where: { $0.startTime == chapter.startTime }) {
|
||||
// Merge with existing chapter at same timestamp
|
||||
result[lastIndex].title += " / " + chapter.title
|
||||
} else {
|
||||
result.append(chapter)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Inserts a synthetic intro chapter at 0:00 if the first chapter doesn't start there.
|
||||
private static func insertSyntheticIntro(
|
||||
_ chapters: [(startTime: TimeInterval, title: String)],
|
||||
introTitle: String
|
||||
) -> [(startTime: TimeInterval, title: String)] {
|
||||
guard let first = chapters.first, first.startTime > 0 else {
|
||||
return chapters
|
||||
}
|
||||
|
||||
var result = chapters
|
||||
result.insert((startTime: 0, title: introTitle), at: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
/// Converts raw chapter data to VideoChapter objects with end times.
|
||||
private static func buildVideoChapters(
|
||||
from chapters: [(startTime: TimeInterval, title: String)],
|
||||
videoDuration: TimeInterval
|
||||
) -> [VideoChapter] {
|
||||
return chapters.enumerated().map { index, chapter in
|
||||
let endTime: TimeInterval
|
||||
if index < chapters.count - 1 {
|
||||
endTime = chapters[index + 1].startTime
|
||||
} else {
|
||||
endTime = videoDuration
|
||||
}
|
||||
|
||||
return VideoChapter(
|
||||
title: chapter.title,
|
||||
startTime: chapter.startTime,
|
||||
endTime: endTime
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
30
Yattee/Utilities/CountFormatter.swift
Normal file
30
Yattee/Utilities/CountFormatter.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// CountFormatter.swift
|
||||
// Yattee
|
||||
//
|
||||
// Centralized utility for formatting counts with compact notation (1K, 2.5K, etc.)
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Utility for formatting counts with compact notation and proper pluralization.
|
||||
enum CountFormatter {
|
||||
/// Formats a count to compact notation (e.g., 1K, 2.5K, 1M, 1B).
|
||||
/// - Parameter count: The number to format
|
||||
/// - Returns: Formatted string (e.g., "150", "1.2K", "2.5M", "1B")
|
||||
static func compact(_ count: Int) -> String {
|
||||
switch count {
|
||||
case 0..<1_000:
|
||||
return "\(count)"
|
||||
case 1_000..<1_000_000:
|
||||
let value = Double(count) / 1_000
|
||||
return String(format: "%.1fK", value).replacingOccurrences(of: ".0K", with: "K")
|
||||
case 1_000_000..<1_000_000_000:
|
||||
let value = Double(count) / 1_000_000
|
||||
return String(format: "%.1fM", value).replacingOccurrences(of: ".0M", with: "M")
|
||||
default:
|
||||
let value = Double(count) / 1_000_000_000
|
||||
return String(format: "%.1fB", value).replacingOccurrences(of: ".0B", with: "B")
|
||||
}
|
||||
}
|
||||
}
|
||||
103
Yattee/Utilities/DescriptionText.swift
Normal file
103
Yattee/Utilities/DescriptionText.swift
Normal file
@@ -0,0 +1,103 @@
|
||||
//
|
||||
// DescriptionText.swift
|
||||
// Yattee
|
||||
//
|
||||
// Utilities for parsing and formatting video description text with clickable links and timestamps.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Description Text Utilities
|
||||
|
||||
enum DescriptionText {
|
||||
/// Creates an attributed string with clickable URLs and timestamps.
|
||||
/// Timestamps are converted to `yattee-seek://SECONDS` URLs.
|
||||
static func attributed(_ text: String, linkColor: Color = .accentColor) -> AttributedString {
|
||||
var attributedString = AttributedString(text)
|
||||
|
||||
// URL regex pattern
|
||||
let urlPattern = #"https?://[^\s<>\"\']+"#
|
||||
|
||||
if let regex = try? NSRegularExpression(pattern: urlPattern, options: []) {
|
||||
let nsRange = NSRange(text.startIndex..., in: text)
|
||||
let matches = regex.matches(in: text, options: [], range: nsRange)
|
||||
|
||||
for match in matches {
|
||||
guard let range = Range(match.range, in: text),
|
||||
let attributedRange = Range(range, in: attributedString),
|
||||
let url = URL(string: String(text[range])) else {
|
||||
continue
|
||||
}
|
||||
|
||||
attributedString[attributedRange].link = url
|
||||
attributedString[attributedRange].foregroundColor = linkColor
|
||||
}
|
||||
}
|
||||
|
||||
// Timestamp pattern: matches formats like 0:00, 00:00, 0:00:00, 00:00:00
|
||||
// Must be at word boundary (not part of a larger number sequence)
|
||||
let timestampPattern = #"(?<![:\d])(\d{1,2}:\d{2}(?::\d{2})?)(?![:\d])"#
|
||||
|
||||
if let timestampRegex = try? NSRegularExpression(pattern: timestampPattern, options: []) {
|
||||
let nsRange = NSRange(text.startIndex..., in: text)
|
||||
let matches = timestampRegex.matches(in: text, options: [], range: nsRange)
|
||||
|
||||
for match in matches {
|
||||
guard let range = Range(match.range, in: text),
|
||||
let attributedRange = Range(range, in: attributedString) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let timestampString = String(text[range])
|
||||
let seconds = parseTimestamp(timestampString)
|
||||
|
||||
if let url = URL(string: "yattee-seek://\(seconds)") {
|
||||
attributedString[attributedRange].link = url
|
||||
attributedString[attributedRange].foregroundColor = linkColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return attributedString
|
||||
}
|
||||
|
||||
/// Parses a timestamp string (MM:SS or H:MM:SS) into total seconds.
|
||||
static func parseTimestamp(_ timestamp: String) -> Int {
|
||||
let components = timestamp.split(separator: ":").compactMap { Int($0) }
|
||||
switch components.count {
|
||||
case 2: // MM:SS
|
||||
return components[0] * 60 + components[1]
|
||||
case 3: // H:MM:SS
|
||||
return components[0] * 3600 + components[1] * 60 + components[2]
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// URL scheme used for seek timestamps.
|
||||
static let seekScheme = "yattee-seek"
|
||||
|
||||
/// Extracts the seconds value from a seek URL, if valid.
|
||||
static func seekSeconds(from url: URL) -> Int? {
|
||||
guard url.scheme == seekScheme else { return nil }
|
||||
return Int(url.host ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OpenURL Action for Seeking
|
||||
|
||||
extension View {
|
||||
/// Adds a URL handler that intercepts timestamp links and seeks the player.
|
||||
func handleTimestampLinks(using playerService: PlayerService?) -> some View {
|
||||
self.environment(\.openURL, OpenURLAction { url in
|
||||
if let seconds = DescriptionText.seekSeconds(from: url) {
|
||||
Task {
|
||||
await playerService?.seek(to: TimeInterval(seconds))
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
return .systemAction
|
||||
})
|
||||
}
|
||||
}
|
||||
158
Yattee/Utilities/DirectMediaHelper.swift
Normal file
158
Yattee/Utilities/DirectMediaHelper.swift
Normal file
@@ -0,0 +1,158 @@
|
||||
//
|
||||
// DirectMediaHelper.swift
|
||||
// Yattee
|
||||
//
|
||||
// Helper for creating Video/Stream objects from direct media URLs.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Helper for handling direct media URLs (mp4, m3u8, etc.) without extraction.
|
||||
enum DirectMediaHelper {
|
||||
/// Provider name for direct media URLs.
|
||||
static let provider = "direct_media"
|
||||
|
||||
// MARK: - Supported Extensions
|
||||
|
||||
/// Video file extensions that can be played directly.
|
||||
static let videoExtensions: Set<String> = [
|
||||
"mp4", "m4v", "mov", "mkv", "avi", "webm", "wmv",
|
||||
"flv", "mpg", "mpeg", "3gp", "ts", "m2ts"
|
||||
]
|
||||
|
||||
/// Audio file extensions that can be played directly.
|
||||
static let audioExtensions: Set<String> = [
|
||||
"mp3", "m4a", "aac", "flac", "wav", "ogg", "opus"
|
||||
]
|
||||
|
||||
/// Streaming format extensions (HLS, DASH).
|
||||
static let streamingExtensions: Set<String> = [
|
||||
"m3u8", "mpd"
|
||||
]
|
||||
|
||||
/// All supported media extensions.
|
||||
static var allExtensions: Set<String> {
|
||||
videoExtensions.union(audioExtensions).union(streamingExtensions)
|
||||
}
|
||||
|
||||
// MARK: - Detection
|
||||
|
||||
/// Checks if the URL points to a direct media file based on extension.
|
||||
static func isDirectMediaURL(_ url: URL) -> Bool {
|
||||
let pathExtension = url.pathExtension.lowercased()
|
||||
return allExtensions.contains(pathExtension)
|
||||
}
|
||||
|
||||
/// Checks if the URL is a streaming format (HLS/DASH).
|
||||
static func isStreamingURL(_ url: URL) -> Bool {
|
||||
let pathExtension = url.pathExtension.lowercased()
|
||||
return streamingExtensions.contains(pathExtension)
|
||||
}
|
||||
|
||||
/// Checks if the URL is an audio-only file.
|
||||
static func isAudioURL(_ url: URL) -> Bool {
|
||||
let pathExtension = url.pathExtension.lowercased()
|
||||
return audioExtensions.contains(pathExtension)
|
||||
}
|
||||
|
||||
// MARK: - Video/Stream Creation
|
||||
|
||||
/// Creates a Video model from a direct media URL.
|
||||
static func createVideo(from url: URL) -> Video {
|
||||
let filename = url.lastPathComponent
|
||||
let title = (filename as NSString).deletingPathExtension
|
||||
let host = url.host ?? "Direct Media"
|
||||
|
||||
return Video(
|
||||
id: VideoID(
|
||||
source: .extracted(extractor: provider, originalURL: url),
|
||||
videoID: url.absoluteString
|
||||
),
|
||||
title: title.isEmpty ? filename : title,
|
||||
description: nil,
|
||||
author: Author(id: provider, name: host, hasRealChannelInfo: false),
|
||||
duration: 0, // Unknown until playback
|
||||
publishedAt: nil,
|
||||
publishedText: nil,
|
||||
viewCount: nil,
|
||||
likeCount: nil,
|
||||
thumbnails: [],
|
||||
isLive: isStreamingURL(url), // Treat HLS/DASH as potentially live
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a Stream from a direct media URL.
|
||||
static func createStream(from url: URL) -> Stream {
|
||||
let pathExtension = url.pathExtension.lowercased()
|
||||
let isAudio = isAudioURL(url)
|
||||
let isStreaming = isStreamingURL(url)
|
||||
|
||||
return Stream(
|
||||
url: url,
|
||||
resolution: isAudio ? nil : .p720, // Default assumption for video
|
||||
format: pathExtension,
|
||||
videoCodec: isAudio ? nil : "unknown",
|
||||
audioCodec: "unknown",
|
||||
bitrate: nil,
|
||||
fileSize: nil,
|
||||
isAudioOnly: isAudio,
|
||||
isLive: isStreaming,
|
||||
mimeType: mimeType(for: pathExtension),
|
||||
audioLanguage: nil,
|
||||
audioTrackName: nil,
|
||||
isOriginalAudio: true,
|
||||
httpHeaders: nil,
|
||||
fps: nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - MIME Type Mapping
|
||||
|
||||
/// Returns the MIME type for a file extension.
|
||||
private static func mimeType(for extension: String) -> String? {
|
||||
switch `extension` {
|
||||
case "mp4", "m4v":
|
||||
return "video/mp4"
|
||||
case "mov":
|
||||
return "video/quicktime"
|
||||
case "mkv":
|
||||
return "video/x-matroska"
|
||||
case "avi":
|
||||
return "video/x-msvideo"
|
||||
case "webm":
|
||||
return "video/webm"
|
||||
case "wmv":
|
||||
return "video/x-ms-wmv"
|
||||
case "flv":
|
||||
return "video/x-flv"
|
||||
case "mpg", "mpeg":
|
||||
return "video/mpeg"
|
||||
case "3gp":
|
||||
return "video/3gpp"
|
||||
case "ts", "m2ts":
|
||||
return "video/mp2t"
|
||||
case "m3u8":
|
||||
return "application/vnd.apple.mpegurl"
|
||||
case "mpd":
|
||||
return "application/dash+xml"
|
||||
case "mp3":
|
||||
return "audio/mpeg"
|
||||
case "m4a":
|
||||
return "audio/mp4"
|
||||
case "aac":
|
||||
return "audio/aac"
|
||||
case "flac":
|
||||
return "audio/flac"
|
||||
case "wav":
|
||||
return "audio/wav"
|
||||
case "ogg":
|
||||
return "audio/ogg"
|
||||
case "opus":
|
||||
return "audio/opus"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
33
Yattee/Utilities/RelativeDateFormatter.swift
Normal file
33
Yattee/Utilities/RelativeDateFormatter.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// RelativeDateFormatter.swift
|
||||
// Yattee
|
||||
//
|
||||
// Centralized utility for formatting relative dates with "just now" support.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Utility for formatting dates relative to now, with proper handling of very recent times.
|
||||
enum RelativeDateFormatter {
|
||||
/// Formats a date relative to now, showing "just now" for very recent dates.
|
||||
/// - Parameters:
|
||||
/// - date: The date to format
|
||||
/// - justNowThreshold: Seconds within which to show "just now" (default: 10)
|
||||
/// - unitsStyle: The style for the relative date formatter (default: .abbreviated)
|
||||
/// - Returns: Formatted string (e.g., "just now", "5 min ago", "2 hours ago")
|
||||
static func string(
|
||||
for date: Date,
|
||||
justNowThreshold: TimeInterval = 10,
|
||||
unitsStyle: RelativeDateTimeFormatter.UnitsStyle = .abbreviated
|
||||
) -> String {
|
||||
let interval = Date().timeIntervalSince(date)
|
||||
|
||||
if interval < justNowThreshold {
|
||||
return String(localized: "common.justNow")
|
||||
}
|
||||
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = unitsStyle
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user