Files
yattee/Yattee/Models/Storyboard.swift
2026-02-08 18:33:56 +01:00

182 lines
6.6 KiB
Swift

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