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,551 @@
//
// BackendSwitcher.swift
// Yattee
//
// Handles seamless switching between player backends during playback.
//
import Foundation
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
// MARK: - Switch Animation
/// Animation style for backend switching.
enum BackendSwitchAnimation: Sendable {
case instant // No animation, immediate swap
case crossfade // Crossfade between views
case slide // Slide transition
var duration: TimeInterval {
switch self {
case .instant: return 0
case .crossfade: return 0.3
case .slide: return 0.4
}
}
}
// MARK: - Switch Result
/// Result of a backend switch operation.
struct BackendSwitchResult: Sendable {
let success: Bool
let sourceBackend: PlayerBackendType
let targetBackend: PlayerBackendType
let timeDrift: TimeInterval // Difference between expected and actual time after switch
let switchDuration: TimeInterval // How long the switch took
}
// MARK: - Backend Switcher Delegate
/// Delegate for switch progress callbacks.
@MainActor
protocol BackendSwitcherDelegate: AnyObject {
func switcherWillBeginSwitch(from source: PlayerBackendType, to target: PlayerBackendType)
func switcherDidPrepareTarget(_ switcher: BackendSwitcher)
func switcherDidCompleteSwitch(_ result: BackendSwitchResult)
func switcherDidFailSwitch(_ error: Error)
}
// MARK: - Backend Switcher
/// Manages seamless hot-swapping between player backends.
@MainActor
final class BackendSwitcher {
// MARK: - Properties
weak var delegate: BackendSwitcherDelegate?
/// Whether a switch is currently in progress.
private(set) var isSwitching: Bool = false
// MARK: - Dependencies
/// Factory for creating backend instances.
private let backendFactory: BackendFactory
/// Settings manager for quality preferences.
weak var settingsManager: SettingsManager?
init(backendFactory: BackendFactory, settingsManager: SettingsManager?) {
self.backendFactory = backendFactory
self.settingsManager = settingsManager
}
// MARK: - Public Methods
/// Switch from one backend to another during active playback.
///
/// This method:
/// 1. Captures the current playback state from the source backend
/// 2. Selects a compatible stream for the target backend
/// 3. Initializes the target backend and loads the stream
/// 4. Seeks to the captured position
/// 5. Waits for the target to be ready
/// 6. Performs a smooth visual transition
/// 7. Resumes playback on the target backend
/// 8. Cleans up the source backend
///
/// - Parameters:
/// - source: The currently active backend
/// - targetType: The type of backend to switch to
/// - streams: Available streams for the current video
/// - animation: Animation style for the transition
/// - Returns: The new active backend
/// - Throws: BackendError if the switch fails
func switchBackend(
from source: any PlayerBackend,
to targetType: PlayerBackendType,
streams: [Stream],
animation: BackendSwitchAnimation = .crossfade
) async throws -> any PlayerBackend {
guard !isSwitching else {
throw BackendError.switchFailed("Switch already in progress")
}
let startTime = Date()
isSwitching = true
defer { isSwitching = false }
LoggingService.shared.logPlayer("Backend switch starting", details: "From \(source.backendType.rawValue) to \(targetType.rawValue)")
delegate?.switcherWillBeginSwitch(from: source.backendType, to: targetType)
// Step 1: Capture current state
let capturedState = source.captureState()
// Step 2: Find compatible stream for target backend
guard let selection = selectStream(for: targetType, from: streams) else {
throw BackendError.switchFailed("No compatible stream found for \(targetType.displayName)")
}
let targetStream = selection.video
let targetAudioStream = selection.audio
// Step 3: Prepare source for handoff (pause but keep state)
source.prepareForHandoff()
// Step 4: Create and initialize target backend
let target = try backendFactory.createBackend(type: targetType)
// Step 5: Load stream on target (without autoplay)
do {
let useEDL = settingsManager?.mpvUseEDLStreams ?? true
try await target.load(stream: targetStream, audioStream: targetAudioStream, autoplay: false, useEDL: useEDL)
} catch {
// Rollback: resume source backend
if capturedState.isPlaying {
source.play()
}
throw BackendError.switchFailed("Failed to load stream on target: \(error.localizedDescription)")
}
delegate?.switcherDidPrepareTarget(self)
// Step 6: Seek to captured position
if capturedState.currentTime > 0 {
await target.seek(to: capturedState.currentTime, showLoading: false)
}
// Step 7: Restore other state (volume, rate, mute)
target.volume = capturedState.volume
target.isMuted = capturedState.isMuted
target.rate = capturedState.rate
// Step 8: Perform visual transition
await performTransition(
from: source,
to: target,
animation: animation
)
// Step 9: Resume playback if was playing
if capturedState.isPlaying {
target.play()
}
// Step 10: Stop source backend
source.stop()
// Calculate result metrics
let switchDuration = Date().timeIntervalSince(startTime)
let timeDrift = abs(target.currentTime - capturedState.currentTime)
let result = BackendSwitchResult(
success: true,
sourceBackend: source.backendType,
targetBackend: targetType,
timeDrift: timeDrift,
switchDuration: switchDuration
)
LoggingService.shared.logPlayer("Backend switch completed", details: "Duration: \(String(format: "%.2f", switchDuration))s, drift: \(String(format: "%.3f", timeDrift))s")
delegate?.switcherDidCompleteSwitch(result)
return target
}
/// Check if switching to a given backend type is possible.
func canSwitch(to targetType: PlayerBackendType, streams: [Stream]) -> Bool {
// Check if we have a compatible stream
selectStream(for: targetType, from: streams) != nil
}
// MARK: - Private Methods
/// Select the best compatible stream for a backend type.
private func selectStream(for backendType: PlayerBackendType, from streams: [Stream]) -> (video: Stream, audio: Stream?)? {
let supportedFormats = backendType.supportedFormats
let preferredQuality = settingsManager?.preferredQuality ?? .auto
// Separate streams by type
let videoOnlyStreams = streams.filter { stream in
guard !stream.isAudioOnly && stream.isVideoOnly else { return false }
let format = StreamFormat.detect(from: stream)
return supportedFormats.contains(format)
}
let muxedStreams = streams.filter { stream in
let format = StreamFormat.detect(from: stream)
guard supportedFormats.contains(format) else { return false }
return stream.isMuxed || format == .hls || format == .dash
}
let audioStreams = streams.filter { $0.isAudioOnly }
// Get the maximum resolution based on user's quality preference
let maxResolution = preferredQuality.maxResolution
// For live streams, always prefer HLS/DASH (designed for live streaming)
let isLiveStream = streams.contains(where: { $0.isLive })
if isLiveStream {
if let hlsStream = muxedStreams.first(where: { StreamFormat.detect(from: $0) == .hls }) {
return (hlsStream, nil)
}
if let dashStream = muxedStreams.first(where: { StreamFormat.detect(from: $0) == .dash }) {
return (dashStream, nil)
}
}
// Note: For non-live videos, we prefer progressive formats (MP4/WebM) over HLS/DASH
// because they typically offer better quality. HLS/DASH are only used as last resort.
// Try to find the best video-only stream + audio
if !videoOnlyStreams.isEmpty && !audioStreams.isEmpty {
let filteredVideoStreams: [Stream]
if let maxRes = maxResolution {
filteredVideoStreams = videoOnlyStreams.filter { stream in
guard let resolution = stream.resolution else { return true }
return resolution <= maxRes
}
} else {
filteredVideoStreams = videoOnlyStreams
}
// Sort by resolution first, then by codec quality (AV1 > VP9 > H.264)
let sortedVideo = filteredVideoStreams.sorted { s1, s2 in
let res1 = s1.resolution ?? .p360
let res2 = s2.resolution ?? .p360
if res1 != res2 {
return res1 > res2
}
// Same resolution - prefer better codec
return videoCodecPriority(s1.videoCodec) > videoCodecPriority(s2.videoCodec)
}
if let bestVideo = sortedVideo.first {
// Select best audio stream based on preferred language, codec, and bitrate
let preferredAudioLanguage = settingsManager?.preferredAudioLanguage
let bestAudio = audioStreams
.sorted { stream1, stream2 in
// First priority: preferred language or original audio
if let preferred = preferredAudioLanguage {
// User selected a specific language
let lang1 = stream1.audioLanguage ?? ""
let lang2 = stream2.audioLanguage ?? ""
let matches1 = lang1.hasPrefix(preferred)
let matches2 = lang2.hasPrefix(preferred)
if matches1 != matches2 { return matches1 }
} else {
// No preference set - prefer original audio track
if stream1.isOriginalAudio != stream2.isOriginalAudio {
return stream1.isOriginalAudio
}
}
// Second priority: prefer Opus > AAC for MPV (better quality/compression)
let codecPriority1 = audioCodecPriority(stream1.audioCodec)
let codecPriority2 = audioCodecPriority(stream2.audioCodec)
if codecPriority1 != codecPriority2 {
return codecPriority1 > codecPriority2
}
// Third priority: higher bitrate
return (stream1.bitrate ?? 0) > (stream2.bitrate ?? 0)
}
.first
if let audio = bestAudio {
return (bestVideo, audio)
}
}
}
// Fallback to muxed streams - prefer progressive formats over HLS/DASH for non-live content
let filteredMuxed: [Stream]
if let maxRes = maxResolution {
filteredMuxed = muxedStreams.filter { stream in
guard let resolution = stream.resolution else { return true }
return resolution <= maxRes
}
} else {
filteredMuxed = muxedStreams
}
// Sort: prefer non-HLS/DASH (progressive) formats, then by resolution
let sortedMuxed = filteredMuxed.sorted { s1, s2 in
let format1 = StreamFormat.detect(from: s1)
let format2 = StreamFormat.detect(from: s2)
let isAdaptive1 = format1 == .hls || format1 == .dash
let isAdaptive2 = format2 == .hls || format2 == .dash
// Prefer progressive formats for non-live content
if isAdaptive1 != isAdaptive2 {
return !isAdaptive1 // non-adaptive (false) comes first
}
return (s1.resolution ?? .p360) > (s2.resolution ?? .p360)
}
if let bestMuxed = sortedMuxed.first {
return (bestMuxed, nil)
}
// Last resort: any muxed stream (HLS/DASH will be selected here if nothing else available)
if let anyMuxed = muxedStreams.sorted(by: { ($0.resolution ?? .p360) > ($1.resolution ?? .p360) }).first {
return (anyMuxed, nil)
}
return nil
}
/// Returns codec priority for video streams (higher = better for MPV).
/// AV1 > VP9 > H.264/AVC
private func videoCodecPriority(_ codec: String?) -> Int {
guard let codec = codec?.lowercased() else { return 0 }
if codec.contains("av1") || codec.contains("av01") {
return 3 // Best compression, modern codec
} else if codec.contains("vp9") || codec.contains("vp09") {
return 2 // Good compression, widely supported
} else if codec.contains("avc") || codec.contains("h264") || codec.contains("h.264") {
return 1 // Most compatible, less efficient
}
return 0
}
/// Returns codec priority for audio streams (higher = better for MPV).
/// Opus > AAC
private func audioCodecPriority(_ codec: String?) -> Int {
guard let codec = codec?.lowercased() else { return 0 }
if codec.contains("opus") {
return 2 // Best quality/compression ratio
} else if codec.contains("aac") || codec.contains("mp4a") {
return 1 // Good compatibility
}
return 0
}
/// Perform visual transition between backends.
private func performTransition(
from source: any PlayerBackend,
to target: any PlayerBackend,
animation: BackendSwitchAnimation
) async {
guard animation != .instant else { return }
#if canImport(UIKit)
guard let sourceView = source.playerView,
let targetView = target.playerView,
let containerView = sourceView.superview else {
return
}
// Add target view behind source
targetView.frame = containerView.bounds
targetView.alpha = 0
containerView.insertSubview(targetView, belowSubview: sourceView)
// Animate transition
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
UIView.animate(withDuration: animation.duration, animations: {
switch animation {
case .crossfade:
sourceView.alpha = 0
targetView.alpha = 1
case .slide:
sourceView.transform = CGAffineTransform(translationX: -sourceView.bounds.width, y: 0)
targetView.alpha = 1
case .instant:
break
}
}, completion: { _ in
sourceView.removeFromSuperview()
continuation.resume()
})
}
#elseif canImport(AppKit)
guard let sourceView = source.playerView,
let targetView = target.playerView,
let containerView = sourceView.superview else {
return
}
// Add target view
targetView.frame = containerView.bounds
targetView.alphaValue = 0
containerView.addSubview(targetView, positioned: .below, relativeTo: sourceView)
// Animate transition
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
NSAnimationContext.runAnimationGroup({ context in
context.duration = animation.duration
sourceView.animator().alphaValue = 0
targetView.animator().alphaValue = 1
}, completionHandler: {
sourceView.removeFromSuperview()
continuation.resume()
})
}
#endif
}
}
// MARK: - Backend Factory
/// Factory for creating player backend instances with pre-warming pool.
@MainActor
final class BackendFactory {
/// Pool of pre-warmed backends ready for instant playback (1 per type)
private var backendPool: [PlayerBackendType: any PlayerBackend] = [:]
/// Statistics for monitoring pool efficiency
private var poolHits = 0
private var poolMisses = 0
/// Create or retrieve a pre-warmed backend.
func createBackend(type: PlayerBackendType) throws -> any PlayerBackend {
// Try to get from pool first
if let backend = backendPool[type] {
backendPool[type] = nil // Remove from pool
poolHits += 1
LoggingService.shared.debug("BackendFactory: pool hit for \(type.displayName) (hits=\(poolHits), misses=\(poolMisses))", category: .mpv)
// Immediately start warming a replacement in background
Task {
await prewarmBackend(type: type)
}
return backend
}
poolMisses += 1
LoggingService.shared.debug("BackendFactory: pool miss for \(type.displayName) (hits=\(poolHits), misses=\(poolMisses))", category: .mpv)
// Create new backend and begin setup
let backend = createBackendInstance(type: type)
if let mpvBackend = backend as? MPVBackend {
mpvBackend.beginSetup()
}
return backend
}
/// Create a backend instance (without pool).
private func createBackendInstance(type: PlayerBackendType) -> any PlayerBackend {
switch type {
case .mpv:
return MPVBackend()
}
}
/// Pre-warm a backend and add to pool.
func prewarmBackend(type: PlayerBackendType) async {
let startTime = Date()
LoggingService.shared.debug("BackendFactory: pre-warming \(type.displayName)", category: .mpv)
let backend = await MainActor.run {
createBackendInstance(type: type)
}
// Begin async setup
if let mpvBackend = backend as? MPVBackend {
await MainActor.run {
mpvBackend.beginSetup()
}
// Wait for setup to complete
do {
try await mpvBackend.waitForSetup()
} catch {
LoggingService.shared.debug("BackendFactory: pre-warm failed for \(type.displayName): \(error)", category: .mpv)
return
}
}
let duration = Date().timeIntervalSince(startTime)
LoggingService.shared.debug("BackendFactory: \(type.displayName) pre-warmed in \(String(format: "%.3f", duration))s", category: .mpv)
// Add to pool (only if slot is empty - don't accumulate)
await MainActor.run {
if backendPool[type] == nil {
backendPool[type] = backend
LoggingService.shared.debug("BackendFactory: \(type.displayName) added to pool", category: .mpv)
} else {
LoggingService.shared.debug("BackendFactory: \(type.displayName) pool already full, discarding", category: .mpv)
}
}
}
/// Pre-warm all available backends in parallel.
func prewarmAllBackends() async {
let startTime = Date()
LoggingService.shared.debug("BackendFactory: pre-warming all backends", category: .mpv)
// Pre-warm in parallel
await withTaskGroup(of: Void.self) { group in
for type in availableBackends {
group.addTask {
await self.prewarmBackend(type: type)
}
}
}
let duration = Date().timeIntervalSince(startTime)
LoggingService.shared.debug("BackendFactory: all backends pre-warmed in \(String(format: "%.3f", duration))s", category: .mpv)
}
/// Drain the pool (called on memory warning).
func drainPool() {
let count = backendPool.count
backendPool.removeAll()
LoggingService.shared.debug("BackendFactory: pool drained (\(count) backends released)", category: .mpv)
}
/// Check if a backend type is available on this platform.
func isAvailable(_ type: PlayerBackendType) -> Bool {
switch type {
case .mpv:
return true
}
}
/// Get all available backend types.
var availableBackends: [PlayerBackendType] {
PlayerBackendType.allCases.filter { isAvailable($0) }
}
}

View File

@@ -0,0 +1,292 @@
//
// DeArrowAPI.swift
// Yattee
//
// DeArrow API client for fetching community-submitted titles and thumbnails.
//
import Foundation
// MARK: - Response Models
/// DeArrow branding data for a video.
struct DeArrowBranding: Codable, Sendable {
let titles: [DeArrowTitle]
let thumbnails: [DeArrowThumbnail]
let randomTime: Double?
let videoDuration: Double?
/// Returns the best title (first non-original with positive votes, or first locked).
var bestTitle: String? {
// Prefer locked titles, then highest voted non-original
if let locked = titles.first(where: { $0.locked && !$0.original }) {
return locked.title
}
if let best = titles.first(where: { !$0.original && $0.votes >= 0 }) {
return best.title
}
return nil
}
/// Returns the best thumbnail timestamp.
var bestThumbnailTimestamp: Double? {
// Prefer locked thumbnails, then highest voted non-original
if let locked = thumbnails.first(where: { $0.locked && !$0.original }) {
return locked.timestamp
}
if let best = thumbnails.first(where: { !$0.original && $0.votes >= 0 }) {
return best.timestamp
}
// Fall back to random time if available
return randomTime
}
}
/// A community-submitted title.
struct DeArrowTitle: Codable, Sendable {
let title: String
let original: Bool
let votes: Int
let locked: Bool
let UUID: String?
enum CodingKeys: String, CodingKey {
case title, original, votes, locked, UUID
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.title = try container.decode(String.self, forKey: .title)
self.original = try container.decodeIfPresent(Bool.self, forKey: .original) ?? false
self.votes = try container.decodeIfPresent(Int.self, forKey: .votes) ?? 0
self.locked = try container.decodeIfPresent(Bool.self, forKey: .locked) ?? false
self.UUID = try container.decodeIfPresent(String.self, forKey: .UUID)
}
}
/// A community-submitted thumbnail timestamp.
struct DeArrowThumbnail: Codable, Sendable {
let timestamp: Double?
let original: Bool
let votes: Int
let locked: Bool
let UUID: String?
enum CodingKeys: String, CodingKey {
case timestamp, original, votes, locked, UUID
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.timestamp = try container.decodeIfPresent(Double.self, forKey: .timestamp)
self.original = try container.decodeIfPresent(Bool.self, forKey: .original) ?? false
self.votes = try container.decodeIfPresent(Int.self, forKey: .votes) ?? 0
self.locked = try container.decodeIfPresent(Bool.self, forKey: .locked) ?? false
self.UUID = try container.decodeIfPresent(String.self, forKey: .UUID)
}
}
// MARK: - DeArrow API
/// DeArrow API client for fetching community-submitted video branding.
actor DeArrowAPI {
private let httpClient: HTTPClient
private let urlSession: URLSession
/// Cache for branding data by video ID.
private var cache: [String: DeArrowBranding] = [:]
/// Set of video IDs that returned 404 (no branding available).
private var notFoundCache: Set<String> = []
/// Maximum cache size before cleanup.
private let maxCacheSize = 500
/// Default DeArrow API URL.
private static let defaultAPIURL = URL(string: "https://sponsor.ajay.app")!
/// Default DeArrow thumbnail generation service URL.
private static let defaultThumbnailURL = URL(string: "https://dearrow-thumb.ajay.app")!
/// DeArrow API base URL.
private var baseURL: URL
/// DeArrow thumbnail generation service URL.
private var thumbnailBaseURL: URL
init(httpClient: HTTPClient, urlSession: URLSession = .shared, baseURL: URL? = nil, thumbnailBaseURL: URL? = nil) {
self.httpClient = httpClient
self.urlSession = urlSession
self.baseURL = baseURL ?? Self.defaultAPIURL
self.thumbnailBaseURL = thumbnailBaseURL ?? Self.defaultThumbnailURL
}
/// Updates the base URL for API requests.
/// Clears the cache when URL changes.
func setBaseURL(_ url: URL) {
if baseURL != url {
baseURL = url
cache.removeAll()
notFoundCache.removeAll()
}
}
/// Updates the thumbnail base URL for thumbnail requests.
func setThumbnailBaseURL(_ url: URL) {
if thumbnailBaseURL != url {
thumbnailBaseURL = url
}
}
/// Returns the current thumbnail base URL.
nonisolated func currentThumbnailBaseURL() -> URL {
// Note: This returns the default URL when called from nonisolated context.
// For dynamic URL access, use the async version.
Self.defaultThumbnailURL
}
/// Returns the current thumbnail base URL (async version for isolation).
func getThumbnailBaseURL() -> URL {
thumbnailBaseURL
}
/// Fetches branding data for a YouTube video.
/// - Parameter videoID: The YouTube video ID.
/// - Returns: The branding data, or nil if not available.
func branding(for videoID: String) async throws -> DeArrowBranding? {
// Check not-found cache first
if notFoundCache.contains(videoID) {
return nil
}
// Check cache
if let cached = cache[videoID] {
return cached
}
var components = URLComponents(url: baseURL.appendingPathComponent("/api/branding"), resolvingAgainstBaseURL: false)!
components.queryItems = [
URLQueryItem(name: "videoID", value: videoID)
]
guard let url = components.url else {
throw APIError.invalidRequest
}
var request = URLRequest(url: url)
request.timeoutInterval = 5 // Short timeout for performance
do {
let data = try await httpClient.performRaw(request)
let decoder = JSONDecoder()
let branding = try decoder.decode(DeArrowBranding.self, from: data)
// Cache the result
cacheResult(branding, for: videoID)
Task { @MainActor in
LoggingService.shared.logPlayer("DeArrow: fetched branding", details: "Video: \(videoID), Title: \(branding.bestTitle ?? "none")")
}
return branding
} catch let error as DecodingError {
Task { @MainActor in
LoggingService.shared.logPlayerError("DeArrow decode error", error: error)
}
throw APIError.decodingError(error)
} catch let error as APIError {
if case .notFound = error {
// Cache the 404 to avoid repeated requests
notFoundCache.insert(videoID)
return nil
}
throw error
}
}
/// Generates a thumbnail URL for a video at a specific timestamp.
/// - Parameters:
/// - videoID: The YouTube video ID.
/// - timestamp: The timestamp in seconds (optional - omit for cached thumbnail).
/// - Returns: The thumbnail URL.
func thumbnailURL(for videoID: String, timestamp: Double? = nil) -> URL {
var components = URLComponents(url: thumbnailBaseURL, resolvingAgainstBaseURL: false)!
components.path = "/api/v1/getThumbnail"
var queryItems = [URLQueryItem(name: "videoID", value: videoID)]
if let timestamp {
queryItems.append(URLQueryItem(name: "time", value: String(format: "%.2f", timestamp)))
}
components.queryItems = queryItems
return components.url!
}
/// Generates a thumbnail URL using the default thumbnail base URL.
/// Use this when you need a URL synchronously without actor isolation.
nonisolated static func defaultThumbnailURL(for videoID: String, timestamp: Double? = nil) -> URL {
var components = URLComponents(url: defaultThumbnailURL, resolvingAgainstBaseURL: false)!
components.path = "/api/v1/getThumbnail"
var queryItems = [URLQueryItem(name: "videoID", value: videoID)]
if let timestamp {
queryItems.append(URLQueryItem(name: "time", value: String(format: "%.2f", timestamp)))
}
components.queryItems = queryItems
return components.url!
}
/// Result of fetching a thumbnail with timestamp verification.
struct ThumbnailFetchResult: Sendable {
let imageData: Data?
let serverTimestamp: Double?
let url: URL
}
/// Fetches a thumbnail, optionally without specifying time to get cached version.
/// - Parameters:
/// - videoID: The YouTube video ID.
/// - timestamp: The timestamp in seconds (optional).
/// - Returns: The fetch result including server's X-Timestamp header.
func fetchThumbnail(for videoID: String, timestamp: Double? = nil) async -> ThumbnailFetchResult {
let url = thumbnailURL(for: videoID, timestamp: timestamp)
var request = URLRequest(url: url)
request.timeoutInterval = 5
do {
let (data, response) = try await urlSession.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
return ThumbnailFetchResult(imageData: nil, serverTimestamp: nil, url: url)
}
// Extract X-Timestamp header
let serverTimestamp: Double?
if let timestampHeader = httpResponse.value(forHTTPHeaderField: "X-Timestamp") {
serverTimestamp = Double(timestampHeader)
} else {
serverTimestamp = nil
}
return ThumbnailFetchResult(imageData: data, serverTimestamp: serverTimestamp, url: url)
} catch {
return ThumbnailFetchResult(imageData: nil, serverTimestamp: nil, url: url)
}
}
/// Clears all cached data.
func clearCache() {
cache.removeAll()
notFoundCache.removeAll()
}
// MARK: - Private
private func cacheResult(_ branding: DeArrowBranding, for videoID: String) {
// Simple LRU: if cache is full, remove oldest entries
if cache.count >= maxCacheSize {
let keysToRemove = Array(cache.keys.prefix(maxCacheSize / 4))
for key in keysToRemove {
cache.removeValue(forKey: key)
}
}
cache[videoID] = branding
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,290 @@
//
// MPVLogging.swift
// Yattee
//
// Centralized MPV rendering diagnostic logging.
// Logs to Console (print) AND LoggingService for persistence.
//
import Foundation
#if os(iOS) || os(tvOS)
import OpenGLES
#elseif os(macOS)
import OpenGL
#endif
/// Centralized MPV rendering diagnostic logging.
/// Use this to diagnose rare rendering issues (black/green screen while audio plays).
enum MPVLogging {
// MARK: - Setting Check
/// Thread-safe cached check for verbose logging setting.
/// Uses atomic operations for thread safety without locks.
private static var _cachedIsEnabled: Bool = false
private static var _lastCheckTime: UInt64 = 0
private static let cacheDurationNanos: UInt64 = 1_000_000_000 // 1 second
/// Check if verbose logging is enabled (cached for performance).
/// Safe to call from any thread.
private static func isEnabled() -> Bool {
let now = DispatchTime.now().uptimeNanoseconds
// Refresh cache every second
if now - _lastCheckTime > cacheDurationNanos {
_lastCheckTime = now
// Read from UserDefaults directly for thread safety
// (SettingsManager is @MainActor)
_cachedIsEnabled = UserDefaults.standard.bool(forKey: "verboseMPVLogging")
}
return _cachedIsEnabled
}
// MARK: - Logging Functions
/// Log a verbose MPV rendering diagnostic message.
/// Only logs if verbose MPV logging is enabled in settings.
/// Thread-safe and can be called from any queue.
///
/// - Parameters:
/// - message: The main log message
/// - details: Optional additional details
/// - file: Source file (auto-captured)
/// - function: Function name (auto-captured)
/// - line: Line number (auto-captured)
static func log(
_ message: String,
details: String? = nil,
file: String = #file,
function: String = #function,
line: Int = #line
) {
guard isEnabled() else { return }
let timestamp = Self.timestamp()
let threadName = Self.threadName()
let fileName = (file as NSString).lastPathComponent
let fullMessage = "[MPV-Verbose] [\(timestamp)] [\(threadName)] \(message)"
// Log to Console immediately (thread-safe)
print(fullMessage)
if let details {
print(" \(details)")
}
print(" [\(fileName):\(line) \(function)]")
// Log to LoggingService on MainActor for persistence
let logDetails = details.map { "\($0)\n[\(fileName):\(line) \(function)]" }
?? "[\(fileName):\(line) \(function)]"
Task { @MainActor in
LoggingService.shared.log(
level: .debug,
category: .mpv,
message: "[MPV-Verbose] \(message)",
details: logDetails
)
}
}
/// Log with warning level for potential issues.
static func warn(
_ message: String,
details: String? = nil,
file: String = #file,
function: String = #function,
line: Int = #line
) {
guard isEnabled() else { return }
let timestamp = Self.timestamp()
let threadName = Self.threadName()
let fileName = (file as NSString).lastPathComponent
let fullMessage = "[MPV-Verbose] ⚠️ \(timestamp)] [\(threadName)] \(message)"
print(fullMessage)
if let details {
print(" \(details)")
}
print(" [\(fileName):\(line) \(function)]")
let logDetails = details.map { "\($0)\n[\(fileName):\(line) \(function)]" }
?? "[\(fileName):\(line) \(function)]"
Task { @MainActor in
LoggingService.shared.log(
level: .warning,
category: .mpv,
message: "[MPV-Verbose] \(message)",
details: logDetails
)
}
}
/// Log OpenGL/EAGL state for debugging context and framebuffer issues.
///
/// - Parameters:
/// - prefix: Description of the operation (e.g., "createFramebuffer")
/// - framebuffer: The framebuffer ID
/// - renderbuffer: The renderbuffer ID
/// - width: Framebuffer width
/// - height: Framebuffer height
/// - contextCurrent: Whether the GL context is current
/// - framebufferComplete: Whether the framebuffer is complete (nil if not checked)
static func logGLState(
_ prefix: String,
framebuffer: UInt32,
renderbuffer: UInt32,
width: Int32,
height: Int32,
contextCurrent: Bool,
framebufferComplete: Bool? = nil
) {
var state = "FB:\(framebuffer) RB:\(renderbuffer) \(width)x\(height) ctx:\(contextCurrent ? "" : "")"
if let complete = framebufferComplete {
state += " complete:\(complete ? "" : "")"
}
log("\(prefix): \(state)")
}
/// Log display link state changes.
///
/// - Parameters:
/// - action: The action being performed (e.g., "start", "stop", "pause")
/// - isPaused: Current paused state
/// - targetFPS: Target frame rate if applicable
/// - reason: Optional reason for the action
static func logDisplayLink(
_ action: String,
isPaused: Bool? = nil,
targetFPS: Double? = nil,
reason: String? = nil
) {
var details: [String] = []
if let isPaused {
details.append("paused:\(isPaused)")
}
if let targetFPS {
details.append("targetFPS:\(String(format: "%.1f", targetFPS))")
}
if let reason {
details.append("reason:\(reason)")
}
let detailsStr = details.isEmpty ? nil : details.joined(separator: " ")
log("DisplayLink \(action)", details: detailsStr)
}
/// Log view lifecycle events.
///
/// - Parameters:
/// - event: The lifecycle event (e.g., "willMove(toSuperview:)", "didMoveToSuperview")
/// - hasSuperview: Whether the view has a superview after the event
/// - details: Additional context
static func logViewLifecycle(
_ event: String,
hasSuperview: Bool,
details: String? = nil
) {
log("View \(event)", details: "hasSuperview:\(hasSuperview)" + (details.map { " \($0)" } ?? ""))
}
/// Log app lifecycle / scene phase transitions.
///
/// - Parameters:
/// - event: The lifecycle event
/// - isPiPActive: Whether PiP is currently active
/// - isRendering: Whether rendering is active
static func logAppLifecycle(
_ event: String,
isPiPActive: Bool? = nil,
isRendering: Bool? = nil
) {
var details: [String] = []
if let isPiPActive {
details.append("pip:\(isPiPActive)")
}
if let isRendering {
details.append("rendering:\(isRendering)")
}
let detailsStr = details.isEmpty ? nil : details.joined(separator: " ")
log("App \(event)", details: detailsStr)
}
/// Log rotation and fullscreen transitions.
///
/// - Parameters:
/// - event: The transition event
/// - fromOrientation: Previous orientation if applicable
/// - toOrientation: Target orientation if applicable
static func logTransition(
_ event: String,
fromSize: CGSize? = nil,
toSize: CGSize? = nil
) {
var details: [String] = []
if let fromSize {
details.append("from:\(Int(fromSize.width))x\(Int(fromSize.height))")
}
if let toSize {
details.append("to:\(Int(toSize.width))x\(Int(toSize.height))")
}
let detailsStr = details.isEmpty ? nil : details.joined(separator: " ")
log("Transition \(event)", details: detailsStr)
}
/// Log render operations (use sparingly to avoid log spam).
///
/// - Parameters:
/// - event: The render event
/// - fbo: Framebuffer being rendered to
/// - width: Render width
/// - height: Render height
/// - success: Whether the operation succeeded
static func logRender(
_ event: String,
fbo: Int32? = nil,
width: Int32? = nil,
height: Int32? = nil,
success: Bool? = nil
) {
var details: [String] = []
if let fbo {
details.append("fbo:\(fbo)")
}
if let width, let height {
details.append("\(width)x\(height)")
}
if let success {
details.append(success ? "" : "")
}
let detailsStr = details.isEmpty ? nil : details.joined(separator: " ")
log("Render \(event)", details: detailsStr)
}
// MARK: - Private Helpers
private static func timestamp() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss.SSS"
return formatter.string(from: Date())
}
private static func threadName() -> String {
if Thread.isMainThread {
return "main"
}
if let name = Thread.current.name, !name.isEmpty {
return name
}
// Get queue label if available
let label = String(cString: __dispatch_queue_get_label(nil), encoding: .utf8) ?? "unknown"
return label
}
}

View File

@@ -0,0 +1,293 @@
//
// MPVOGLView.swift
// Yattee
//
// NSView that hosts MPVOpenGLLayer and manages CADisplayLink for macOS.
// Moves all rendering off the main thread for smooth UI during video playback.
//
#if os(macOS)
import AppKit
import CoreMedia
import CoreVideo
import Libmpv
// MARK: - MPVOGLView
/// View for MPV video rendering on macOS.
/// Hosts an MPVOpenGLLayer and manages CADisplayLink for vsync timing.
final class MPVOGLView: NSView {
// MARK: - Properties
/// The OpenGL layer that handles rendering.
private(set) lazy var videoLayer: MPVOpenGLLayer = {
MPVOpenGLLayer(videoView: self)
}()
/// Reference to the MPV client.
private weak var mpvClient: MPVClient?
/// CADisplayLink for frame timing and vsync (macOS 14+).
private var displayLink: CADisplayLink?
/// Whether the view has been uninitialized.
private var isUninited = false
/// Lock for thread-safe access to isUninited.
private let uninitLock = NSLock()
// MARK: - First Frame Tracking
/// Tracks whether MPV has signaled it has a frame ready to render.
var mpvHasFrameReady = false
/// Callback when first frame is rendered.
var onFirstFrameRendered: (() -> Void)? {
get { videoLayer.onFirstFrameRendered }
set { videoLayer.onFirstFrameRendered = newValue }
}
// MARK: - Video Info
/// Video frame rate from MPV (for debug overlay).
var videoFPS: Double = 60.0
/// Actual display link frame rate.
var displayLinkActualFPS: Double = 60.0
/// Current display link target frame rate (for debug overlay).
var displayLinkTargetFPS: Double {
displayLinkActualFPS
}
// MARK: - PiP Properties (forwarded to layer)
/// Whether to capture frames for PiP.
var captureFramesForPiP: Bool {
get { videoLayer.captureFramesForPiP }
set { videoLayer.captureFramesForPiP = newValue }
}
/// Whether PiP is currently active.
var isPiPActive: Bool {
get { videoLayer.isPiPActive }
set { videoLayer.isPiPActive = newValue }
}
/// Callback when a new frame is ready for PiP.
var onFrameReady: ((CVPixelBuffer, CMTime) -> Void)? {
get { videoLayer.onFrameReady }
set { videoLayer.onFrameReady = newValue }
}
/// Video content width (actual video dimensions for letterbox cropping).
var videoContentWidth: Int {
get { videoLayer.videoContentWidth }
set { videoLayer.videoContentWidth = newValue }
}
/// Video content height (actual video dimensions for letterbox cropping).
var videoContentHeight: Int {
get { videoLayer.videoContentHeight }
set { videoLayer.videoContentHeight = newValue }
}
// MARK: - Initialization
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
/// Convenience initializer with zero frame.
convenience init() {
self.init(frame: .zero)
}
private func commonInit() {
// Set up layer-backed view
wantsLayer = true
layer = videoLayer
// Configure layer properties
videoLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
// Configure view properties
autoresizingMask = [.width, .height]
}
deinit {
uninit()
}
// MARK: - Setup
/// Set up with an MPV client.
func setup(with client: MPVClient) throws {
self.mpvClient = client
// Set up the layer
try videoLayer.setup(with: client)
// Start display link
startDisplayLink()
}
/// Async setup variant.
func setupAsync(with client: MPVClient) async throws {
try setup(with: client)
}
// MARK: - View Lifecycle
override var isOpaque: Bool { true }
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if let window {
// Recreate display link for new window
stopDisplayLink()
startDisplayLink()
// Update contents scale for new window
videoLayer.contentsScale = window.backingScaleFactor
}
}
override func viewDidChangeBackingProperties() {
super.viewDidChangeBackingProperties()
// Update contents scale when backing properties change
if let scale = window?.backingScaleFactor {
videoLayer.contentsScale = scale
}
// Update display refresh rate
updateDisplayRefreshRate()
}
override func draw(_ dirtyRect: NSRect) {
// No-op - the layer handles all drawing
}
// MARK: - CADisplayLink Management
func startDisplayLink() {
guard displayLink == nil else { return }
// Create display link using modern API (macOS 14+)
displayLink = displayLink(target: self, selector: #selector(displayLinkFired(_:)))
displayLink?.add(to: .main, forMode: .common)
// Update refresh rate info
updateDisplayRefreshRate()
LoggingService.shared.debug("MPVOGLView: display link started", category: .mpv)
}
func stopDisplayLink() {
displayLink?.invalidate()
displayLink = nil
LoggingService.shared.debug("MPVOGLView: display link stopped", category: .mpv)
}
@objc private func displayLinkFired(_ sender: CADisplayLink) {
// Check if uninited (thread-safe)
uninitLock.lock()
let uninited = isUninited
uninitLock.unlock()
guard !uninited else { return }
// Report frame swap to MPV for vsync timing
mpvClient?.reportSwap()
}
/// Update display link for the current display.
func updateDisplayLink() {
// With CADisplayLink, we just need to update the refresh rate info
updateDisplayRefreshRate()
}
/// Update the cached display refresh rate.
private func updateDisplayRefreshRate() {
guard let screen = window?.screen else {
displayLinkActualFPS = 60.0
return
}
// Get refresh rate from screen
displayLinkActualFPS = Double(screen.maximumFramesPerSecond)
if displayLinkActualFPS <= 0 {
displayLinkActualFPS = 60.0
}
LoggingService.shared.debug("MPVOGLView: display refresh rate: \(displayLinkActualFPS) Hz", category: .mpv)
}
// MARK: - Public Methods
/// Reset first frame tracking (call when loading new content).
func resetFirstFrameTracking() {
mpvHasFrameReady = false
videoLayer.resetFirstFrameTracking()
}
/// Clear the view to black.
func clearToBlack() {
videoLayer.clearToBlack()
}
/// Pause rendering.
func pauseRendering() {
// For now, just stop triggering updates
// The layer will still respond to explicit update() calls
}
/// Resume rendering.
func resumeRendering() {
videoLayer.update(force: true)
}
/// Update cached time position for PiP timestamps.
func updateTimePosition(_ time: Double) {
videoLayer.updateTimePosition(time)
}
/// Clear the main view for PiP transition (stub for now).
func clearMainViewForPiP() {
clearToBlack()
}
/// Update PiP target render size - forces recreation of PiP capture resources.
func updatePiPTargetSize(_ size: CMVideoDimensions) {
videoLayer.updatePiPTargetSize(size)
}
// MARK: - Cleanup
/// Uninitialize the view and release resources.
func uninit() {
uninitLock.lock()
defer { uninitLock.unlock() }
guard !isUninited else { return }
isUninited = true
stopDisplayLink()
videoLayer.uninit()
// Note: onFirstFrameRendered and onFrameReady are forwarded to videoLayer,
// and videoLayer.uninit() clears them
}
}
#endif

View File

@@ -0,0 +1,862 @@
//
// MPVOpenGLLayer.swift
// Yattee
//
// CAOpenGLLayer subclass for MPV rendering on macOS.
// Renders on a background thread to avoid blocking the main thread.
//
#if os(macOS)
import AppKit
import OpenGL.GL
import OpenGL.GL3
import Libmpv
import CoreMedia
import CoreVideo
// MARK: - OpenGL Pixel Format Attributes
private let glFormatBase: [CGLPixelFormatAttribute] = [
kCGLPFAOpenGLProfile,
CGLPixelFormatAttribute(kCGLOGLPVersion_3_2_Core.rawValue),
kCGLPFAAccelerated,
kCGLPFADoubleBuffer,
kCGLPFAAllowOfflineRenderers,
CGLPixelFormatAttribute(0)
]
private let glFormat10Bit: [CGLPixelFormatAttribute] = [
kCGLPFAOpenGLProfile,
CGLPixelFormatAttribute(kCGLOGLPVersion_3_2_Core.rawValue),
kCGLPFAAccelerated,
kCGLPFADoubleBuffer,
kCGLPFAAllowOfflineRenderers,
kCGLPFAColorSize,
CGLPixelFormatAttribute(64),
kCGLPFAColorFloat,
CGLPixelFormatAttribute(0)
]
// MARK: - MPVOpenGLLayer
/// OpenGL layer for MPV rendering on macOS.
/// Renders on a background thread to avoid blocking the main thread during video playback.
final class MPVOpenGLLayer: CAOpenGLLayer {
// MARK: - Properties
/// Reference to the video view that hosts this layer.
private weak var videoView: MPVOGLView?
/// Reference to the MPV client for rendering.
private weak var mpvClient: MPVClient?
/// Dedicated queue for OpenGL rendering (off main thread).
private let renderQueue = DispatchQueue(label: "stream.yattee.mpv.render", qos: .userInteractive)
/// CGL context for OpenGL rendering.
private let cglContext: CGLContextObj
/// CGL pixel format used to create the context.
private let cglPixelFormat: CGLPixelFormatObj
/// Lock to single-thread calls to `display`.
private let displayLock = NSRecursiveLock()
/// Buffer depth (8 for standard, 16 for 10-bit).
private var bufferDepth: GLint = 8
/// Current framebuffer object ID.
private var fbo: GLint = 1
/// When `true` the frame needs to be rendered.
private var needsFlip = false
private let needsFlipLock = NSLock()
/// When `true` drawing will proceed even if mpv indicates nothing needs to be done.
private var forceDraw = false
private let forceDrawLock = NSLock()
/// Whether the layer has been set up with an MPV client.
private var isSetup = false
/// Whether the layer is being cleaned up.
private var isUninited = false
/// Tracks whether first frame has been rendered.
private var hasRenderedFirstFrame = false
/// Callback when first frame is rendered.
var onFirstFrameRendered: (() -> Void)?
// MARK: - PiP Capture Properties
/// Zero-copy texture cache for efficient PiP capture.
private var textureCache: CVOpenGLTextureCache?
/// Framebuffer for PiP capture.
private var pipFramebuffer: GLuint = 0
/// Texture from CVOpenGLTextureCache (bound to pixel buffer).
private var pipTexture: CVOpenGLTexture?
/// Pixel buffer for PiP capture (IOSurface-backed for zero-copy).
private var pipPixelBuffer: CVPixelBuffer?
/// Current PiP capture dimensions.
private var pipCaptureWidth: Int = 0
private var pipCaptureHeight: Int = 0
/// Offscreen render FBO for PiP mode (when layer isn't visible).
private var pipRenderFBO: GLuint = 0
/// Render texture for PiP mode FBO.
private var pipRenderTexture: GLuint = 0
/// Dimensions of the PiP render FBO.
private var pipRenderWidth: Int = 0
private var pipRenderHeight: Int = 0
/// Whether to capture frames for PiP.
var captureFramesForPiP = false
/// Whether PiP is currently active.
var isPiPActive = false
/// Callback when a frame is ready for PiP.
var onFrameReady: ((CVPixelBuffer, CMTime) -> Void)?
/// Video content width (actual video, not view size) - for letterbox/pillarbox cropping.
var videoContentWidth: Int = 0
/// Video content height (actual video, not view size) - for letterbox/pillarbox cropping.
var videoContentHeight: Int = 0
/// Cached time position for PiP presentation timestamps.
private var cachedTimePos: Double = 0
/// Frame counter for PiP logging.
private var pipFrameCount: UInt64 = 0
// MARK: - Initialization
/// Creates an MPVOpenGLLayer for the given video view.
init(videoView: MPVOGLView) {
self.videoView = videoView
// Create pixel format (try 10-bit first, fall back to 8-bit)
let (pixelFormat, depth) = MPVOpenGLLayer.createPixelFormat()
self.cglPixelFormat = pixelFormat
self.bufferDepth = depth
// Create OpenGL context
self.cglContext = MPVOpenGLLayer.createContext(pixelFormat: pixelFormat)
super.init()
// Configure layer
autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
backgroundColor = NSColor.black.cgColor
isOpaque = true
// Set color space to device RGB (sRGB) to prevent color space conversion issues
// Without this, macOS may apply unwanted gamma/color transformations
colorspace = CGColorSpaceCreateDeviceRGB()
// Use appropriate contents format for bit depth
if bufferDepth > 8 {
contentsFormat = .RGBA16Float
}
// Start with synchronous drawing disabled (we control updates via renderQueue)
isAsynchronous = false
let colorDepth = bufferDepth
Task { @MainActor in
LoggingService.shared.debug("MPVOpenGLLayer: initialized with \(colorDepth)-bit color", category: .mpv)
}
}
/// Creates a shadow copy of the layer (called by Core Animation during scale changes).
override init(layer: Any) {
let previousLayer = layer as! MPVOpenGLLayer
self.videoView = previousLayer.videoView
self.mpvClient = previousLayer.mpvClient
self.cglPixelFormat = previousLayer.cglPixelFormat
self.cglContext = previousLayer.cglContext
self.bufferDepth = previousLayer.bufferDepth
self.isSetup = previousLayer.isSetup
super.init(layer: layer)
autoresizingMask = previousLayer.autoresizingMask
backgroundColor = previousLayer.backgroundColor
isOpaque = previousLayer.isOpaque
colorspace = previousLayer.colorspace
contentsFormat = previousLayer.contentsFormat
isAsynchronous = previousLayer.isAsynchronous
Task { @MainActor in
LoggingService.shared.debug("MPVOpenGLLayer: created shadow copy", category: .mpv)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
uninit()
}
// MARK: - Setup
/// Set up the layer with an MPV client.
func setup(with client: MPVClient) throws {
guard !isSetup else {
Task { @MainActor in
LoggingService.shared.debug("MPVOpenGLLayer: already set up", category: .mpv)
}
return
}
self.mpvClient = client
// Make context current for render context creation
CGLSetCurrentContext(cglContext)
// Create MPV render context
let success = client.createRenderContext(getProcAddress: macOSGetProcAddress)
guard success else {
Task { @MainActor in
LoggingService.shared.error("MPVOpenGLLayer: failed to create MPV render context", category: .mpv)
}
throw MPVRenderError.renderContextFailed(-1)
}
// Store CGL context in client for locking
client.setOpenGLContext(cglContext)
// Set up render update callback
client.onRenderUpdate = { [weak self] in
self?.update()
}
// Note: We don't set onVideoFrameReady here anymore.
// The mpvHasFrameReady flag is now set in draw() when we actually render a frame.
// This is more accurate and avoids the issue where the render callback
// was consuming the frame-ready flag before canDraw() could check it.
isSetup = true
Task { @MainActor in
LoggingService.shared.debug("MPVOpenGLLayer: setup complete", category: .mpv)
}
}
/// Clean up resources.
func uninit() {
guard !isUninited else { return }
isUninited = true
// Clean up PiP capture resources
destroyPiPCapture()
onFrameReady = nil
// Clear callbacks
mpvClient?.onRenderUpdate = nil
mpvClient?.onVideoFrameReady = nil
onFirstFrameRendered = nil
Task { @MainActor in
LoggingService.shared.debug("MPVOpenGLLayer: uninit complete", category: .mpv)
}
}
// MARK: - CAOpenGLLayer Overrides
override func canDraw(
inCGLContext ctx: CGLContextObj,
pixelFormat pf: CGLPixelFormatObj,
forLayerTime t: CFTimeInterval,
displayTime ts: UnsafePointer<CVTimeStamp>?
) -> Bool {
guard !isUninited, isSetup else { return false }
// Check if force draw is requested or MPV has a frame ready
let force = forceDrawLock.withLock { forceDraw }
if force { return true }
return mpvClient?.shouldRenderUpdateFrame() ?? false
}
override func draw(
inCGLContext ctx: CGLContextObj,
pixelFormat pf: CGLPixelFormatObj,
forLayerTime t: CFTimeInterval,
displayTime ts: UnsafePointer<CVTimeStamp>?
) {
guard !isUninited, isSetup, let mpvClient else { return }
// Reset flags
needsFlipLock.withLock { needsFlip = false }
forceDrawLock.withLock { forceDraw = false }
// Clear the buffer
glClear(GLbitfield(GL_COLOR_BUFFER_BIT))
// Get current FBO binding and viewport dimensions
var currentFBO: GLint = 0
glGetIntegerv(GLenum(GL_DRAW_FRAMEBUFFER_BINDING), &currentFBO)
var viewport: [GLint] = [0, 0, 0, 0]
glGetIntegerv(GLenum(GL_VIEWPORT), &viewport)
let width = viewport[2]
let height = viewport[3]
guard width > 0, height > 0 else { return }
// Use the detected FBO (or fallback to cached)
if currentFBO != 0 {
fbo = currentFBO
}
// Render the frame
mpvClient.renderWithDepth(
fbo: fbo,
width: width,
height: height,
depth: bufferDepth
)
glFlush()
// Capture frame for PiP if enabled
if captureFramesForPiP {
captureFrameForPiP(viewWidth: width, viewHeight: height, mainFBO: fbo)
}
// Mark that we've rendered a frame (for first-frame tracking)
if let videoView, !videoView.mpvHasFrameReady {
DispatchQueue.main.async { [weak self] in
self?.videoView?.mpvHasFrameReady = true
}
}
// Notify on first frame
if !hasRenderedFirstFrame {
hasRenderedFirstFrame = true
DispatchQueue.main.async { [weak self] in
self?.onFirstFrameRendered?()
}
}
}
override func copyCGLPixelFormat(forDisplayMask mask: UInt32) -> CGLPixelFormatObj {
cglPixelFormat
}
override func copyCGLContext(forPixelFormat pf: CGLPixelFormatObj) -> CGLContextObj {
cglContext
}
/// Trigger a display update (dispatched to render queue).
override func display() {
displayLock.lock()
defer { displayLock.unlock() }
let isUpdate = needsFlipLock.withLock { needsFlip }
if Thread.isMainThread {
super.display()
} else {
// When not on main thread, use explicit transaction
CATransaction.begin()
super.display()
CATransaction.commit()
}
// Flush any implicit transaction
CATransaction.flush()
// Handle cases where canDraw/draw weren't called by AppKit but MPV has frames ready.
// This can happen when the view is in another space or not visible.
// We need to tell MPV to skip rendering to prevent frame buildup.
let stillNeedsFlip = needsFlipLock.withLock { needsFlip }
guard isUpdate && stillNeedsFlip else { return }
// If we get here, display() was called but draw() wasn't invoked by AppKit.
// Need to do a skip render to keep MPV's frame queue moving.
guard let mpvClient, let renderContext = mpvClient.mpvRenderContext,
mpvClient.shouldRenderUpdateFrame() else { return }
// Must lock OpenGL context before calling mpv render functions
mpvClient.lockAndSetOpenGLContext()
defer { mpvClient.unlockOpenGLContext() }
var skip: CInt = 1
withUnsafeMutablePointer(to: &skip) { skipPtr in
var params: [mpv_render_param] = [
mpv_render_param(type: MPV_RENDER_PARAM_SKIP_RENDERING, data: skipPtr),
mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil)
]
_ = params.withUnsafeMutableBufferPointer { paramsPtr in
mpv_render_context_render(renderContext, paramsPtr.baseAddress)
}
}
}
// MARK: - Public Methods
/// Request a render update (called when MPV signals a new frame).
func update(force: Bool = false) {
renderQueue.async { [weak self] in
guard let self, !self.isUninited else { return }
if force {
self.forceDrawLock.withLock { self.forceDraw = true }
}
self.needsFlipLock.withLock { self.needsFlip = true }
// When PiP is active, the layer may not be visible so CAOpenGLLayer.draw()
// won't be called by Core Animation. We need to manually render and capture
// frames for PiP.
if self.isPiPActive && self.captureFramesForPiP {
self.renderForPiP()
} else {
self.display()
}
}
}
/// Render a frame specifically for PiP capture (when main view is hidden).
private func renderForPiP() {
guard !isUninited, isSetup, let mpvClient else { return }
guard mpvClient.shouldRenderUpdateFrame() else { return }
// Lock and set OpenGL context
CGLLockContext(cglContext)
CGLSetCurrentContext(cglContext)
defer { CGLUnlockContext(cglContext) }
// Use video dimensions for render size, or fall back to reasonable defaults
let width = GLint(videoContentWidth > 0 ? videoContentWidth : 1920)
let height = GLint(videoContentHeight > 0 ? videoContentHeight : 1080)
guard width > 0, height > 0 else { return }
// Set up offscreen render FBO if needed
setupPiPRenderFBO(width: Int(width), height: Int(height))
guard pipRenderFBO != 0 else { return }
// Bind our render FBO
glBindFramebuffer(GLenum(GL_FRAMEBUFFER), pipRenderFBO)
glViewport(0, 0, width, height)
// Render the frame to our FBO
mpvClient.renderWithDepth(
fbo: GLint(pipRenderFBO),
width: width,
height: height,
depth: bufferDepth
)
glFlush()
// Report frame swap for vsync timing - important for smooth PiP playback
mpvClient.reportSwap()
// Capture frame for PiP
captureFrameForPiP(viewWidth: width, viewHeight: height, mainFBO: GLint(pipRenderFBO))
// Unbind FBO
glBindFramebuffer(GLenum(GL_FRAMEBUFFER), 0)
}
/// Set up offscreen FBO for PiP rendering.
private func setupPiPRenderFBO(width: Int, height: Int) {
// Skip if dimensions unchanged and FBO exists
if width == pipRenderWidth && height == pipRenderHeight && pipRenderFBO != 0 {
return
}
// Clean up existing FBO
if pipRenderFBO != 0 {
glDeleteFramebuffers(1, &pipRenderFBO)
pipRenderFBO = 0
}
if pipRenderTexture != 0 {
glDeleteTextures(1, &pipRenderTexture)
pipRenderTexture = 0
}
pipRenderWidth = width
pipRenderHeight = height
// Create render texture
glGenTextures(1, &pipRenderTexture)
glBindTexture(GLenum(GL_TEXTURE_2D), pipRenderTexture)
glTexImage2D(
GLenum(GL_TEXTURE_2D),
0,
GL_RGBA8,
GLsizei(width),
GLsizei(height),
0,
GLenum(GL_RGBA),
GLenum(GL_UNSIGNED_BYTE),
nil
)
glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MIN_FILTER), GL_LINEAR)
glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MAG_FILTER), GL_LINEAR)
glBindTexture(GLenum(GL_TEXTURE_2D), 0)
// Create FBO and attach texture
glGenFramebuffers(1, &pipRenderFBO)
glBindFramebuffer(GLenum(GL_FRAMEBUFFER), pipRenderFBO)
glFramebufferTexture2D(
GLenum(GL_FRAMEBUFFER),
GLenum(GL_COLOR_ATTACHMENT0),
GLenum(GL_TEXTURE_2D),
pipRenderTexture,
0
)
// Check FBO status
let status = glCheckFramebufferStatus(GLenum(GL_FRAMEBUFFER))
if status != GL_FRAMEBUFFER_COMPLETE {
Task { @MainActor in
LoggingService.shared.warning("MPVOpenGLLayer: PiP render FBO incomplete: \(status)", category: .mpv)
}
}
glBindFramebuffer(GLenum(GL_FRAMEBUFFER), 0)
Task { @MainActor in
LoggingService.shared.debug("MPVOpenGLLayer: Created PiP render FBO \(width)x\(height)", category: .mpv)
}
}
/// Clear the layer to black.
func clearToBlack() {
renderQueue.async { [weak self] in
guard let self else { return }
CGLSetCurrentContext(self.cglContext)
glClearColor(0.0, 0.0, 0.0, 1.0)
glClear(GLbitfield(GL_COLOR_BUFFER_BIT))
glFlush()
// Force a display to show the cleared frame
self.update(force: true)
}
}
/// Reset first frame tracking (call when loading new content).
func resetFirstFrameTracking() {
hasRenderedFirstFrame = false
}
// MARK: - Pixel Format and Context Creation
/// Create a CGL pixel format, trying 10-bit first, falling back to 8-bit.
private static func createPixelFormat() -> (CGLPixelFormatObj, GLint) {
var pixelFormat: CGLPixelFormatObj?
var numPixelFormats: GLint = 0
// Try 10-bit first
var result = CGLChoosePixelFormat(glFormat10Bit, &pixelFormat, &numPixelFormats)
if result == kCGLNoError, let pf = pixelFormat {
Task { @MainActor in
LoggingService.shared.debug("MPVOpenGLLayer: created 10-bit pixel format", category: .mpv)
}
return (pf, 16)
}
// Fall back to 8-bit
result = CGLChoosePixelFormat(glFormatBase, &pixelFormat, &numPixelFormats)
if result == kCGLNoError, let pf = pixelFormat {
Task { @MainActor in
LoggingService.shared.debug("MPVOpenGLLayer: created 8-bit pixel format", category: .mpv)
}
return (pf, 8)
}
// This should not happen on any reasonable Mac
fatalError("MPVOpenGLLayer: failed to create any OpenGL pixel format")
}
/// Create a CGL context with the given pixel format.
private static func createContext(pixelFormat: CGLPixelFormatObj) -> CGLContextObj {
var context: CGLContextObj?
let result = CGLCreateContext(pixelFormat, nil, &context)
guard result == kCGLNoError, let ctx = context else {
fatalError("MPVOpenGLLayer: failed to create OpenGL context: \(result)")
}
// Enable vsync
var swapInterval: GLint = 1
CGLSetParameter(ctx, kCGLCPSwapInterval, &swapInterval)
// Enable multi-threaded OpenGL engine for better performance
CGLEnable(ctx, kCGLCEMPEngine)
CGLSetCurrentContext(ctx)
Task { @MainActor in
LoggingService.shared.debug("MPVOpenGLLayer: created CGL context with vsync and multi-threaded engine", category: .mpv)
}
return ctx
}
// MARK: - PiP Capture Methods
/// Update the cached time position for PiP presentation timestamps.
func updateTimePosition(_ time: Double) {
cachedTimePos = time
}
/// Update the target PiP capture size and force recreation of capture resources.
/// Called when PiP window size changes (via didTransitionToRenderSize).
func updatePiPTargetSize(_ size: CMVideoDimensions) {
// Force recreation of capture resources at new size by resetting dimensions
pipCaptureWidth = 0
pipCaptureHeight = 0
Task { @MainActor in
LoggingService.shared.debug("MPVOpenGLLayer: Updated PiP target size to \(size.width)x\(size.height)", category: .mpv)
}
}
/// Set up the texture cache and PiP framebuffer for zero-copy capture.
private func setupPiPCapture(width: Int, height: Int) {
// Skip if dimensions unchanged and resources exist
if width == pipCaptureWidth && height == pipCaptureHeight && textureCache != nil {
return
}
// Clean up existing resources
destroyPiPCapture()
pipCaptureWidth = width
pipCaptureHeight = height
// Create texture cache with our CGL context and pixel format
var cache: CVOpenGLTextureCache?
let cacheResult = CVOpenGLTextureCacheCreate(
kCFAllocatorDefault,
nil,
cglContext,
cglPixelFormat,
nil,
&cache
)
guard cacheResult == kCVReturnSuccess, let cache else {
Task { @MainActor in
LoggingService.shared.warning("MPVOpenGLLayer PiP: Failed to create texture cache: \(cacheResult)", category: .mpv)
}
return
}
textureCache = cache
// Create pixel buffer with IOSurface backing for zero-copy
let pixelBufferAttributes: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferWidthKey as String: width,
kCVPixelBufferHeightKey as String: height,
kCVPixelBufferIOSurfacePropertiesKey as String: [:] as [String: Any],
kCVPixelBufferOpenGLCompatibilityKey as String: true
]
var pixelBuffer: CVPixelBuffer?
let pbResult = CVPixelBufferCreate(
kCFAllocatorDefault,
width,
height,
kCVPixelFormatType_32BGRA,
pixelBufferAttributes as CFDictionary,
&pixelBuffer
)
guard pbResult == kCVReturnSuccess, let pixelBuffer else {
Task { @MainActor in
LoggingService.shared.warning("MPVOpenGLLayer PiP: Failed to create pixel buffer: \(pbResult)", category: .mpv)
}
return
}
pipPixelBuffer = pixelBuffer
// Create GL texture from pixel buffer via texture cache
var texture: CVOpenGLTexture?
let texResult = CVOpenGLTextureCacheCreateTextureFromImage(
kCFAllocatorDefault,
cache,
pixelBuffer,
nil,
&texture
)
guard texResult == kCVReturnSuccess, let texture else {
Task { @MainActor in
LoggingService.shared.warning("MPVOpenGLLayer PiP: Failed to create texture from pixel buffer: \(texResult)", category: .mpv)
}
return
}
pipTexture = texture
// Get texture properties (macOS typically uses GL_TEXTURE_RECTANGLE_ARB)
let textureTarget = CVOpenGLTextureGetTarget(texture)
let textureName = CVOpenGLTextureGetName(texture)
// Create FBO for PiP capture
glGenFramebuffers(1, &pipFramebuffer)
glBindFramebuffer(GLenum(GL_FRAMEBUFFER), pipFramebuffer)
// Attach texture to FBO
glFramebufferTexture2D(
GLenum(GL_FRAMEBUFFER),
GLenum(GL_COLOR_ATTACHMENT0),
textureTarget,
textureName,
0
)
// Verify FBO is complete
let status = glCheckFramebufferStatus(GLenum(GL_FRAMEBUFFER))
if status != GL_FRAMEBUFFER_COMPLETE {
Task { @MainActor in
LoggingService.shared.warning("MPVOpenGLLayer PiP: Framebuffer incomplete: \(status)", category: .mpv)
}
destroyPiPCapture()
return
}
// Restore default framebuffer
glBindFramebuffer(GLenum(GL_FRAMEBUFFER), 0)
Task { @MainActor in
LoggingService.shared.debug("MPVOpenGLLayer PiP: Zero-copy capture setup complete (\(width)x\(height)), textureTarget=\(textureTarget)", category: .mpv)
}
}
/// Clean up PiP capture resources.
private func destroyPiPCapture() {
if pipFramebuffer != 0 {
glDeleteFramebuffers(1, &pipFramebuffer)
pipFramebuffer = 0
}
if pipRenderFBO != 0 {
glDeleteFramebuffers(1, &pipRenderFBO)
pipRenderFBO = 0
}
if pipRenderTexture != 0 {
glDeleteTextures(1, &pipRenderTexture)
pipRenderTexture = 0
}
pipTexture = nil
pipPixelBuffer = nil
if let cache = textureCache {
CVOpenGLTextureCacheFlush(cache, 0)
}
textureCache = nil
pipCaptureWidth = 0
pipCaptureHeight = 0
pipRenderWidth = 0
pipRenderHeight = 0
}
/// Capture the current framebuffer contents as a CVPixelBuffer for PiP (zero-copy).
private func captureFrameForPiP(viewWidth: GLint, viewHeight: GLint, mainFBO: GLint) {
guard viewWidth > 0, viewHeight > 0, let callback = onFrameReady else { return }
// Use actual video dimensions for capture (avoid capturing letterbox/pillarbox black bars)
// If video dimensions not set, fall back to view dimensions
let captureVideoWidth = videoContentWidth > 0 ? videoContentWidth : Int(viewWidth)
let captureVideoHeight = videoContentHeight > 0 ? videoContentHeight : Int(viewHeight)
// Set up or update capture resources if needed (based on video dimensions)
setupPiPCapture(width: captureVideoWidth, height: captureVideoHeight)
guard pipFramebuffer != 0, let pixelBuffer = pipPixelBuffer else { return }
// Calculate the source rect in the framebuffer that contains just the video
// (excluding letterbox/pillarbox black bars)
let videoAspect = CGFloat(captureVideoWidth) / CGFloat(captureVideoHeight)
let viewAspect = CGFloat(viewWidth) / CGFloat(viewHeight)
var srcX: GLint = 0
var srcY: GLint = 0
var srcWidth = viewWidth
var srcHeight = viewHeight
if videoAspect > viewAspect {
// Video is wider than view - pillarboxed (black bars on top/bottom)
let scaledHeight = CGFloat(viewWidth) / videoAspect
srcY = GLint((CGFloat(viewHeight) - scaledHeight) / 2)
srcHeight = GLint(scaledHeight)
} else if videoAspect < viewAspect {
// Video is taller than view - letterboxed (black bars on left/right)
let scaledWidth = CGFloat(viewHeight) * videoAspect
srcX = GLint((CGFloat(viewWidth) - scaledWidth) / 2)
srcWidth = GLint(scaledWidth)
}
// Bind PiP framebuffer as draw target
glBindFramebuffer(GLenum(GL_DRAW_FRAMEBUFFER), pipFramebuffer)
glBindFramebuffer(GLenum(GL_READ_FRAMEBUFFER), GLenum(mainFBO))
// Blit from main framebuffer to PiP framebuffer (with scaling and vertical flip)
// Source: just the video area in main framebuffer (bottom-left origin)
// Dest: PiP texture (top-left origin, so we flip Y)
glBlitFramebuffer(
srcX, srcY, srcX + srcWidth, srcY + srcHeight, // src rect (video area only)
0, GLint(pipCaptureHeight), GLint(pipCaptureWidth), 0, // dst rect (flipped Y)
GLbitfield(GL_COLOR_BUFFER_BIT),
GLenum(GL_LINEAR)
)
// Restore main framebuffer
glBindFramebuffer(GLenum(GL_FRAMEBUFFER), GLenum(mainFBO))
// Flush to ensure texture is updated before passing to AVSampleBufferDisplayLayer
glFlush()
// Create presentation time from cached time position
let presentationTime = CMTime(seconds: cachedTimePos, preferredTimescale: 90000)
// Log periodically
pipFrameCount += 1
if pipFrameCount <= 3 || pipFrameCount % 120 == 0 {
// Capture values to avoid capturing self in @Sendable closure
let frameCount = pipFrameCount
let timePos = cachedTimePos
Task { @MainActor in
LoggingService.shared.debug("MPVOpenGLLayer PiP: Captured frame #\(frameCount), \(captureVideoWidth)x\(captureVideoHeight), time=\(timePos)", category: .mpv)
}
}
// Deliver the frame - pixel buffer is already populated via zero-copy
DispatchQueue.main.async {
callback(pixelBuffer, presentationTime)
}
}
}
// MARK: - OpenGL Proc Address
/// Get OpenGL function address for macOS.
private func macOSGetProcAddress(
_ ctx: UnsafeMutableRawPointer?,
_ name: UnsafePointer<CChar>?
) -> UnsafeMutableRawPointer? {
guard let name else { return nil }
let symbolName = String(cString: name)
guard let framework = CFBundleGetBundleWithIdentifier("com.apple.opengl" as CFString) else {
return nil
}
return CFBundleGetFunctionPointerForName(framework, symbolName as CFString)
}
#endif

View File

@@ -0,0 +1,986 @@
//
// MPVPiPBridge.swift
// Yattee
//
// Native Picture-in-Picture support for MPV using AVSampleBufferDisplayLayer.
//
#if os(iOS) || os(macOS)
import AVKit
import CoreMedia
import CoreVideo
import os
#if os(iOS)
import UIKit
typealias PlatformView = UIView
typealias PlatformColor = UIColor
#elseif os(macOS)
import AppKit
typealias PlatformView = NSView
typealias PlatformColor = NSColor
#endif
/// Bridges MPV video output to AVPictureInPictureController using AVSampleBufferDisplayLayer.
/// This enables native PiP for MPV-rendered content.
@MainActor
final class MPVPiPBridge: NSObject {
// MARK: - Properties
private let sampleBufferLayer = AVSampleBufferDisplayLayer()
private var pipController: AVPictureInPictureController?
private weak var mpvBackend: MPVBackend?
/// Whether PiP is currently active.
var isPiPActive: Bool {
pipController?.isPictureInPictureActive ?? false
}
/// Whether PiP is possible (controller exists and is not nil).
var isPiPPossible: Bool {
pipController?.isPictureInPicturePossible ?? false
}
/// Callback for when user wants to restore from PiP to main app.
var onRestoreUserInterface: (() async -> Void)?
/// Callback for when PiP active status changes.
var onPiPStatusChanged: ((Bool) -> Void)?
/// Callback for when PiP will start (for early UI updates like clearing main view)
var onPiPWillStart: (() -> Void)?
/// Callback for when PiP will stop (to resume main view rendering before animation ends)
var onPiPWillStop: (() -> Void)?
/// Callback for when PiP stops without restore (user clicked close button in PiP)
var onPiPDidStopWithoutRestore: (() -> Void)?
/// Callback for when isPictureInPicturePossible changes
var onPiPPossibleChanged: ((Bool) -> Void)?
/// Callback for when PiP render size changes (for resizing capture buffers)
var onPiPRenderSizeChanged: ((CMVideoDimensions) -> Void)?
/// KVO observation for isPictureInPicturePossible
private var pipPossibleObservation: NSKeyValueObservation?
/// Current PiP render size from AVPictureInPictureController
private var currentPiPRenderSize: CMVideoDimensions?
/// Current video aspect ratio (width / height)
private var videoAspectRatio: CGFloat = 16.0 / 9.0
/// Track whether restore was requested before PiP stopped
private var restoreWasRequested = false
#if os(macOS)
/// Timer to periodically update layer frame to match superlayer
private var layerResizeTimer: Timer?
/// Track if we've logged the PiP window hierarchy already
private var hasLoggedPiPHierarchy = false
/// Views we've hidden that need to be restored before PiP cleanup.
/// Uses weak references to avoid retaining AVKit internal views that get deallocated
/// when the PiP window closes, which would cause crashes in objc_release.
private var hiddenPiPViews = NSHashTable<NSView>.weakObjects()
#endif
// MARK: - Format Descriptions
private var currentFormatDescription: CMVideoFormatDescription?
private var lastPresentationTime: CMTime = .zero
/// Timebase for controlling sample buffer display timing
private var timebase: CMTimebase?
/// Cache last pixel buffer to re-enqueue during close animation
private var lastPixelBuffer: CVPixelBuffer?
// MARK: - Playback State (Thread-Safe for nonisolated delegate methods)
/// Cached duration for PiP time range (thread-safe)
private let _duration = OSAllocatedUnfairLock(initialState: 0.0)
/// Cached paused state for PiP (thread-safe)
private let _isPaused = OSAllocatedUnfairLock(initialState: false)
/// Update cached playback state from backend (call periodically)
func updatePlaybackState(duration: Double, currentTime: Double, isPaused: Bool) {
_duration.withLock { $0 = duration }
_isPaused.withLock { $0 = isPaused }
// Update timebase with current playback position
if let timebase {
let time = CMTime(seconds: currentTime, preferredTimescale: 90000)
CMTimebaseSetTime(timebase, time: time)
}
}
// MARK: - Setup
/// Set up PiP with the given MPV backend and container view.
/// - Parameters:
/// - backend: The MPV backend to connect to
/// - containerView: The view to embed the sample buffer layer in
func setup(backend: MPVBackend, in containerView: PlatformView) {
self.mpvBackend = backend
// Configure sample buffer layer
sampleBufferLayer.frame = containerView.bounds
#if os(macOS)
// On macOS, use resize to fill the entire area (ignoring aspect ratio)
// This works around AVKit's PiP window sizing that includes title bar height
sampleBufferLayer.videoGravity = .resize
sampleBufferLayer.contentsGravity = .resize
// Enable auto-resizing to fill superlayer
sampleBufferLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
#else
sampleBufferLayer.videoGravity = .resizeAspect
sampleBufferLayer.contentsGravity = .resizeAspect
#endif
sampleBufferLayer.backgroundColor = PlatformColor.clear.cgColor
// Set up timebase for controlling playback timing
var timebase: CMTimebase?
CMTimebaseCreateWithSourceClock(
allocator: kCFAllocatorDefault,
sourceClock: CMClockGetHostTimeClock(),
timebaseOut: &timebase
)
if let timebase {
self.timebase = timebase
sampleBufferLayer.controlTimebase = timebase
CMTimebaseSetRate(timebase, rate: 1.0)
CMTimebaseSetTime(timebase, time: .zero)
}
// IMPORTANT: Hide the layer during normal playback so it doesn't cover
// the OpenGL rendering. It will be shown when PiP is active.
sampleBufferLayer.isHidden = true
// Layer must be in view hierarchy for PiP to work, but can be hidden
#if os(iOS)
containerView.layer.addSublayer(sampleBufferLayer)
#elseif os(macOS)
// On macOS, add the layer to the container view's layer.
// The warning about NSHostingController is unavoidable with AVSampleBufferDisplayLayer PiP,
// but it doesn't affect functionality - the PiP window works correctly.
containerView.wantsLayer = true
if let layer = containerView.layer {
// Add on top - the layer is hidden during normal playback anyway
layer.addSublayer(sampleBufferLayer)
}
sampleBufferLayer.frame = containerView.bounds
#endif
// Create content source for sample buffer playback
let contentSource = AVPictureInPictureController.ContentSource(
sampleBufferDisplayLayer: sampleBufferLayer,
playbackDelegate: self
)
// Create PiP controller
pipController = AVPictureInPictureController(contentSource: contentSource)
pipController?.delegate = self
// Observe isPictureInPicturePossible changes via KVO
// Note: Don't use .initial here - callbacks aren't set up yet when setup() is called.
// Use notifyPiPPossibleState() after setting up callbacks.
pipPossibleObservation = pipController?.observe(\.isPictureInPicturePossible, options: [.new]) { [weak self] _, change in
let isPossible = change.newValue ?? false
Task { @MainActor [weak self] in
self?.onPiPPossibleChanged?(isPossible)
}
}
#if os(iOS)
// Observe app lifecycle to handle background transitions while PiP is active
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillResignActive),
name: UIApplication.willResignActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
#endif
LoggingService.shared.debug("MPVPiPBridge: Setup complete", category: .mpv)
}
/// Manually notify current isPiPPossible state.
/// Call this after setting up onPiPPossibleChanged callback.
func notifyPiPPossibleState() {
onPiPPossibleChanged?(isPiPPossible)
}
/// Update the video aspect ratio for proper PiP sizing.
/// Call this when video dimensions are known or change.
/// - Parameter aspectRatio: Video width divided by height (e.g., 16/9 = 1.777...)
func updateVideoAspectRatio(_ aspectRatio: CGFloat) {
guard aspectRatio > 0 else { return }
videoAspectRatio = aspectRatio
#if os(macOS)
// On macOS, update layer bounds to match aspect ratio
// This helps AVKit size the PiP window correctly
let currentBounds = sampleBufferLayer.bounds
let newHeight = currentBounds.width / aspectRatio
let newBounds = CGRect(x: 0, y: 0, width: currentBounds.width, height: newHeight)
CATransaction.begin()
CATransaction.setDisableActions(true)
sampleBufferLayer.bounds = newBounds
CATransaction.commit()
LoggingService.shared.debug("MPVPiPBridge: Updated aspect ratio to \(aspectRatio), layer bounds: \(newBounds)", category: .mpv)
#else
// On iOS, don't modify bounds when PiP is inactive - this causes frame misalignment
// (negative Y offset) which breaks the system's PiP restore UI positioning.
// AVKit gets the aspect ratio from the enqueued video frames.
// If PiP is active and video changed, calculate and update the layer frame.
// The superlayer bounds don't update during PiP (view hierarchy hidden),
// so we calculate the correct frame based on screen width and aspect ratio.
if isPiPActive {
// Detect significant aspect ratio change - if so, flush buffer to force AVKit
// to re-read video dimensions from the new format description
let currentBounds = sampleBufferLayer.bounds
if currentBounds.height > 0 {
let previousRatio = currentBounds.width / currentBounds.height
let ratioChange = abs(aspectRatio - previousRatio) / previousRatio
if ratioChange > 0.05 { // >5% change indicates new video
// Flush buffer and clear format description to force AVKit to re-read dimensions
sampleBufferLayer.sampleBufferRenderer.flush()
currentFormatDescription = nil
LoggingService.shared.debug("MPVPiPBridge: Flushed buffer for aspect ratio change \(previousRatio) -> \(aspectRatio)", category: .mpv)
}
}
let screenWidth = UIScreen.main.bounds.width
// Calculate height based on aspect ratio, capped to leave room for details
let maxHeight = UIScreen.main.bounds.height * 0.6 // Leave 40% for details
let calculatedHeight = screenWidth / aspectRatio
let height = min(calculatedHeight, maxHeight)
let width = height < calculatedHeight ? height * aspectRatio : screenWidth
let newFrame = CGRect(x: 0, y: 0, width: width, height: height)
CATransaction.begin()
CATransaction.setDisableActions(true)
sampleBufferLayer.frame = newFrame
sampleBufferLayer.bounds = CGRect(origin: .zero, size: newFrame.size)
CATransaction.commit()
pipController?.invalidatePlaybackState()
LoggingService.shared.debug("MPVPiPBridge: Updated aspect ratio to \(aspectRatio), calculated layer frame during PiP: \(newFrame)", category: .mpv)
} else {
LoggingService.shared.debug("MPVPiPBridge: Updated aspect ratio to \(aspectRatio) (bounds unchanged on iOS)", category: .mpv)
}
#endif
}
#if os(iOS)
@objc private func appWillResignActive() {
guard isPiPActive, let timebase else { return }
// Sync timebase and pre-buffer frames before iOS throttles/suspends
if let currentTime = mpvBackend?.currentTime {
let time = CMTime(seconds: currentTime, preferredTimescale: 90000)
CMTimebaseSetTime(timebase, time: time)
}
CMTimebaseSetRate(timebase, rate: 1.0)
preBufferFramesForBackgroundTransition()
}
@objc private func appDidEnterBackground() {
guard isPiPActive, let timebase else { return }
// Ensure timebase is synced and running
if let currentTime = mpvBackend?.currentTime {
let time = CMTime(seconds: currentTime, preferredTimescale: 90000)
CMTimebaseSetTime(timebase, time: time)
}
CMTimebaseSetRate(timebase, rate: 1.0)
// Pre-buffer additional frames as secondary buffer
preBufferFramesForBackgroundTransition()
}
/// Pre-buffer frames with future timestamps to bridge iOS background suspension.
/// Note: iOS suspends app code for ~300-400ms during background transition.
/// Pre-buffered frames show the same content but keep the layer fed.
private func preBufferFramesForBackgroundTransition() {
guard let pixelBuffer = lastPixelBuffer,
let formatDescription = currentFormatDescription,
let timebase else { return }
let currentTimebaseTime = CMTimebaseGetTime(timebase)
let frameInterval = CMTime(value: 1, timescale: 30)
var currentPTS = currentTimebaseTime
// Pre-enqueue 30 frames (~1 second) to bridge the iOS suspension gap
for _ in 0..<30 {
currentPTS = CMTimeAdd(currentPTS, frameInterval)
var sampleTimingInfo = CMSampleTimingInfo(
duration: frameInterval,
presentationTimeStamp: currentPTS,
decodeTimeStamp: .invalid
)
var sampleBuffer: CMSampleBuffer?
CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescription: formatDescription,
sampleTiming: &sampleTimingInfo,
sampleBufferOut: &sampleBuffer
)
guard let sampleBuffer else { continue }
if sampleBufferLayer.sampleBufferRenderer.status != .failed {
sampleBufferLayer.sampleBufferRenderer.enqueue(sampleBuffer)
}
}
lastPresentationTime = currentPTS
}
#endif
/// Update the layer frame when container bounds change.
/// On macOS, the frame should be relative to the window's content view.
func updateLayerFrame(_ frame: CGRect) {
sampleBufferLayer.frame = frame
}
#if os(macOS)
/// Update the layer frame based on container view's bounds.
/// Call this on macOS when the container view's size changes.
func updateLayerFrame(for containerView: NSView) {
sampleBufferLayer.frame = containerView.bounds
}
#endif
/// Move the sample buffer layer to a new container view.
/// This is needed when transitioning from fullscreen to PiP,
/// as the layer must be in a visible window hierarchy.
func moveLayer(to containerView: PlatformView) {
sampleBufferLayer.removeFromSuperlayer()
sampleBufferLayer.frame = containerView.bounds
#if os(iOS)
containerView.layer.addSublayer(sampleBufferLayer)
#elseif os(macOS)
containerView.wantsLayer = true
containerView.layer?.addSublayer(sampleBufferLayer)
#endif
}
/// Clean up and release resources.
func cleanup() {
NotificationCenter.default.removeObserver(self)
pipPossibleObservation?.invalidate()
pipPossibleObservation = nil
lastPixelBuffer = nil
pipController?.stopPictureInPicture()
pipController = nil
sampleBufferLayer.removeFromSuperlayer()
sampleBufferLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil)
mpvBackend = nil
}
/// Flush the sample buffer to clear any displayed frame.
/// Call this when stopping playback to prevent stale frames when reusing backend.
func flushBuffer() {
sampleBufferLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil)
frameCount = 0
}
// MARK: - Frame Enqueueing
/// Track frame count for logging
private var frameCount = 0
/// Enqueue a video frame from MPV for display.
/// This is called by MPV's render callback when a frame is ready.
/// - Parameters:
/// - pixelBuffer: The decoded video frame as CVPixelBuffer
/// - presentationTime: The presentation timestamp for this frame
func enqueueFrame(_ pixelBuffer: CVPixelBuffer, presentationTime: CMTime) {
frameCount += 1
// Log first few frames and then periodically
if frameCount <= 3 || frameCount % 60 == 0 {
let width = CVPixelBufferGetWidth(pixelBuffer)
let height = CVPixelBufferGetHeight(pixelBuffer)
LoggingService.shared.debug("MPVPiPBridge: Enqueue frame #\(frameCount), size: \(width)x\(height), layer status: \(sampleBufferLayer.sampleBufferRenderer.status.rawValue)", category: .mpv)
}
// Create format description if needed or if dimensions changed
let width = CVPixelBufferGetWidth(pixelBuffer)
let height = CVPixelBufferGetHeight(pixelBuffer)
if currentFormatDescription == nil ||
CMVideoFormatDescriptionGetDimensions(currentFormatDescription!).width != Int32(width) ||
CMVideoFormatDescriptionGetDimensions(currentFormatDescription!).height != Int32(height) {
var formatDescription: CMVideoFormatDescription?
CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescriptionOut: &formatDescription
)
currentFormatDescription = formatDescription
}
guard let formatDescription = currentFormatDescription else { return }
// Create sample timing info
var sampleTimingInfo = CMSampleTimingInfo(
duration: CMTime(value: 1, timescale: 30), // Approximate frame duration
presentationTimeStamp: presentationTime,
decodeTimeStamp: .invalid
)
// Create sample buffer
var sampleBuffer: CMSampleBuffer?
CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescription: formatDescription,
sampleTiming: &sampleTimingInfo,
sampleBufferOut: &sampleBuffer
)
guard let sampleBuffer else { return }
// Cache the pixel buffer for re-enqueuing during close animation
lastPixelBuffer = pixelBuffer
// Enqueue on sample buffer layer
if sampleBufferLayer.sampleBufferRenderer.status != .failed {
sampleBufferLayer.sampleBufferRenderer.enqueue(sampleBuffer)
lastPresentationTime = presentationTime
} else {
// Flush and retry if layer is in failed state
sampleBufferLayer.sampleBufferRenderer.flush()
sampleBufferLayer.sampleBufferRenderer.enqueue(sampleBuffer)
}
}
/// Re-enqueue the last frame to prevent placeholder from showing
private func reenqueueLastFrame() {
guard let pixelBuffer = lastPixelBuffer,
let formatDescription = currentFormatDescription else { return }
// Increment presentation time slightly to avoid duplicate timestamps
let newPresentationTime = CMTimeAdd(lastPresentationTime, CMTime(value: 1, timescale: 30))
var sampleTimingInfo = CMSampleTimingInfo(
duration: CMTime(value: 1, timescale: 30),
presentationTimeStamp: newPresentationTime,
decodeTimeStamp: .invalid
)
var sampleBuffer: CMSampleBuffer?
CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescription: formatDescription,
sampleTiming: &sampleTimingInfo,
sampleBufferOut: &sampleBuffer
)
guard let sampleBuffer else { return }
if sampleBufferLayer.sampleBufferRenderer.status != .failed {
sampleBufferLayer.sampleBufferRenderer.enqueue(sampleBuffer)
lastPresentationTime = newPresentationTime
}
}
// MARK: - PiP Control
/// Start Picture-in-Picture.
func startPiP() {
guard let pipController, pipController.isPictureInPicturePossible else {
LoggingService.shared.warning("MPVPiPBridge: PiP not possible", category: .mpv)
return
}
#if os(iOS)
// Update layer frame to match current superlayer bounds before starting PiP.
// This ensures the frame is correct for the current video's player area.
if let superlayer = sampleBufferLayer.superlayer {
CATransaction.begin()
CATransaction.setDisableActions(true)
sampleBufferLayer.frame = superlayer.bounds
sampleBufferLayer.bounds = CGRect(origin: .zero, size: superlayer.bounds.size)
CATransaction.commit()
LoggingService.shared.debug("MPVPiPBridge: Updated layer frame before PiP: \(superlayer.bounds)", category: .mpv)
}
#endif
// Show the layer before starting PiP - it needs to be visible for PiP to work
sampleBufferLayer.isHidden = false
pipController.startPictureInPicture()
LoggingService.shared.debug("MPVPiPBridge: Starting PiP", category: .mpv)
}
/// Stop Picture-in-Picture.
func stopPiP() {
pipController?.stopPictureInPicture()
// Layer will be hidden in didStopPictureInPicture delegate
LoggingService.shared.debug("MPVPiPBridge: Stopping PiP", category: .mpv)
}
/// Toggle Picture-in-Picture.
func togglePiP() {
if isPiPActive {
stopPiP()
} else {
startPiP()
}
}
/// Invalidate and update the playback state in PiP window.
func invalidatePlaybackState() {
pipController?.invalidatePlaybackState()
}
}
// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate
extension MPVPiPBridge: AVPictureInPictureSampleBufferPlaybackDelegate {
nonisolated func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
setPlaying playing: Bool
) {
// Update cached state immediately for responsive UI
_isPaused.withLock { $0 = !playing }
Task { @MainActor in
// Update timebase rate
if let timebase {
CMTimebaseSetRate(timebase, rate: playing ? 1.0 : 0.0)
}
if playing {
mpvBackend?.play()
} else {
mpvBackend?.pause()
}
// Notify PiP system that state changed
pipController?.invalidatePlaybackState()
}
}
nonisolated func pictureInPictureControllerTimeRangeForPlayback(
_ pictureInPictureController: AVPictureInPictureController
) -> CMTimeRange {
let duration = _duration.withLock { $0 }
// Return actual duration if known
if duration > 0 {
return CMTimeRange(start: .zero, duration: CMTime(seconds: duration, preferredTimescale: 90000))
}
// Fallback to a reasonable default until we know the actual duration
return CMTimeRange(start: .zero, duration: CMTime(seconds: 3600, preferredTimescale: 90000))
}
nonisolated func pictureInPictureControllerIsPlaybackPaused(
_ pictureInPictureController: AVPictureInPictureController
) -> Bool {
_isPaused.withLock { $0 }
}
// Optional: Handle skip by interval (completion handler style to avoid compiler crash)
nonisolated func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
skipByInterval skipInterval: CMTime,
completion completionHandler: @escaping @Sendable () -> Void
) {
Task { @MainActor in
let currentTime = mpvBackend?.currentTime ?? 0
let newTime = currentTime + skipInterval.seconds
await mpvBackend?.seek(to: max(0, newTime))
completionHandler()
}
}
// Optional: Whether to prohibit background audio
nonisolated func pictureInPictureControllerShouldProhibitBackgroundAudioPlayback(
_ pictureInPictureController: AVPictureInPictureController
) -> Bool {
false // Allow background audio
}
// Required: Handle render size changes
nonisolated func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
didTransitionToRenderSize newRenderSize: CMVideoDimensions
) {
Task { @MainActor in
currentPiPRenderSize = newRenderSize
onPiPRenderSizeChanged?(newRenderSize)
LoggingService.shared.debug("MPVPiPBridge: PiP render size changed to \(newRenderSize.width)x\(newRenderSize.height)", category: .mpv)
}
}
}
// MARK: - AVPictureInPictureControllerDelegate
extension MPVPiPBridge: AVPictureInPictureControllerDelegate {
nonisolated func pictureInPictureControllerWillStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
Task { @MainActor in
// Reset PiP render size - will be updated by didTransitionToRenderSize
currentPiPRenderSize = nil
// Reset restore flag - will be set if user clicks restore button
restoreWasRequested = false
// Show the sample buffer layer when PiP starts
sampleBufferLayer.isHidden = false
#if os(macOS)
// Ensure our layer has no background that could cause black areas
sampleBufferLayer.backgroundColor = nil
// Hide other sublayers (like _NSOpenGLViewBackingLayer) that would cover our video
if let superlayer = sampleBufferLayer.superlayer,
let sublayers = superlayer.sublayers {
for layer in sublayers where layer !== sampleBufferLayer {
layer.isHidden = true
LoggingService.shared.debug("MPVPiPBridge: Hiding layer \(type(of: layer)) for PiP", category: .mpv)
}
}
// Clear backgrounds on parent layers that could cause black areas
// The container view's backing layer often has a black background
var currentLayer: CALayer? = sampleBufferLayer.superlayer
var depth = 0
while let layer = currentLayer {
let layerType = String(describing: type(of: layer))
if layer.backgroundColor != nil {
LoggingService.shared.debug("MPVPiPBridge: Clearing background on \(layerType) at depth \(depth)", category: .mpv)
layer.backgroundColor = nil
}
currentLayer = layer.superlayer
depth += 1
if depth > 5 { break } // Don't go too far up
}
#endif
// Notify to clear main view immediately
onPiPWillStart?()
LoggingService.shared.debug("MPVPiPBridge: Will start PiP", category: .mpv)
}
}
nonisolated func pictureInPictureControllerDidStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
Task { @MainActor in
onPiPStatusChanged?(true)
// Debug: Log layer frame and bounds
LoggingService.shared.debug("MPVPiPBridge: Did start PiP - layer frame: \(sampleBufferLayer.frame), bounds: \(sampleBufferLayer.bounds), videoGravity: \(sampleBufferLayer.videoGravity.rawValue)", category: .mpv)
#if os(macOS)
// Start timer to update layer frame to match PiP window
startLayerResizeTimer()
#endif
}
}
nonisolated func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
failedToStartPictureInPictureWithError error: Error
) {
Task { @MainActor in
// Hide the layer again since PiP failed
sampleBufferLayer.isHidden = true
#if os(macOS)
// Unhide other sublayers that we hid when trying to start PiP
if let superlayer = sampleBufferLayer.superlayer,
let sublayers = superlayer.sublayers {
for layer in sublayers where layer !== sampleBufferLayer {
layer.isHidden = false
}
}
#endif
onPiPStatusChanged?(false)
LoggingService.shared.logMPVError("MPVPiPBridge: Failed to start PiP", error: error)
}
}
nonisolated func pictureInPictureControllerWillStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
Task { @MainActor in
#if os(macOS)
// Restore hidden views BEFORE cleanup to prevent crashes
restoreHiddenPiPViews()
#endif
// Pre-enqueue multiple copies of the last frame to ensure buffer has content
// throughout the entire close animation (typically ~0.3-0.5 seconds)
for _ in 0..<30 {
reenqueueLastFrame()
}
// Resume main view rendering before animation ends
// Keep sampleBufferLayer visible and receiving frames during close animation
// to avoid showing the "video is playing in picture in picture" placeholder
onPiPWillStop?()
LoggingService.shared.debug("MPVPiPBridge: Will stop PiP, pre-enqueued frames", category: .mpv)
}
}
nonisolated func pictureInPictureControllerDidStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
Task { @MainActor in
#if os(macOS)
// Stop layer resize timer
stopLayerResizeTimer()
// Unhide other sublayers that we hid when PiP started
if let superlayer = sampleBufferLayer.superlayer,
let sublayers = superlayer.sublayers {
for layer in sublayers where layer !== sampleBufferLayer {
layer.isHidden = false
}
}
#endif
// Hide the sample buffer layer when PiP stops
sampleBufferLayer.isHidden = true
// Clear cached pixel buffer
lastPixelBuffer = nil
onPiPStatusChanged?(false)
// If restore wasn't requested, notify that PiP stopped without restore
// (user clicked X button instead of restore button)
if !restoreWasRequested {
LoggingService.shared.debug("MPVPiPBridge: Did stop PiP without restore (close button)", category: .mpv)
onPiPDidStopWithoutRestore?()
} else {
LoggingService.shared.debug("MPVPiPBridge: Did stop PiP (with restore)", category: .mpv)
}
}
}
nonisolated func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
) {
Task { @MainActor in
// Mark that restore was requested - didStopPictureInPicture will check this
restoreWasRequested = true
LoggingService.shared.debug("MPVPiPBridge: Restore requested", category: .mpv)
await onRestoreUserInterface?()
completionHandler(true)
}
}
}
// MARK: - macOS Layer Resize Timer
#if os(macOS)
extension MPVPiPBridge {
/// Start a timer to periodically resize the layer to match the PiP window.
/// This is needed on macOS because AVKit doesn't automatically resize the layer.
func startLayerResizeTimer() {
stopLayerResizeTimer()
// Check immediately
updateLayerFrameToMatchPiPWindow()
// Then check periodically
layerResizeTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
self?.updateLayerFrameToMatchPiPWindow()
}
}
}
/// Stop the layer resize timer.
func stopLayerResizeTimer() {
layerResizeTimer?.invalidate()
layerResizeTimer = nil
}
/// Find the PiP window by enumerating all windows.
private func findPiPWindow() -> NSWindow? {
// Get all windows in the app
let allWindows = NSApplication.shared.windows
for window in allWindows {
let className = String(describing: type(of: window))
// PiP windows on macOS are typically named PIPPanelWindow or similar
if className.contains("PIP") || className.contains("PiP") || className.contains("Picture") {
LoggingService.shared.debug("MPVPiPBridge: Found PiP window: \(className), frame: \(window.frame)", category: .mpv)
return window
}
}
// If no PiP window found in our app, it might be owned by AVKit framework
// Try to find it via CGWindowListCopyWindowInfo
let options = CGWindowListOption(arrayLiteral: .optionOnScreenOnly, .excludeDesktopElements)
if let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] {
for windowInfo in windowList {
if let ownerName = windowInfo[kCGWindowOwnerName as String] as? String,
ownerName.contains("Picture") || ownerName.contains("PiP") {
LoggingService.shared.debug("MPVPiPBridge: Found PiP via CGWindowList: \(ownerName)", category: .mpv)
}
if let windowName = windowInfo[kCGWindowName as String] as? String {
if windowName.contains("Picture") || windowName.contains("PiP") {
// Found it, but CGWindowInfo doesn't give us NSWindow
if let bounds = windowInfo[kCGWindowBounds as String] as? [String: Any],
let width = bounds["Width"] as? CGFloat,
let height = bounds["Height"] as? CGFloat {
LoggingService.shared.debug("MPVPiPBridge: PiP window bounds from CGWindowList: \(width)x\(height)", category: .mpv)
}
}
}
}
}
return nil
}
/// Recursively log view hierarchy for debugging
private func logViewHierarchy(_ view: NSView, depth: Int) {
let indent = String(repeating: " ", count: depth)
let viewType = String(describing: type(of: view))
let layerInfo: String
if let layer = view.layer {
let bgColor = layer.backgroundColor != nil ? "has bg" : "no bg"
let clips = layer.masksToBounds ? "clips" : "no clip"
layerInfo = "layer: \(layer.frame), \(bgColor), \(clips)"
} else {
layerInfo = "no layer"
}
LoggingService.shared.debug("MPVPiPBridge: \(indent)[\(depth)] \(viewType) frame: \(view.frame), \(layerInfo)", category: .mpv)
// Check sublayers
if let layer = view.layer {
for sublayer in layer.sublayers ?? [] {
let sublayerType = String(describing: type(of: sublayer))
let subBg = sublayer.backgroundColor != nil ? "HAS BG" : "no bg"
let subClips = sublayer.masksToBounds ? "clips" : "no clip"
LoggingService.shared.debug("MPVPiPBridge: \(indent) -> sublayer: \(sublayerType), frame: \(sublayer.frame), \(subBg), \(subClips)", category: .mpv)
}
}
// Recurse into subviews (limit depth to avoid spam)
if depth < 6 {
for subview in view.subviews {
logViewHierarchy(subview, depth: depth + 1)
}
}
}
/// Fix the mispositioned AVPictureInPictureCALayerHostView that causes the black bar
private func fixPiPLayerHostViewPosition(in pipWindow: NSWindow) {
guard let contentView = pipWindow.contentView else { return }
// Find the AVPictureInPictureCALayerHostView which is positioned incorrectly
findAndFixLayerHostView(in: contentView, windowBounds: contentView.bounds)
}
private func findAndFixLayerHostView(in view: NSView, windowBounds: CGRect) {
let viewType = String(describing: type(of: view))
// AVPictureInPictureCALayerHostView contains the SOURCE view content (our OpenGL view)
// It's positioned incorrectly and shows the black background from our app
// We want to HIDE this completely - we only need the AVSampleBufferDisplayLayerContentLayer
if viewType.contains("AVPictureInPictureCALayerHostView") {
if !view.isHidden {
LoggingService.shared.debug("MPVPiPBridge: Hiding \(viewType) - it contains source view with black bg", category: .mpv)
view.isHidden = true
view.layer?.isHidden = true
// Track this view so we can unhide it before cleanup
hiddenPiPViews.add(view)
}
}
// Disable clipping on content layers
if viewType.contains("AVPictureInPictureSampleBufferDisplayLayerHostView") {
// Disable clipping on this view and its sublayers
view.layer?.masksToBounds = false
for sublayer in view.layer?.sublayers ?? [] {
sublayer.masksToBounds = false
}
}
// Recurse
for subview in view.subviews {
findAndFixLayerHostView(in: subview, windowBounds: windowBounds)
}
}
/// Restore any views we hid to prevent crashes during cleanup
private func restoreHiddenPiPViews() {
let views = hiddenPiPViews.allObjects
let count = views.count
for view in views {
view.isHidden = false
view.layer?.isHidden = false
}
hiddenPiPViews.removeAllObjects()
LoggingService.shared.debug("MPVPiPBridge: Restored \(count) hidden PiP views", category: .mpv)
}
/// Update the sample buffer layer's frame to match the PiP window size.
private func updateLayerFrameToMatchPiPWindow() {
// Try to find the PiP window
if let pipWindow = findPiPWindow() {
// Get the content view bounds (excludes title bar)
let windowSize = pipWindow.contentView?.bounds.size ?? pipWindow.frame.size
// Log detailed PiP window view hierarchy once
if !hasLoggedPiPHierarchy, let contentView = pipWindow.contentView {
hasLoggedPiPHierarchy = true
LoggingService.shared.debug("MPVPiPBridge: ===== PiP Window View Hierarchy =====", category: .mpv)
LoggingService.shared.debug("MPVPiPBridge: Window frame: \(pipWindow.frame), contentView frame: \(contentView.frame)", category: .mpv)
logViewHierarchy(contentView, depth: 0)
}
// Fix mispositioned internal AVKit views that cause the black bar
fixPiPLayerHostViewPosition(in: pipWindow)
let newFrame = CGRect(origin: .zero, size: windowSize)
if sampleBufferLayer.frame.size != newFrame.size {
LoggingService.shared.debug("MPVPiPBridge: Resizing layer to match PiP window: \(sampleBufferLayer.frame) -> \(newFrame)", category: .mpv)
CATransaction.begin()
CATransaction.setDisableActions(true)
sampleBufferLayer.frame = newFrame
CATransaction.commit()
}
} else {
// Fallback: try to match superlayer
guard let superlayer = sampleBufferLayer.superlayer else { return }
let superBounds = superlayer.bounds
if sampleBufferLayer.frame != superBounds {
LoggingService.shared.debug("MPVPiPBridge: Resizing layer to match superlayer: \(sampleBufferLayer.frame) -> \(superBounds)", category: .mpv)
CATransaction.begin()
CATransaction.setDisableActions(true)
sampleBufferLayer.frame = superBounds
CATransaction.commit()
}
}
}
}
#endif
#endif

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,502 @@
//
// MPVSoftwareRenderView.swift
// Yattee
//
// Software (CPU-based) rendering view for MPV in iOS/tvOS Simulator.
// Uses MPV_RENDER_API_TYPE_SW to render to memory buffer, then displays via CGImage.
//
import Foundation
import Libmpv
#if os(iOS)
import UIKit
#elseif os(tvOS)
import UIKit
#endif
import CoreMedia
#if targetEnvironment(simulator) && (os(iOS) || os(tvOS))
/// Software-based MPV render view for iOS/tvOS Simulator (where OpenGL ES is not available).
/// Renders video frames to CPU memory buffer and displays via CALayer.
final class MPVSoftwareRenderView: UIView {
// MARK: - Properties
private weak var mpvClient: MPVClient?
private var isSetup = false
/// Render buffer for MPV to write pixel data
private var renderBuffer: UnsafeMutableRawPointer?
private var renderWidth: Int = 0
private var renderHeight: Int = 0
private var renderStride: Int = 0
/// Display link for frame rendering
private var displayLink: CADisplayLink?
/// Video frame rate from MPV
var videoFPS: Double = 30.0 {
didSet {
updateDisplayLinkFrameRate()
}
}
/// Current display link target frame rate
var displayLinkTargetFPS: Double {
videoFPS
}
/// Lock for thread-safe rendering
private let renderLock = NSLock()
private var isRendering = false
/// Tracks whether first frame has been rendered
private var hasRenderedFirstFrame = false
/// Tracks whether MPV has signaled it has a frame ready
private var mpvHasFrameReady = false
/// Generation counter to invalidate stale frame callbacks
private var frameGeneration: UInt = 0
/// Callback when first frame is rendered
var onFirstFrameRendered: (() -> Void)?
/// Callback when view is added to window (for PiP setup)
var onDidMoveToWindow: ((UIView) -> Void)?
/// Dedicated queue for rendering operations
private let renderQueue = DispatchQueue(label: "stream.yattee.mpv.software-render", qos: .userInitiated)
/// Lock for buffer recreation
private let bufferLock = NSLock()
private var isRecreatingBuffer = false
// MARK: - Initialization
init() {
super.init(frame: .zero)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
backgroundColor = .black
contentScaleFactor = UIScreen.main.scale
// Observe app lifecycle
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
}
@objc private func appDidEnterBackground() {
displayLink?.isPaused = true
MPVLogging.logDisplayLink("paused", isPaused: true, reason: "enterBackground")
}
@objc private func appDidBecomeActive() {
displayLink?.isPaused = false
MPVLogging.logDisplayLink("resumed", isPaused: false, reason: "becomeActive")
if isSetup {
renderQueue.async { [weak self] in
self?.performRender()
}
}
}
deinit {
NotificationCenter.default.removeObserver(self)
stopDisplayLink()
// Free render buffer on render queue
renderQueue.sync {
freeRenderBuffer()
}
}
// MARK: - View Lifecycle
override func willMove(toSuperview newSuperview: UIView?) {
super.willMove(toSuperview: newSuperview)
if newSuperview == nil {
MPVLogging.logDisplayLink("stop", reason: "removedFromSuperview")
stopDisplayLink()
}
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
if superview != nil && isSetup {
if displayLink == nil {
MPVLogging.logDisplayLink("start", reason: "addedToSuperview")
startDisplayLink()
}
// Trigger immediate render
renderQueue.async { [weak self] in
self?.performRender()
}
}
}
override func layoutSubviews() {
super.layoutSubviews()
guard isSetup else { return }
let currentSize = bounds.size
let scale = contentScaleFactor
let expectedWidth = Int(currentSize.width * scale)
let expectedHeight = Int(currentSize.height * scale)
// Check if buffer size needs update
let bufferMismatch = abs(renderWidth - expectedWidth) > 2 || abs(renderHeight - expectedHeight) > 2
guard bufferMismatch && expectedWidth > 0 && expectedHeight > 0 else { return }
MPVLogging.logTransition("layoutSubviews - size mismatch (async resize)",
fromSize: CGSize(width: renderWidth, height: renderHeight),
toSize: CGSize(width: expectedWidth, height: expectedHeight))
// Recreate buffer on background queue
isRecreatingBuffer = true
renderQueue.async { [weak self] in
guard let self else { return }
self.allocateRenderBuffer(width: expectedWidth, height: expectedHeight)
self.isRecreatingBuffer = false
MPVLogging.log("layoutSubviews: buffer recreation complete")
}
}
override func didMoveToWindow() {
super.didMoveToWindow()
if window != nil {
onDidMoveToWindow?(self)
}
}
// MARK: - Setup
/// Set up with an MPV client (async version).
func setupAsync(with client: MPVClient) async throws {
self.mpvClient = client
// Create MPV software render context
let success = client.createSoftwareRenderContext()
if !success {
MPVLogging.warn("setupAsync: failed to create MPV software render context")
throw MPVRenderError.renderContextFailed(-1)
}
// Set up render update callback
client.onRenderUpdate = { [weak self] in
DispatchQueue.main.async {
self?.setNeedsDisplay()
}
}
// Set up video frame callback
client.onVideoFrameReady = { [weak self] in
guard let self else { return }
let capturedGeneration = self.frameGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.frameGeneration == capturedGeneration else { return }
self.mpvHasFrameReady = true
}
}
await MainActor.run {
// Allocate initial render buffer
let scale = contentScaleFactor
let width = Int(bounds.width * scale)
let height = Int(bounds.height * scale)
if width > 0 && height > 0 {
renderQueue.async { [weak self] in
self?.allocateRenderBuffer(width: width, height: height)
}
}
startDisplayLink()
isSetup = true
}
MPVLogging.log("MPVSoftwareRenderView: setup complete")
}
/// Update time position for frame timestamps.
func updateTimePosition(_ time: Double) {
// Not used in software rendering, but kept for API compatibility
}
// MARK: - Buffer Management
/// Allocate aligned render buffer for MPV to write pixels.
/// Must be called on renderQueue.
private func allocateRenderBuffer(width: Int, height: Int) {
guard width > 0 && height > 0 else {
return
}
// Free existing buffer
freeRenderBuffer()
// Calculate stride (4 bytes per pixel for RGBA, aligned to 64 bytes)
let bytesPerPixel = 4
let minStride = width * bytesPerPixel
let stride = ((minStride + 63) / 64) * 64 // Round up to 64-byte alignment
// Allocate aligned buffer
var buffer: UnsafeMutableRawPointer?
let bufferSize = stride * height
let alignResult = posix_memalign(&buffer, 64, bufferSize)
guard alignResult == 0, let buffer else {
MPVLogging.warn("allocateRenderBuffer: posix_memalign failed (\(alignResult))")
return
}
// Zero out buffer
memset(buffer, 0, bufferSize)
renderBuffer = buffer
renderWidth = width
renderHeight = height
renderStride = stride
// Trigger an immediate render now that we have a buffer
if isSetup {
DispatchQueue.main.async { [weak self] in
guard let self, !self.isRendering else { return }
self.renderLock.lock()
self.isRendering = true
self.renderLock.unlock()
self.renderQueue.async { [weak self] in
self?.performRender()
}
}
}
}
/// Free render buffer.
/// Must be called on renderQueue.
private func freeRenderBuffer() {
if let buffer = renderBuffer {
free(buffer)
renderBuffer = nil
}
renderWidth = 0
renderHeight = 0
renderStride = 0
}
// MARK: - Display Link
private func startDisplayLink() {
displayLink = CADisplayLink(target: self, selector: #selector(displayLinkFired))
updateDisplayLinkFrameRate()
displayLink?.add(to: .main, forMode: .common)
}
private func updateDisplayLinkFrameRate() {
guard let displayLink else { return }
// Match video FPS
let preferred = Float(min(max(videoFPS, 24.0), 60.0))
displayLink.preferredFrameRateRange = CAFrameRateRange(
minimum: 24,
maximum: 60,
preferred: preferred
)
}
private func stopDisplayLink() {
displayLink?.invalidate()
displayLink = nil
}
/// Pause rendering.
func pauseRendering() {
displayLink?.isPaused = true
}
/// Resume rendering.
func resumeRendering() {
guard let displayLink else { return }
displayLink.isPaused = false
if isSetup {
renderQueue.async { [weak self] in
self?.performRender()
}
}
}
/// Reset first frame tracking.
func resetFirstFrameTracking() {
frameGeneration += 1
hasRenderedFirstFrame = false
mpvHasFrameReady = false
}
/// Clear the render view to black.
func clearToBlack() {
guard renderBuffer != nil else { return }
renderQueue.async { [weak self] in
guard let self, let buffer = self.renderBuffer else { return }
memset(buffer, 0, self.renderStride * self.renderHeight)
DispatchQueue.main.async { [weak self] in
self?.layer.contents = nil
}
}
}
// MARK: - Rendering
@objc private func displayLinkFired() {
guard isSetup, !isRendering else { return }
renderLock.lock()
isRendering = true
renderLock.unlock()
renderQueue.async { [weak self] in
self?.performRender()
}
}
/// Frame counter for periodic logging
private var renderFrameLogCounter: UInt64 = 0
private func performRender() {
defer {
renderLock.lock()
isRendering = false
renderLock.unlock()
}
guard let mpvClient else {
renderFrameLogCounter += 1
return
}
guard let buffer = renderBuffer, renderWidth > 0, renderHeight > 0 else {
renderFrameLogCounter += 1
return
}
// Skip if buffer is being recreated
if isRecreatingBuffer {
return
}
// Render frame to buffer - returns true if a frame was actually rendered
let didRender = mpvClient.renderSoftware(
buffer: buffer,
width: Int32(renderWidth),
height: Int32(renderHeight),
stride: renderStride
)
// Only update the layer if we actually rendered a frame
guard didRender else {
return
}
// Convert buffer to CGImage and update layer
if let image = bufferToCGImage() {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.layer.contents = image
}
// Notify on first frame rendered
if !hasRenderedFirstFrame {
hasRenderedFirstFrame = true
mpvHasFrameReady = true
DispatchQueue.main.async { [weak self] in
self?.onFirstFrameRendered?()
}
}
}
renderFrameLogCounter += 1
}
/// Convert render buffer to CGImage for display.
/// Must be called on renderQueue.
private func bufferToCGImage() -> CGImage? {
guard let buffer = renderBuffer, renderWidth > 0, renderHeight > 0 else {
return nil
}
// Copy buffer data to avoid lifetime issues
let bufferSize = renderStride * renderHeight
let dataCopy = Data(bytes: buffer, count: bufferSize)
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue)
guard let dataProvider = CGDataProvider(data: dataCopy as CFData) else {
return nil
}
guard let image = CGImage(
width: renderWidth,
height: renderHeight,
bitsPerComponent: 8,
bitsPerPixel: 32,
bytesPerRow: renderStride,
space: colorSpace,
bitmapInfo: bitmapInfo,
provider: dataProvider,
decode: nil,
shouldInterpolate: false,
intent: .defaultIntent
) else {
return nil
}
return image
}
// MARK: - PiP Compatibility Stubs
/// These properties/methods exist for API compatibility with MPVRenderView.
/// PiP is not supported in software rendering mode.
var captureFramesForPiP: Bool = false
var isPiPActive: Bool = false
var videoContentWidth: Int = 0
var videoContentHeight: Int = 0
var onFrameReady: ((CVPixelBuffer, CMTime) -> Void)?
func clearMainViewForPiP() {
clearToBlack()
}
}
#endif

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,430 @@
//
// NowPlayingService.swift
// Yattee
//
// Manages Now Playing info for Control Center and Lock Screen.
//
import Foundation
import MediaPlayer
import AVFoundation
#if os(iOS) || os(tvOS)
import UIKit
typealias NowPlayingImage = UIImage
#elseif os(macOS)
import AppKit
typealias NowPlayingImage = NSImage
#endif
/// Service for updating system Now Playing info (Control Center, Lock Screen).
@MainActor
final class NowPlayingService {
// MARK: - Properties
private let infoCenter = MPNowPlayingInfoCenter.default()
private let commandCenter = MPRemoteCommandCenter.shared()
private var currentVideo: Video?
private var artworkImage: NowPlayingImage?
weak var playerService: PlayerService?
weak var deArrowBrandingProvider: DeArrowBrandingProvider?
weak var settingsManager: SettingsManager?
weak var playerControlsLayoutService: PlayerControlsLayoutService?
// MARK: - Initialization
init() {
configureRemoteCommands()
}
// MARK: - Public Methods
/// Updates Now Playing info for a video.
func updateNowPlaying(
video: Video,
currentTime: TimeInterval,
duration: TimeInterval,
isPlaying: Bool
) {
currentVideo = video
// Use DeArrow title if available, otherwise use original title
let title = deArrowBrandingProvider?.title(for: video) ?? video.title
// Determine if this is a live stream
let isLive = video.isLive
// Build the Now Playing info dictionary with all properties needed for tvOS
var nowPlayingInfo: [String: Any] = [
MPMediaItemPropertyTitle: title,
MPMediaItemPropertyArtist: video.author.name,
MPNowPlayingInfoPropertyIsLiveStream: isLive,
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime,
MPNowPlayingInfoPropertyPlaybackQueueCount: 1,
MPNowPlayingInfoPropertyPlaybackQueueIndex: 0,
MPMediaItemPropertyMediaType: MPMediaType.anyVideo.rawValue,
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0,
MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0
]
// Only add duration for non-live content
if !isLive && duration > 0 {
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
}
// Add artwork if available
if let artwork = artworkImage {
#if os(iOS) || os(tvOS)
nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(
boundsSize: artwork.size
) { _ in artwork }
#elseif os(macOS)
nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(
boundsSize: artwork.size
) { _ in artwork }
#endif
}
LoggingService.shared.debug(
"Setting Now Playing info with \(nowPlayingInfo.count) keys: \(nowPlayingInfo.keys.joined(separator: ", "))",
category: .player
)
infoCenter.nowPlayingInfo = nowPlayingInfo
// Only set playbackState on non-tvOS platforms.
// tvOS requires com.apple.mediaremote.set-playback-state entitlement which is restricted.
// The MPNowPlayingInfoPropertyPlaybackRate property is sufficient to indicate state.
#if !os(tvOS)
infoCenter.playbackState = isPlaying ? .playing : .paused
#endif
LoggingService.shared.debug(
"Updated Now Playing: \(video.id.id) - title: \(title), duration: \(duration), time: \(currentTime), playing: \(isPlaying), live: \(isLive)",
category: .player
)
}
/// Updates playback time without changing other metadata.
func updatePlaybackTime(currentTime: TimeInterval, duration: TimeInterval, isPlaying: Bool) {
guard var info = infoCenter.nowPlayingInfo else { return }
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
info[MPMediaItemPropertyPlaybackDuration] = duration
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
infoCenter.nowPlayingInfo = info
#if !os(tvOS)
infoCenter.playbackState = isPlaying ? .playing : .paused
#endif
}
/// Updates playback rate (playing/paused state).
/// Also updates elapsed time to ensure Now Playing info persists in Control Center.
func updatePlaybackRate(isPlaying: Bool, currentTime: TimeInterval? = nil) {
LoggingService.shared.debug(
"updatePlaybackRate called: isPlaying=\(isPlaying), currentTime=\(currentTime ?? -1)",
category: .player
)
guard var info = infoCenter.nowPlayingInfo else {
LoggingService.shared.warning(
"updatePlaybackRate: nowPlayingInfo is nil, cannot update",
category: .player
)
return
}
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
// When pausing, we must also update the elapsed time to ensure iOS
// properly preserves the Now Playing info in Control Center.
// Without this, iOS may clear the metadata when playback stops.
if let time = currentTime {
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = time
}
infoCenter.nowPlayingInfo = info
#if !os(tvOS)
infoCenter.playbackState = isPlaying ? .playing : .paused
#endif
LoggingService.shared.debug(
"updatePlaybackRate completed: rate=\(isPlaying ? 1.0 : 0.0), info keys=\(info.keys.count)",
category: .player
)
}
/// Immediately updates elapsed playback time (used for seek feedback in Control Center).
func updatePlaybackTimeImmediate(_ time: TimeInterval) {
guard var info = infoCenter.nowPlayingInfo else { return }
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = time
infoCenter.nowPlayingInfo = info
}
/// Loads artwork from local path (for offline playback) or URL and updates Now Playing info.
/// - Parameters:
/// - url: Remote URL to fetch artwork from (used as fallback if localPath fails)
/// - localPath: Local file path for offline artwork (tried first if provided)
func loadArtwork(from url: URL?, localPath: URL? = nil) async {
// 1. Try local path first (for offline playback of downloaded videos)
if let localPath {
do {
let data = try Data(contentsOf: localPath)
#if os(iOS) || os(tvOS)
if let image = UIImage(data: data) {
artworkImage = image
updateNowPlayingWithCurrentArtwork()
LoggingService.shared.debug(
"Loaded artwork from local path: \(localPath.lastPathComponent)",
category: .player
)
return
}
#elseif os(macOS)
if let image = NSImage(data: data) {
artworkImage = image
updateNowPlayingWithCurrentArtwork()
LoggingService.shared.debug(
"Loaded artwork from local path: \(localPath.lastPathComponent)",
category: .player
)
return
}
#endif
} catch {
LoggingService.shared.debug(
"Local artwork not available, falling back to network: \(error.localizedDescription)",
category: .player
)
}
}
// 2. Fall back to network fetch
guard let url else {
artworkImage = nil
return
}
do {
let (data, _) = try await URLSession.shared.data(from: url)
#if os(iOS) || os(tvOS)
artworkImage = UIImage(data: data)
#elseif os(macOS)
artworkImage = NSImage(data: data)
#endif
updateNowPlayingWithCurrentArtwork()
} catch {
LoggingService.shared.error(
"Failed to load artwork: \(error.localizedDescription)",
category: .player
)
}
}
/// Helper to update Now Playing info with current artwork
private func updateNowPlayingWithCurrentArtwork() {
if let video = currentVideo,
let info = infoCenter.nowPlayingInfo,
let duration = info[MPMediaItemPropertyPlaybackDuration] as? TimeInterval,
let currentTime = info[MPNowPlayingInfoPropertyElapsedPlaybackTime] as? TimeInterval,
let rate = info[MPNowPlayingInfoPropertyPlaybackRate] as? Double {
updateNowPlaying(
video: video,
currentTime: currentTime,
duration: duration,
isPlaying: rate > 0
)
}
}
/// Clears Now Playing info.
func clearNowPlaying() {
infoCenter.nowPlayingInfo = nil
#if !os(tvOS)
infoCenter.playbackState = .stopped
#endif
currentVideo = nil
artworkImage = nil
LoggingService.shared.debug("Cleared Now Playing info", category: .player)
}
// MARK: - Remote Commands
/// Removes all existing command targets to allow reconfiguration.
private func removeAllTargets() {
commandCenter.playCommand.removeTarget(nil)
commandCenter.pauseCommand.removeTarget(nil)
commandCenter.togglePlayPauseCommand.removeTarget(nil)
commandCenter.skipForwardCommand.removeTarget(nil)
commandCenter.skipBackwardCommand.removeTarget(nil)
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
commandCenter.nextTrackCommand.removeTarget(nil)
commandCenter.previousTrackCommand.removeTarget(nil)
}
/// Configures remote commands based on current settings.
/// Call this method when settings change to reconfigure the commands.
func configureRemoteCommands() {
// Remove existing targets to prevent duplicate handlers
removeAllTargets()
// Read settings from active preset's cached global settings
if let layoutService = playerControlsLayoutService {
Task {
let layout = await layoutService.activeLayout()
await MainActor.run {
self.configureRemoteCommandsWithSettings(
mode: layout.globalSettings.systemControlsMode,
duration: layout.globalSettings.systemControlsSeekDuration
)
}
}
// Return early - async task will call configureRemoteCommandsWithSettings
return
}
// Fallback to cached defaults if no layout service
let mode = GlobalLayoutSettings.cached.systemControlsMode
let duration = GlobalLayoutSettings.cached.systemControlsSeekDuration
configureRemoteCommandsWithSettings(mode: mode, duration: duration)
}
/// Configures remote commands with the specified settings.
/// - Parameters:
/// - mode: The system controls mode (seek or skip track).
/// - duration: The seek duration when mode is .seek.
private func configureRemoteCommandsWithSettings(mode: SystemControlsMode, duration: SystemControlsSeekDuration) {
// Remove existing targets (in case called from async path)
removeAllTargets()
// Play
commandCenter.playCommand.isEnabled = true
commandCenter.playCommand.addTarget { [weak self] _ in
LoggingService.shared.debug("Remote playCommand received", category: .player)
guard let self else {
LoggingService.shared.warning("Remote playCommand: self is nil", category: .player)
return .commandFailed
}
self.playerService?.resume()
return .success
}
// Pause
commandCenter.pauseCommand.isEnabled = true
commandCenter.pauseCommand.addTarget { [weak self] _ in
LoggingService.shared.debug("Remote pauseCommand received", category: .player)
guard let self else {
LoggingService.shared.warning("Remote pauseCommand: self is nil", category: .player)
return .commandFailed
}
self.playerService?.pause()
return .success
}
// Toggle play/pause
commandCenter.togglePlayPauseCommand.isEnabled = true
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
LoggingService.shared.debug("Remote togglePlayPauseCommand received", category: .player)
guard let self else {
LoggingService.shared.warning("Remote togglePlayPauseCommand: self is nil", category: .player)
return .commandFailed
}
self.playerService?.togglePlayPause()
return .success
}
// Configure skip commands based on mode
let seekEnabled = mode == .seek
commandCenter.skipForwardCommand.isEnabled = seekEnabled
commandCenter.skipBackwardCommand.isEnabled = seekEnabled
if seekEnabled {
commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: duration.timeInterval)]
commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: duration.timeInterval)]
// Skip forward
commandCenter.skipForwardCommand.addTarget { [weak self] event in
guard let self,
let skipEvent = event as? MPSkipIntervalCommandEvent else {
return .commandFailed
}
// Immediately update Now Playing time to prevent UI jumping
if let currentTime = self.infoCenter.nowPlayingInfo?[MPNowPlayingInfoPropertyElapsedPlaybackTime] as? TimeInterval,
let videoDuration = self.infoCenter.nowPlayingInfo?[MPMediaItemPropertyPlaybackDuration] as? TimeInterval {
let newTime = min(currentTime + skipEvent.interval, videoDuration)
self.updatePlaybackTimeImmediate(newTime)
}
Task {
self.playerService?.seekForward(by: skipEvent.interval)
}
return .success
}
// Skip backward
commandCenter.skipBackwardCommand.addTarget { [weak self] event in
guard let self,
let skipEvent = event as? MPSkipIntervalCommandEvent else {
return .commandFailed
}
// Immediately update Now Playing time to prevent UI jumping
if let currentTime = self.infoCenter.nowPlayingInfo?[MPNowPlayingInfoPropertyElapsedPlaybackTime] as? TimeInterval {
let newTime = max(currentTime - skipEvent.interval, 0)
self.updatePlaybackTimeImmediate(newTime)
}
Task {
self.playerService?.seekBackward(by: skipEvent.interval)
}
return .success
}
}
// Seek (scrubbing) - always enabled
commandCenter.changePlaybackPositionCommand.isEnabled = true
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let self,
let positionEvent = event as? MPChangePlaybackPositionCommandEvent else {
return .commandFailed
}
// Immediately update Now Playing time to prevent UI jumping back
self.updatePlaybackTimeImmediate(positionEvent.positionTime)
Task {
await self.playerService?.seek(to: positionEvent.positionTime)
}
return .success
}
// Next/Previous track - always enabled
commandCenter.nextTrackCommand.isEnabled = true
commandCenter.nextTrackCommand.addTarget { [weak self] _ in
guard let self else { return .commandFailed }
Task {
await self.playerService?.playNext()
}
return .success
}
commandCenter.previousTrackCommand.isEnabled = true
commandCenter.previousTrackCommand.addTarget { [weak self] _ in
guard let self else { return .commandFailed }
Task {
await self.playerService?.playPrevious()
}
return .success
}
LoggingService.shared.debug(
"Remote commands configured: mode=\(mode), seekDuration=\(duration.rawValue)s",
category: .player
)
}
}

View File

@@ -0,0 +1,313 @@
//
// PlayerBackend.swift
// Yattee
//
// Abstract interface for video playback backends.
//
import Foundation
import AVFoundation
import SwiftUI
#if os(iOS) || os(tvOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
// MARK: - Backend State
/// Captured state for backend switching.
struct BackendState: Sendable {
let currentTime: TimeInterval
let duration: TimeInterval
let rate: Float
let volume: Float
let isMuted: Bool
let isPlaying: Bool
init(
currentTime: TimeInterval = 0,
duration: TimeInterval = 0,
rate: Float = 1.0,
volume: Float = 1.0,
isMuted: Bool = false,
isPlaying: Bool = false
) {
self.currentTime = currentTime
self.duration = duration
self.rate = rate
self.volume = volume
self.isMuted = isMuted
self.isPlaying = isPlaying
}
}
// MARK: - Stream Format
/// Stream format categories for backend compatibility.
enum StreamFormat: String, CaseIterable, Sendable {
case hls // HTTP Live Streaming
case dash // DASH/MPD
case mp4H264 // MP4 with H.264
case mp4H265 // MP4 with H.265/HEVC
case webmVP9 // WebM with VP9
case webmAV1 // WebM with AV1
case audioAAC // AAC audio
case audioOpus // Opus audio
case audioMP3 // MP3 audio
/// Whether MPV can play this format.
var isMPVCompatible: Bool {
// MPV supports all formats
true
}
/// Detect format from stream properties.
static func detect(from stream: Stream) -> StreamFormat {
let format = stream.format.lowercased()
let videoCodec = stream.videoCodec?.lowercased() ?? ""
let audioCodec = stream.audioCodec?.lowercased() ?? ""
let mimeType = stream.mimeType?.lowercased() ?? ""
// Check for HLS
if mimeType.contains("mpegurl") || format == "hls" {
return .hls
}
// Check for DASH
if mimeType.contains("dash") || format == "dash" {
return .dash
}
// Audio-only streams
if stream.isAudioOnly {
if audioCodec.contains("opus") {
return .audioOpus
} else if audioCodec.contains("mp3") || mimeType.contains("mp3") {
return .audioMP3
} else {
return .audioAAC
}
}
// Video streams
if format == "webm" || mimeType.contains("webm") {
if videoCodec.contains("av1") || videoCodec.contains("av01") {
return .webmAV1
} else {
return .webmVP9
}
}
if format == "mp4" || mimeType.contains("mp4") {
if videoCodec.contains("hev") || videoCodec.contains("hvc") || videoCodec.contains("265") {
return .mp4H265
} else {
return .mp4H264
}
}
// Default to MP4 H.264
return .mp4H264
}
}
// MARK: - Backend Type
/// Available player backend types.
enum PlayerBackendType: String, CaseIterable, Codable, Sendable {
case mpv = "mpv"
var displayName: String {
"MPV"
}
var supportedFormats: Set<StreamFormat> {
Set(StreamFormat.allCases)
}
/// Whether this backend supports AirPlay.
var supportsAirPlay: Bool {
false
}
/// Whether this backend supports Picture-in-Picture.
var supportsPiP: Bool {
false // MPV PiP requires additional work
}
}
// MARK: - Backend Delegate
/// Delegate protocol for backend callbacks.
@MainActor
protocol PlayerBackendDelegate: AnyObject {
func backend(_ backend: any PlayerBackend, didUpdateTime time: TimeInterval)
func backend(_ backend: any PlayerBackend, didUpdateDuration duration: TimeInterval)
func backend(_ backend: any PlayerBackend, didChangeState state: PlaybackState)
func backend(_ backend: any PlayerBackend, didUpdateBufferedTime time: TimeInterval)
func backend(_ backend: any PlayerBackend, didUpdateBufferProgress progress: Int)
func backend(_ backend: any PlayerBackend, didEncounterError error: Error)
func backend(_ backend: any PlayerBackend, didUpdateVideoSize width: Int, height: Int)
func backend(_ backend: any PlayerBackend, didUpdateRetryState currentRetry: Int, maxRetries: Int, isRetrying: Bool, exhausted: Bool)
func backend(_ backend: any PlayerBackend, didRequestStreamRefresh atTime: TimeInterval?)
func backendDidBecomeReady(_ backend: any PlayerBackend)
func backendDidFinishPlaying(_ backend: any PlayerBackend)
}
// MARK: - Player Backend Protocol
/// Abstract interface for video playback backends.
/// Currently implemented by MPVBackend.
@MainActor
protocol PlayerBackend: AnyObject {
/// The type of this backend.
var backendType: PlayerBackendType { get }
/// Delegate for callbacks.
var delegate: PlayerBackendDelegate? { get set }
/// Current playback time in seconds.
var currentTime: TimeInterval { get }
/// Total duration in seconds.
var duration: TimeInterval { get }
/// Buffered time in seconds.
var bufferedTime: TimeInterval { get }
/// Whether the backend is ready to play.
var isReady: Bool { get }
/// Whether playback is currently active.
var isPlaying: Bool { get }
/// Current playback rate (1.0 = normal).
var rate: Float { get set }
/// Current volume (0.0 - 1.0).
var volume: Float { get set }
/// Whether audio is muted.
var isMuted: Bool { get set }
/// Formats this backend can play.
var supportedFormats: Set<StreamFormat> { get }
// MARK: - Playback Control
/// Load a stream for playback.
/// - Parameters:
/// - stream: The video (or muxed video+audio) stream to play
/// - audioStream: Optional separate audio stream (for video-only streams)
/// - autoplay: Whether to start playback automatically
/// - useEDL: For MPV, whether to use EDL combined streams (ignored by AVPlayer)
func load(stream: Stream, audioStream: Stream?, autoplay: Bool, useEDL: Bool) async throws
/// Start or resume playback.
func play()
/// Pause playback.
func pause()
/// Stop playback and release resources.
func stop()
/// Seek to a specific time.
/// - Parameters:
/// - time: The time to seek to in seconds
/// - showLoading: If true, show loading state during seek (e.g., for SponsorBlock intro skips)
func seek(to time: TimeInterval, showLoading: Bool) async
/// Signal that an initial seek will be performed after load completes.
/// This allows the backend to defer ready callbacks until the seek completes,
/// preventing a flash of the video at position 0 before jumping to resume position.
func prepareForInitialSeek()
// MARK: - Backend Switching
/// Capture current state for switching.
func captureState() -> BackendState
/// Restore state after switching.
func restore(state: BackendState) async
/// Prepare for handoff to another backend.
func prepareForHandoff()
// MARK: - View
#if os(iOS) || os(tvOS)
/// The view displaying video content (UIKit).
var playerView: UIView? { get }
#elseif os(macOS)
/// The view displaying video content (AppKit).
var playerView: NSView? { get }
#endif
// MARK: - Background Playback
/// Handle scene phase changes for background playback.
/// - Parameters:
/// - phase: The new scene phase
/// - backgroundEnabled: Whether background playback is enabled in settings
/// - isPiPActive: Whether Picture-in-Picture is currently active
func handleScenePhase(_ phase: ScenePhase, backgroundEnabled: Bool, isPiPActive: Bool)
}
// MARK: - Default Implementations
extension PlayerBackend {
/// Check if this backend can play a given stream.
func canPlay(stream: Stream) -> Bool {
let format = StreamFormat.detect(from: stream)
return supportedFormats.contains(format)
}
/// Capture current state with all properties.
func captureState() -> BackendState {
BackendState(
currentTime: currentTime,
duration: duration,
rate: rate,
volume: volume,
isMuted: isMuted,
isPlaying: isPlaying
)
}
/// Default implementation does nothing.
func handleScenePhase(_ phase: ScenePhase, backgroundEnabled: Bool, isPiPActive: Bool) {
// No-op by default
}
}
// MARK: - Backend Errors
/// Errors that can occur during backend operations.
enum BackendError: LocalizedError {
case unsupportedFormat(StreamFormat)
case loadFailed(String)
case seekFailed
case notReady
case switchFailed(String)
case backendUnavailable(PlayerBackendType)
var errorDescription: String? {
switch self {
case .unsupportedFormat(let format):
return "Unsupported stream format: \(format.rawValue)"
case .loadFailed(let reason):
return "Failed to load stream: \(reason)"
case .seekFailed:
return "Failed to seek to position"
case .notReady:
return "Backend is not ready for playback"
case .switchFailed(let reason):
return "Failed to switch backends: \(reason)"
case .backendUnavailable(let type):
return "\(type.displayName) backend is not available"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,670 @@
//
// PlayerState.swift
// Yattee
//
// Observable state for the video player.
//
import Foundation
import AVFoundation
/// The current state of video playback.
enum PlaybackState: Equatable, Sendable {
case idle
case loading
case ready
case playing
case paused
case buffering
case ended
case failed(Error)
static func == (lhs: PlaybackState, rhs: PlaybackState) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle),
(.loading, .loading),
(.ready, .ready),
(.playing, .playing),
(.paused, .paused),
(.buffering, .buffering),
(.ended, .ended):
return true
case (.failed, .failed):
return true // Compare errors by existence, not content
default:
return false
}
}
}
/// Retry state for stream loading.
struct RetryState: Equatable, Sendable {
/// Current retry number (1-5), not counting initial attempt.
let currentRetry: Int
/// Maximum number of retries (5).
let maxRetries: Int
/// Whether a retry is currently in progress.
let isRetrying: Bool
/// Whether all retries have been exhausted.
let exhausted: Bool
static let idle = RetryState(currentRetry: 0, maxRetries: 5, isRetrying: false, exhausted: false)
/// Text to display during retries (e.g., "Retrying... (1/5)").
var displayText: String? {
guard isRetrying, currentRetry > 0 else { return nil }
return String(localized: "player.retry.status \(currentRetry) \(maxRetries)")
}
}
/// Represents a queued video for playback.
struct QueuedVideo: Identifiable, Equatable, Sendable, Codable {
let id: String
let video: Video
let stream: Stream?
let audioStream: Stream?
let captions: [Caption]
let startTime: TimeInterval?
let addedAt: Date
let queueSource: QueueSource?
init(
video: Video,
stream: Stream? = nil,
audioStream: Stream? = nil,
captions: [Caption] = [],
startTime: TimeInterval? = nil,
queueSource: QueueSource? = nil
) {
self.id = UUID().uuidString
self.video = video
self.stream = stream
self.audioStream = audioStream
self.captions = captions
self.startTime = startTime
self.addedAt = Date()
self.queueSource = queueSource
}
/// Internal init that preserves existing ID and addedAt (for updating streams).
init(
id: String,
video: Video,
stream: Stream?,
audioStream: Stream?,
captions: [Caption],
startTime: TimeInterval?,
addedAt: Date,
queueSource: QueueSource?
) {
self.id = id
self.video = video
self.stream = stream
self.audioStream = audioStream
self.captions = captions
self.startTime = startTime
self.addedAt = addedAt
self.queueSource = queueSource
}
static func == (lhs: QueuedVideo, rhs: QueuedVideo) -> Bool {
lhs.id == rhs.id
}
}
/// Playback rate options.
enum PlaybackRate: Double, CaseIterable, Identifiable, Sendable {
case x025 = 0.25
case x05 = 0.5
case x075 = 0.75
case x1 = 1.0
case x125 = 1.25
case x15 = 1.5
case x175 = 1.75
case x2 = 2.0
case x25 = 2.5
case x3 = 3.0
var id: Double { rawValue }
var displayText: String {
if rawValue == 1.0 {
return String(localized: "player.playbackRate.normal")
}
return String(format: "%.2gx", rawValue)
}
/// Compact display text that always shows numeric value (e.g., "1x", "1.5x").
/// Use this in space-constrained UI like the player pill.
var compactDisplayText: String {
String(format: "%.2gx", rawValue)
}
}
/// Picture-in-Picture state.
enum PiPState: Equatable, Sendable {
case inactive
case active
}
/// Queue playback mode.
enum QueueMode: String, CaseIterable, Codable, Sendable {
case normal
case repeatAll
case repeatOne
case shuffle
/// SF Symbol icon for this mode.
var icon: String {
switch self {
case .normal: "list.bullet"
case .repeatAll: "repeat"
case .repeatOne: "repeat.1"
case .shuffle: "shuffle"
}
}
/// Localized display name.
var displayName: String {
switch self {
case .normal: String(localized: "queue.mode.normal")
case .repeatAll: String(localized: "queue.mode.repeatAll")
case .repeatOne: String(localized: "queue.mode.repeatOne")
case .shuffle: String(localized: "queue.mode.shuffle")
}
}
/// Loads saved queue mode from UserDefaults.
static func loadSaved() -> QueueMode {
guard let saved = UserDefaults.standard.string(forKey: "queueMode"),
let mode = QueueMode(rawValue: saved) else {
return .normal
}
return mode
}
/// Saves this mode to UserDefaults.
func save() {
UserDefaults.standard.set(rawValue, forKey: "queueMode")
}
}
/// Observable player state.
@MainActor
@Observable
final class PlayerState {
// MARK: - Current Video
/// The currently playing video.
private(set) var currentVideo: Video?
/// The stream being used for playback.
private(set) var currentStream: Stream?
/// The separate audio stream (for video-only streams).
private(set) var currentAudioStream: Stream?
/// Current playback state.
private(set) var playbackState: PlaybackState = .idle
/// Current retry state for stream loading.
private(set) var retryState: RetryState = .idle
// MARK: - Time & Progress
/// Current playback time in seconds.
var currentTime: TimeInterval = 0
/// Total duration in seconds.
var duration: TimeInterval = 0
/// Buffered time in seconds.
var bufferedTime: TimeInterval = 0
/// Whether duration updates from the player backend should be ignored.
/// Set to true when playing through fast endpoint where duration is known from API
/// but file size is unknown (progressive download).
private(set) var isDurationLockedFromAPI: Bool = false
/// Whether the current video/stream is live.
var isLive: Bool {
currentVideo?.isLive == true || currentStream?.isLive == true
}
/// Whether SMB media playback is currently active.
/// Used to prevent SMB directory browsing while SMB streaming is in progress,
/// as libsmbclient has internal state conflicts when used concurrently.
var isSMBPlaybackActive: Bool {
guard let video = currentVideo else { return false }
guard playbackState == .playing || playbackState == .paused || playbackState == .buffering else {
return false
}
return video.id.isSMBSource
}
/// Progress as a fraction (0-1).
var progress: Double {
guard duration > 0 else { return 0 }
return min(currentTime / duration, 1.0)
}
/// Formatted current time string.
var formattedCurrentTime: String {
// For live streams, show "LIVE" instead of time
if isLive {
return "LIVE"
}
return formatTime(currentTime)
}
/// Formatted duration string.
var formattedDuration: String {
// For live streams, show "LIVE" instead of duration
if isLive {
return "LIVE"
}
return formatTime(duration)
}
/// Formatted remaining time string.
var formattedRemainingTime: String {
"-" + formatTime(max(0, duration - currentTime))
}
// MARK: - Playback Settings
/// Current playback rate.
var rate: PlaybackRate = .x1
/// Whether playback is muted.
var isMuted: Bool = false
/// Volume level (0-1).
var volume: Float = 1.0
// MARK: - Queue
/// Videos queued for playback (upcoming videos only, not including current).
private(set) var queue: [QueuedVideo] = []
/// History of previously played videos (for going back).
private(set) var history: [QueuedVideo] = []
/// Maximum number of videos to keep in history.
private let maxHistorySize = 20
/// Whether there's a previous video in history.
var hasPrevious: Bool {
!history.isEmpty
}
/// Whether there's a next video in queue.
/// Since queue only contains upcoming videos, we just check if it's not empty.
var hasNext: Bool {
!queue.isEmpty
}
/// Current queue playback mode.
var queueMode: QueueMode = .loadSaved() {
didSet {
queueMode.save()
}
}
// MARK: - SponsorBlock
/// SponsorBlock segments for current video.
var sponsorSegments: [SponsorBlockSegment] = []
/// Categories to auto-skip.
var autoSkipCategories: Set<SponsorBlockCategory> = Set(
SponsorBlockCategory.allCases.filter { $0.defaultAutoSkip }
)
/// Whether SponsorBlock is enabled.
var sponsorBlockEnabled: Bool = true
/// Current segment being shown (for skip notification).
var currentSegment: SponsorBlockSegment?
// MARK: - Return YouTube Dislike
/// Dislike count from Return YouTube Dislike API.
var dislikeCount: Int?
// MARK: - Picture-in-Picture
/// Current PiP state.
var pipState: PiPState = .inactive
/// Whether PiP is possible.
var isPiPPossible: Bool = false
// MARK: - Chapters
/// Video chapters if available.
var chapters: [VideoChapter] = []
/// Current chapter based on playback time.
var currentChapter: VideoChapter? {
chapters.last { $0.startTime <= currentTime }
}
// MARK: - Storyboards
/// Available storyboards for seek preview thumbnails.
var storyboards: [Storyboard] = []
/// Preferred storyboard for preview (highest quality available).
var preferredStoryboard: Storyboard? {
storyboards.highest()
}
// MARK: - Video Details
/// Video details loading state.
var videoDetailsState: VideoDetailsLoadState = .idle
// MARK: - Comments
/// Preloaded comments for the current video.
var comments: [Comment] = []
/// Comments loading state.
var commentsState: CommentsLoadState = .idle
/// Continuation token for loading more comments.
var commentsContinuation: String?
// MARK: - UI State
/// Whether controls are visible.
var controlsVisible: Bool = true
/// Whether seeking is in progress.
var isSeeking: Bool = false
/// Whether the video is being closed (used to hide UI before dismissal).
var isClosingVideo: Bool = false
/// Whether the MPV debug overlay is visible.
var showDebugOverlay: Bool = false
/// Whether player controls are locked (buttons/gestures disabled except settings and dismiss).
var isControlsLocked: Bool = false
/// Actual video track aspect ratio (width/height).
/// nil means unknown, default to 16:9.
var videoAspectRatio: Double?
/// Whether the first frame of the current video has been rendered.
/// Reset when a new video loads, set when backendDidBecomeReady is called.
var isFirstFrameReady: Bool = false
/// Whether the buffer is ready and playback can start smoothly.
/// This is set after waiting for sufficient buffer before calling play().
/// Used to keep thumbnail visible until video is truly ready to play.
var isBufferReady: Bool = false
/// Current buffer progress as a percentage (0-100).
/// Updated by MPV's cache-buffering-state property during initial buffering.
/// nil when not buffering or when using AVPlayer backend.
var bufferProgress: Int?
/// Whether the video is vertical (portrait orientation).
var isVerticalVideo: Bool {
guard let ratio = videoAspectRatio else { return false }
return ratio < 1.0
}
/// Display aspect ratio for UI - falls back to 16:9 if unknown.
var displayAspectRatio: Double {
videoAspectRatio ?? (16.0 / 9.0)
}
// MARK: - Methods
/// Updates the current video and stream.
func setCurrentVideo(_ video: Video?, stream: Stream?, audioStream: Stream? = nil) {
// When switching to a different video, reset time-related state to prevent
// the old video's progress from being saved to the new video
if video?.id != currentVideo?.id {
dislikeCount = nil
currentTime = 0
duration = 0
bufferedTime = 0
isDurationLockedFromAPI = false
// Reset video details state for new video
videoDetailsState = .idle
// Reset comments for new video
comments = []
commentsState = .idle
commentsContinuation = nil
// Reset storyboards to prevent previous video's storyboards from appearing
storyboards = []
}
currentVideo = video
currentStream = stream
currentAudioStream = audioStream
if video == nil {
reset()
}
}
/// Whether the playback has failed.
var isFailed: Bool {
if case .failed = playbackState { return true }
return false
}
/// Error message if playback failed, nil otherwise.
var errorMessage: String? {
if case .failed(let error) = playbackState {
return error.localizedDescription
}
return nil
}
/// Updates the playback state.
func setPlaybackState(_ state: PlaybackState) {
playbackState = state
}
/// Updates the retry state.
func setRetryState(_ state: RetryState) {
retryState = state
}
/// Locks duration from API metadata, preventing backend updates.
/// Used for fast endpoint streams where MPV can't determine accurate duration
/// because the file is being progressively downloaded.
func lockDuration(_ duration: TimeInterval) {
self.duration = duration
self.isDurationLockedFromAPI = true
}
/// Resets player state.
func reset() {
currentTime = 0
duration = 0
bufferedTime = 0
isDurationLockedFromAPI = false
sponsorSegments = []
currentSegment = nil
dislikeCount = nil
chapters = []
storyboards = []
playbackState = .idle
retryState = .idle
isClosingVideo = false
videoAspectRatio = nil
isFirstFrameReady = false
isBufferReady = false
bufferProgress = nil
videoDetailsState = .idle
comments = []
commentsState = .idle
commentsContinuation = nil
}
/// Adds a video to the end of the queue.
func addToQueue(_ video: Video, stream: Stream? = nil, audioStream: Stream? = nil, captions: [Caption] = [], queueSource: QueueSource? = nil) {
queue.append(QueuedVideo(video: video, stream: stream, audioStream: audioStream, captions: captions, queueSource: queueSource))
}
/// Adds multiple videos to the end of the queue.
func addToQueue(_ videos: [Video], queueSource: QueueSource? = nil) {
let queuedVideos = videos.map { QueuedVideo(video: $0, queueSource: queueSource) }
queue.append(contentsOf: queuedVideos)
}
/// Inserts a video at the front of the queue (to play next).
func insertNext(_ video: Video, stream: Stream? = nil, audioStream: Stream? = nil, captions: [Caption] = [], queueSource: QueueSource? = nil) {
queue.insert(QueuedVideo(video: video, stream: stream, audioStream: audioStream, captions: captions, queueSource: queueSource), at: 0)
}
/// Removes a video from the queue at the specified index.
func removeFromQueue(at index: Int) {
guard index >= 0, index < queue.count else { return }
queue.remove(at: index)
}
/// Updates a queue item with preloaded video details and streams.
/// Preserves the item's ID for stable SwiftUI identity.
func updateQueueItemWithPreload(at index: Int, video: Video, stream: Stream?, audioStream: Stream?) {
guard index >= 0 && index < queue.count else { return }
let item = queue[index]
queue[index] = QueuedVideo(
id: item.id,
video: video,
stream: stream,
audioStream: audioStream,
captions: item.captions,
startTime: item.startTime,
addedAt: item.addedAt,
queueSource: item.queueSource
)
}
/// Moves a queue item from one position to another.
func moveQueueItem(from sourceIndex: Int, to destinationIndex: Int) {
guard sourceIndex >= 0, sourceIndex < queue.count,
destinationIndex >= 0, destinationIndex <= queue.count,
sourceIndex != destinationIndex else { return }
let item = queue.remove(at: sourceIndex)
let adjustedDestination = destinationIndex > sourceIndex ? destinationIndex - 1 : destinationIndex
queue.insert(item, at: adjustedDestination)
}
/// Clears the queue.
func clearQueue() {
queue.removeAll()
}
/// Removes and returns the next video in queue.
/// Since queue only contains upcoming videos, this removes the first item.
func advanceQueue() -> QueuedVideo? {
guard !queue.isEmpty else { return nil }
return queue.removeFirst()
}
/// Removes and returns the previous video from history.
func retreatQueue() -> QueuedVideo? {
guard !history.isEmpty else { return nil }
return history.removeLast()
}
/// Pushes a video to history (for going back later).
/// Keeps history limited to maxHistorySize.
func pushToHistory(_ video: QueuedVideo) {
history.append(video)
if history.count > maxHistorySize {
history.removeFirst()
}
}
/// Clears the playback history.
func clearHistory() {
history.removeAll()
}
/// Removes and returns a random video from queue (for shuffle mode).
func advanceQueueShuffle() -> QueuedVideo? {
guard !queue.isEmpty else { return nil }
let randomIndex = Int.random(in: 0..<queue.count)
return queue.remove(at: randomIndex)
}
/// Moves all history items back to queue for repeat all mode.
/// History is cleared after moving.
func recycleHistoryToQueue() {
// History is ordered oldest-first, so we insert in that order
// This puts the first played video at front of queue
queue.insert(contentsOf: history, at: 0)
history.removeAll()
}
/// Returns the next video that will be played (first in queue).
var nextQueuedVideo: QueuedVideo? {
queue.first
}
// MARK: - Private
private func formatTime(_ time: TimeInterval) -> String {
let totalSeconds = Int(time)
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%d:%02d", minutes, seconds)
}
}
}
/// Represents a chapter in a video.
struct VideoChapter: Identifiable, Sendable {
let id: UUID
let title: String
let startTime: TimeInterval
let endTime: TimeInterval?
let thumbnailURL: URL?
init(
id: UUID = UUID(),
title: String,
startTime: TimeInterval,
endTime: TimeInterval? = nil,
thumbnailURL: URL? = nil
) {
self.id = id
self.title = title
self.startTime = startTime
self.endTime = endTime
self.thumbnailURL = thumbnailURL
}
/// Duration of the chapter.
var duration: TimeInterval? {
guard let endTime else { return nil }
return endTime - startTime
}
/// Formatted start time.
var formattedStartTime: String {
let totalSeconds = Int(startTime)
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%d:%02d", minutes, seconds)
}
}
}

View File

@@ -0,0 +1,460 @@
//
// QueueManager.swift
// Yattee
//
// Manages the player queue with source tracking and continuation loading.
//
import Foundation
/// Context for media browser queue playback.
/// Stores folder info needed for on-demand stream/caption resolution.
struct MediaBrowserQueueContext: Sendable {
let source: MediaSource
let allFilesInFolder: [MediaFile]
let folderPath: String
}
/// Manages the player queue with advanced features like continuation loading.
@MainActor
@Observable
final class QueueManager {
// MARK: - Dependencies
private let contentService: ContentService
private weak var playerState: PlayerState?
private weak var playerService: PlayerService?
private weak var settingsManager: SettingsManager?
private weak var instancesManager: InstancesManager?
private weak var downloadManager: DownloadManager?
// MARK: - State
/// The current queue source for loading more items.
private(set) var currentQueueSource: QueueSource?
/// Display label for the current queue source (e.g., playlist title, channel name).
private(set) var currentQueueSourceLabel: String?
/// Whether a continuation load is in progress.
private(set) var isLoadingMore = false
/// The threshold for triggering proactive continuation loading.
/// When remaining items fall to this number or below, more items are loaded.
private let continuationThreshold = 2
/// Context for media browser queue playback (if playing from media browser).
private(set) var mediaBrowserContext: MediaBrowserQueueContext?
// MARK: - Initialization
init(contentService: ContentService) {
self.contentService = contentService
}
func setPlayerState(_ state: PlayerState) {
self.playerState = state
}
func setPlayerService(_ service: PlayerService) {
self.playerService = service
}
func setSettingsManager(_ manager: SettingsManager) {
self.settingsManager = manager
}
func setInstancesManager(_ manager: InstancesManager) {
self.instancesManager = manager
}
func setDownloadManager(_ manager: DownloadManager) {
self.downloadManager = manager
}
// MARK: - Queue Feature Toggle
/// Whether the queue feature is enabled.
var isQueueEnabled: Bool {
settingsManager?.queueEnabled ?? true
}
// MARK: - Queue Operations
/// Adds a video to the end of the queue.
func addToQueue(_ video: Video, queueSource: QueueSource? = nil) {
guard isQueueEnabled else { return }
playerState?.addToQueue(video, queueSource: queueSource ?? .manual)
// Update queue source if this is the first item or source is more specific
if currentQueueSource == nil || queueSource != nil {
currentQueueSource = queueSource
}
}
/// Adds multiple videos to the end of the queue.
func addToQueue(_ videos: [Video], queueSource: QueueSource? = nil) {
guard isQueueEnabled, !videos.isEmpty else { return }
playerState?.addToQueue(videos, queueSource: queueSource ?? .manual)
// Update queue source
if currentQueueSource == nil || queueSource != nil {
currentQueueSource = queueSource
}
}
/// Inserts a video to play next (after the current item).
func playNext(_ video: Video, queueSource: QueueSource? = nil) {
guard isQueueEnabled else { return }
playerState?.insertNext(video, queueSource: queueSource ?? .manual)
// Update queue source if this is the first item
if currentQueueSource == nil {
currentQueueSource = queueSource
}
}
/// Removes a video from the queue at the specified index.
func removeFromQueue(at index: Int) {
playerState?.removeFromQueue(at: index)
}
/// Moves a queue item from one position to another.
func moveQueueItem(from sourceIndex: Int, to destinationIndex: Int) {
playerState?.moveQueueItem(from: sourceIndex, to: destinationIndex)
}
/// Clears the entire queue.
func clearQueue() {
playerState?.clearQueue()
currentQueueSource = nil
currentQueueSourceLabel = nil
mediaBrowserContext = nil
}
// MARK: - Play from List
/// Stream info provider for downloaded content or pre-resolved streams.
typealias StreamProvider = (Video) -> (stream: Stream?, audioStream: Stream?, captions: [Caption])?
/// Plays a video from a list, setting up queue and history appropriately.
/// - Parameters:
/// - videos: All videos in the list
/// - index: Index of video to play
/// - queueSource: Source for continuation loading
/// - sourceLabel: Display label for the queue source (e.g., playlist title, channel name)
/// - startTime: Optional start time for the video
/// - streamProvider: Optional closure to get stream info (for downloaded content)
func playFromList(
videos: [Video],
index: Int,
queueSource: QueueSource?,
sourceLabel: String? = nil,
startTime: TimeInterval? = nil,
streamProvider: StreamProvider? = nil
) {
guard isQueueEnabled, !videos.isEmpty, index >= 0, index < videos.count else {
// If queue disabled or invalid params, just play the video directly
if index >= 0, index < videos.count {
let video = videos[index]
if let provider = streamProvider, let result = provider(video) {
playerService?.openVideo(video, stream: result.stream!, audioStream: result.audioStream)
} else if let startTime {
playerService?.openVideo(video, startTime: startTime)
} else {
playerService?.openVideo(video)
}
}
return
}
// 1. Clear queue
clearQueue()
// 2. Populate history with preceding videos (skip if incognito or history disabled)
if index > 0, settingsManager?.incognitoModeEnabled != true, settingsManager?.saveWatchHistory != false {
playerState?.clearHistory()
for i in 0..<index {
let video = videos[i]
if let provider = streamProvider, let result = provider(video) {
let item = QueuedVideo(video: video, stream: result.stream, audioStream: result.audioStream, captions: result.captions, queueSource: queueSource)
playerState?.pushToHistory(item)
} else {
let item = QueuedVideo(video: video, queueSource: queueSource)
playerState?.pushToHistory(item)
}
}
}
// 3. Queue subsequent videos
let subsequentVideos = Array(videos.dropFirst(index + 1))
for video in subsequentVideos {
if let provider = streamProvider, let result = provider(video) {
playerState?.addToQueue(video, stream: result.stream, audioStream: result.audioStream, captions: result.captions, queueSource: queueSource)
} else {
playerState?.addToQueue(video, queueSource: queueSource ?? .manual)
}
}
// 4. Set queue source for continuation
currentQueueSource = queueSource
currentQueueSourceLabel = sourceLabel
// 5. Play the video
let videoToPlay = videos[index]
if let provider = streamProvider, let result = provider(videoToPlay) {
playerService?.openVideo(videoToPlay, stream: result.stream!, audioStream: result.audioStream)
} else if let startTime {
playerService?.openVideo(videoToPlay, startTime: startTime)
} else {
playerService?.openVideo(videoToPlay)
}
}
// MARK: - Play from Media Browser
/// Plays a video from a media browser folder, setting up queue with all playable files.
/// Stream and captions are resolved on-demand when each video plays.
/// - Parameters:
/// - files: All playable files in the folder (videos only, sorted)
/// - index: Index of the video to play
/// - source: The media source (WebDAV/SMB/local folder)
/// - allFilesInFolder: All files including subtitles (for subtitle discovery)
func playFromMediaBrowser(
files: [MediaFile],
index: Int,
source: MediaSource,
allFilesInFolder: [MediaFile]
) {
guard !files.isEmpty, index >= 0, index < files.count else { return }
let folderPath = (files[index].path as NSString).deletingLastPathComponent
let queueSource = QueueSource.mediaBrowser(sourceID: source.id, folderPath: folderPath)
// If queue is disabled, just play the single video
guard isQueueEnabled else {
let video = files[index].toVideo()
playerService?.openVideo(video)
return
}
// 1. Clear queue
clearQueue()
// 2. Store media browser context for on-demand stream/caption resolution
mediaBrowserContext = MediaBrowserQueueContext(
source: source,
allFilesInFolder: allFilesInFolder,
folderPath: folderPath
)
// 3. Populate history with preceding files (skip if incognito or history disabled)
if index > 0, settingsManager?.incognitoModeEnabled != true, settingsManager?.saveWatchHistory != false {
playerState?.clearHistory()
for i in 0..<index {
let video = files[i].toVideo()
let item = QueuedVideo(video: video, queueSource: queueSource)
playerState?.pushToHistory(item)
}
}
// 4. Queue subsequent files (without resolving streams yet - done on-demand)
for file in files.dropFirst(index + 1) {
let video = file.toVideo()
playerState?.addToQueue(video, queueSource: queueSource)
}
// 5. Set queue source and label (just folder name)
currentQueueSource = queueSource
currentQueueSourceLabel = (folderPath as NSString).lastPathComponent
// 6. Play the selected video
// PlayerService will detect media browser context and resolve stream/captions on-demand
let videoToPlay = files[index].toVideo()
playerService?.openVideo(videoToPlay)
}
/// Clears the media browser context when switching to a different queue source.
func clearMediaBrowserContext() {
mediaBrowserContext = nil
}
/// Whether there are more items that can be loaded from the queue source.
func hasMoreItems() -> Bool {
guard let source = currentQueueSource else { return false }
return source.supportsContinuation
}
/// Sets the queue source for continuation loading.
func setQueueSource(_ source: QueueSource?) {
currentQueueSource = source
}
// MARK: - Proactive Continuation Loading
/// Called when a video starts playing to trigger proactive loading.
/// This ensures next videos are pre-loaded before the user reaches the end of the queue,
/// and preloads streams for the next video for seamless transitions.
func onVideoStarted() {
guard isQueueEnabled else { return }
let queueCount = playerState?.queue.count ?? 0
// Load more videos when approaching the end of the queue
if queueCount <= continuationThreshold && hasMoreItems() && !isLoadingMore {
Task {
try? await loadMoreQueueItems()
}
}
// Preload streams for next video
preloadNextQueueStream()
}
/// Loads more items from the queue source using continuation.
func loadMoreQueueItems() async throws {
guard let source = currentQueueSource,
source.supportsContinuation,
let contentSource = source.contentSource,
let instance = instancesManager?.instance(for: contentSource),
!isLoadingMore else {
return
}
isLoadingMore = true
defer { isLoadingMore = false }
do {
let result = try await loadVideosFromSource(source, instance: instance)
playerState?.addToQueue(result.videos, queueSource: source)
// Update continuation token for next load
currentQueueSource = source.withContinuation(result.continuation)
} catch {
// Log error but don't throw - continuation loading is best-effort
LoggingService.shared.logPlayerError("Failed to load more queue items", error: error)
}
}
// MARK: - Stream Preloading
/// Task for current preload operation (cancellable).
private var preloadTask: Task<Void, Never>?
/// Preloads streams for the next video in queue for seamless transitions.
func preloadNextQueueStream() {
guard isQueueEnabled else { return }
guard let playerState, let nextItem = playerState.queue.first else { return }
// Skip if already has stream loaded
guard nextItem.stream == nil else { return }
// Skip preloading for media source videos (SMB, etc.) - streams are resolved locally
guard !nextItem.video.isFromMediaSource else { return }
// Skip preloading if video is downloaded - will use local file
if let downloadManager,
let download = downloadManager.download(for: nextItem.video.id),
download.status == .completed {
return
}
// Cancel any existing preload
preloadTask?.cancel()
preloadTask = Task {
await performStreamPreload(for: nextItem, at: 0)
}
}
private func performStreamPreload(for item: QueuedVideo, at index: Int) async {
guard let instance = instancesManager?.instance(for: item.video) else { return }
do {
// Fetch streams from API
let result = try await contentService.videoWithStreamsAndCaptions(
id: item.video.id.videoID,
instance: instance
)
// Check if cancelled or queue changed
guard !Task.isCancelled else { return }
guard let playerState, index < playerState.queue.count,
playerState.queue[index].video.id == item.video.id else { return }
// Select best streams using PlayerService's logic
guard let playerService else { return }
let (stream, audioStream) = playerService.selectStreamsForPreload(from: result.streams)
// Update queue item with full video details and preloaded streams
playerState.updateQueueItemWithPreload(at: index, video: result.video, stream: stream, audioStream: audioStream)
} catch {
// Silent failure - streams will be fetched on-demand when video plays
}
}
// MARK: - Source-Specific Loading
private func loadVideosFromSource(
_ source: QueueSource,
instance: Instance
) async throws -> (videos: [Video], continuation: String?) {
switch source {
case .channel(let channelID, let contentSource, let continuation):
// Determine which instance to use based on content source
let targetInstance = instanceForContentSource(contentSource) ?? instance
let page = try await contentService.channelVideos(
id: channelID,
instance: targetInstance,
continuation: continuation
)
return (page.videos, page.continuation)
case .playlist(let playlistID, let continuation):
// For playlists, we need to fetch the playlist and get videos
// Note: Current API doesn't support playlist continuation, so return empty
// This can be enhanced when the API supports it
_ = (playlistID, continuation)
return ([], nil)
case .search(let query, _):
// Search uses page numbers, not continuation tokens
// For now, we don't support search continuation in queue
_ = query
return ([], nil)
case .subscriptions(let continuation):
// Subscriptions feed continuation would require SubscriptionService access
// For now, return empty
_ = continuation
return ([], nil)
case .manual:
// Manual entries don't have continuation
return ([], nil)
case .mediaBrowser:
// Media browser folders are fully loaded, no continuation needed
return ([], nil)
}
}
private func instanceForContentSource(_ source: ContentSource) -> Instance? {
switch source {
case .global:
// For global content (YouTube), use any enabled instance
return instancesManager?.instances.first { $0.isEnabled }
case .federated(_, let instanceURL):
// For federated content, find matching instance
return instancesManager?.instances.first { $0.url == instanceURL }
case .extracted:
// Extracted content doesn't use instances
return nil
}
}
}

View File

@@ -0,0 +1,86 @@
//
// ReturnYouTubeDislikeAPI.swift
// Yattee
//
// Return YouTube Dislike API client for fetching video dislikes.
//
import Foundation
/// Vote data from Return YouTube Dislike API.
struct RYDVotes: Codable, Sendable {
let id: String
let likes: Int
let dislikes: Int
let rating: Double
let viewCount: Int
let deleted: Bool
}
/// Return YouTube Dislike API client.
actor ReturnYouTubeDislikeAPI {
private let httpClient: HTTPClient
/// Cache for votes by video ID.
private var votesCache: [String: RYDVotes] = [:]
/// Default Return YouTube Dislike API URL.
private static let baseURL = URL(string: "https://returnyoutubedislikeapi.com")!
init(httpClient: HTTPClient) {
self.httpClient = httpClient
}
/// Fetches vote data for a YouTube video.
func votes(for videoID: String) async throws -> RYDVotes {
// Check cache first
if let cached = votesCache[videoID] {
return cached
}
var components = URLComponents(url: Self.baseURL.appendingPathComponent("/votes"), resolvingAgainstBaseURL: false)!
components.queryItems = [
URLQueryItem(name: "videoId", value: videoID)
]
guard let url = components.url else {
throw APIError.invalidRequest
}
var request = URLRequest(url: url)
request.timeoutInterval = 10
do {
let data = try await httpClient.performRaw(request)
let decoder = JSONDecoder()
let votes = try decoder.decode(RYDVotes.self, from: data)
// Cache the result
votesCache[videoID] = votes
Task { @MainActor in
LoggingService.shared.logPlayer("RYD: fetched votes", details: "Video: \(videoID), Dislikes: \(votes.dislikes)")
}
return votes
} catch let error as DecodingError {
Task { @MainActor in
LoggingService.shared.logPlayerError("RYD decode error", error: error)
}
throw APIError.decodingError(error)
} catch let error as APIError {
// 404 means video not found
if case .notFound = error {
// Cache empty result to avoid repeated requests
let emptyVotes = RYDVotes(id: videoID, likes: 0, dislikes: 0, rating: 0, viewCount: 0, deleted: true)
votesCache[videoID] = emptyVotes
return emptyVotes
}
throw error
}
}
/// Clears the cache.
func clearCache() {
votesCache.removeAll()
}
}

View File

@@ -0,0 +1,213 @@
//
// SponsorBlockAPI.swift
// Yattee
//
// SponsorBlock API client for fetching video segments.
//
import Foundation
/// Action type for a segment.
enum SponsorBlockActionType: String, Codable, Sendable {
case skip = "skip"
case mute = "mute"
case chapter = "chapter"
case full = "full"
case poi = "poi"
}
/// A segment from SponsorBlock.
struct SponsorBlockSegment: Codable, Identifiable, Sendable {
let uuid: String
let category: SponsorBlockCategory
let actionType: SponsorBlockActionType
let segment: [Double]
let videoDuration: Double?
let locked: Int?
let votes: Int?
let segmentDescription: String?
var id: String { uuid }
/// Start time in seconds.
var startTime: Double {
segment.first ?? 0
}
/// End time in seconds.
var endTime: Double {
segment.last ?? 0
}
/// Duration of the segment.
var duration: Double {
endTime - startTime
}
/// Whether this is a point of interest (single timestamp).
var isPointOfInterest: Bool {
actionType == .poi || startTime == endTime
}
private enum CodingKeys: String, CodingKey {
case uuid = "UUID"
case category
case actionType
case segment
case videoDuration
case locked
case votes
case segmentDescription = "description"
}
}
/// SponsorBlock API client.
actor SponsorBlockAPI {
private let httpClient: HTTPClient
private var baseURL: URL
/// Cache for segments by video ID.
private var segmentCache: [String: [SponsorBlockSegment]] = [:]
/// Default SponsorBlock API URL.
private static let defaultAPIURL = URL(string: "https://sponsor.ajay.app")!
init(httpClient: HTTPClient, baseURL: URL? = nil) {
self.httpClient = httpClient
self.baseURL = baseURL ?? Self.defaultAPIURL
}
/// Updates the base URL for API requests.
/// Clears the segment cache when URL changes.
func setBaseURL(_ url: URL) {
if baseURL != url {
baseURL = url
segmentCache.removeAll()
}
}
/// Fetches segments for a YouTube video.
func segments(
for videoID: String,
categories: Set<SponsorBlockCategory> = Set(SponsorBlockCategory.allCases)
) async throws -> [SponsorBlockSegment] {
// Check cache first
if let cached = segmentCache[videoID] {
return cached.filter { categories.contains($0.category) }
}
let categoryParams = categories.map { $0.rawValue }
let categoriesJSON = try JSONEncoder().encode(categoryParams)
guard let categoriesString = String(data: categoriesJSON, encoding: .utf8) else {
throw APIError.invalidRequest
}
var components = URLComponents(url: baseURL.appendingPathComponent("/api/skipSegments"), resolvingAgainstBaseURL: false)!
components.queryItems = [
URLQueryItem(name: "videoID", value: videoID),
URLQueryItem(name: "categories", value: categoriesString)
]
guard let url = components.url else {
throw APIError.invalidRequest
}
var request = URLRequest(url: url)
request.timeoutInterval = 10
do {
let data = try await httpClient.performRaw(request)
let decoder = JSONDecoder()
let segments = try decoder.decode([SponsorBlockSegment].self, from: data)
// Cache the result
segmentCache[videoID] = segments
Task { @MainActor in
LoggingService.shared.logPlayer("SponsorBlock: \(segments.count) segments", details: "Video: \(videoID)")
}
return segments.filter { categories.contains($0.category) }
} catch let error as DecodingError {
Task { @MainActor in
LoggingService.shared.logPlayerError("SponsorBlock decode error", error: error)
}
throw APIError.decodingError(error)
} catch let error as APIError {
// 404 means no segments exist for this video
if case .notFound = error {
segmentCache[videoID] = []
return []
}
throw error
}
}
}
// MARK: - Segment Filtering
extension Array where Element == SponsorBlockSegment {
/// Filters to only skippable segments.
func skippable() -> [SponsorBlockSegment] {
filter { $0.actionType == .skip }
}
/// Filters to only segments in the given categories.
func inCategories(_ categories: Set<SponsorBlockCategory>) -> [SponsorBlockSegment] {
filter { categories.contains($0.category) }
}
/// Finds a segment containing the given time.
func segment(at time: Double) -> SponsorBlockSegment? {
first { time >= $0.startTime && time < $0.endTime }
}
/// Finds the next segment after the given time.
func nextSegment(after time: Double) -> SponsorBlockSegment? {
filter { $0.startTime > time }
.sorted { $0.startTime < $1.startTime }
.first
}
}
// MARK: - Chapter Extraction
extension Array where Element == SponsorBlockSegment {
/// Extracts chapter segments and converts them to VideoChapter array.
///
/// SponsorBlock chapters have:
/// - `actionType == .chapter`
/// - `segment[0]` = startTime
/// - `segmentDescription` = chapter title
///
/// - Parameter videoDuration: The video duration for calculating end times.
/// - Returns: Array of VideoChapter, or empty if no valid chapters found.
func extractChapters(videoDuration: TimeInterval) -> [VideoChapter] {
// Filter to chapter segments only
let chapterSegments = filter { $0.actionType == .chapter }
// Need at least 2 chapters
guard chapterSegments.count >= 2 else { return [] }
// Sort by start time
let sorted = chapterSegments.sorted { $0.startTime < $1.startTime }
// Convert to VideoChapter with proper end times
return sorted.enumerated().map { index, segment in
let title = segment.segmentDescription ?? "Chapter \(index + 1)"
let startTime = TimeInterval(segment.startTime)
let endTime: TimeInterval
if index < sorted.count - 1 {
endTime = TimeInterval(sorted[index + 1].startTime)
} else {
endTime = videoDuration
}
return VideoChapter(
title: title,
startTime: startTime,
endTime: endTime
)
}
}
}

View File

@@ -0,0 +1,399 @@
//
// StoryboardService.swift
// Yattee
//
// Service for loading and extracting storyboard preview thumbnails.
//
import CoreGraphics
import Foundation
#if os(macOS)
import AppKit
#else
import UIKit
#endif
/// Parsed VTT entry mapping time range to image URL and crop region
struct VTTEntry: Sendable {
let startTime: TimeInterval
let endTime: TimeInterval
let imageURL: URL
let cropRect: CGRect? // From #xywh fragment, nil if not present
}
/// Service for loading storyboard sprite sheets and extracting individual thumbnails.
actor StoryboardService {
/// Shared instance for use across the app.
static let shared = StoryboardService()
/// Cache of loaded sprite sheets (sheetURL -> image)
private var sheetCache: [URL: PlatformImage] = [:]
/// Currently loading sheets (to prevent duplicate loads)
private var loadingSheets: Set<URL> = []
/// Maximum number of sheets to keep in memory
private let maxCachedSheets = 10
/// Cached VTT entries per storyboard proxy URL
private var vttCache: [URL: [VTTEntry]] = [:]
/// Currently loading VTT files (to prevent duplicate loads)
private var loadingVTT: Set<URL> = []
// MARK: - Public API
/// Extracts a thumbnail for a specific time from the storyboard.
/// - Parameters:
/// - time: The time in seconds
/// - storyboard: The storyboard configuration
/// - Returns: The extracted thumbnail, or nil if not available
func thumbnail(for time: TimeInterval, from storyboard: Storyboard) async -> PlatformImage? {
// Try VTT-based loading first (proxied URL)
if let entries = await getOrLoadVTT(for: storyboard), !entries.isEmpty {
if let entry = findEntry(for: time, in: entries) {
if let image = sheetCache[entry.imageURL] {
// Use VTT crop rect if available, otherwise calculate from storyboard
let cropRect = entry.cropRect ?? storyboard.cropRect(for: time) ?? CGRect.zero
return image.cropped(to: cropRect)
}
}
}
// Fallback to direct URL loading (templateUrl)
guard let cropRect = storyboard.cropRect(for: time),
let position = storyboard.position(for: time),
let sheetURL = storyboard.sheetURL(for: position.sheetIndex),
let sheet = sheetCache[sheetURL]
else {
return nil
}
return sheet.cropped(to: cropRect)
}
/// Loads the sprite sheet for the given time if not already cached.
/// Uses VTT if available, otherwise falls back to direct URL.
/// - Parameters:
/// - time: The time in seconds
/// - storyboard: The storyboard configuration
func loadSheet(for time: TimeInterval, from storyboard: Storyboard) async {
// Try VTT-based loading first
if let entries = await getOrLoadVTT(for: storyboard), !entries.isEmpty {
if let entry = findEntry(for: time, in: entries) {
await loadSheetByURL(entry.imageURL)
return
}
}
// Fallback to direct URL
guard let position = storyboard.position(for: time),
let sheetURL = storyboard.sheetURL(for: position.sheetIndex)
else {
return
}
await loadSheetByURL(sheetURL)
}
/// Preloads sheets for a range of times (current + adjacent).
/// - Parameters:
/// - time: The center time in seconds
/// - storyboard: The storyboard configuration
func preloadNearbySheets(around time: TimeInterval, from storyboard: Storyboard) async {
// Try VTT-based loading
if let entries = await getOrLoadVTT(for: storyboard), !entries.isEmpty {
// Find entries for current time and nearby times
let timesToLoad = [time - storyboard.intervalSeconds * 25, time, time + storyboard.intervalSeconds * 25]
var urlsToLoad: Set<URL> = []
for t in timesToLoad where t >= 0 {
if let entry = findEntry(for: t, in: entries) {
urlsToLoad.insert(entry.imageURL)
}
}
await withTaskGroup(of: Void.self) { group in
for url in urlsToLoad {
if sheetCache[url] == nil, !loadingSheets.contains(url) {
group.addTask {
await self.loadSheetByURL(url)
}
}
}
}
return
}
// Fallback to direct URL loading
guard let position = storyboard.position(for: time) else { return }
let indices = [position.sheetIndex - 1, position.sheetIndex, position.sheetIndex + 1]
.filter { $0 >= 0 && $0 < storyboard.storyboardCount }
await withTaskGroup(of: Void.self) { group in
for index in indices {
guard let url = storyboard.sheetURL(for: index) else { continue }
if sheetCache[url] == nil, !loadingSheets.contains(url) {
group.addTask {
await self.loadSheetByURL(url)
}
}
}
}
}
/// Clears all caches.
/// Call this when the video changes.
func clearCache() {
sheetCache.removeAll()
loadingSheets.removeAll()
vttCache.removeAll()
loadingVTT.removeAll()
}
// MARK: - VTT Loading and Parsing
/// Gets cached VTT entries or loads them from the proxy URL
private func getOrLoadVTT(for storyboard: Storyboard) async -> [VTTEntry]? {
guard let proxyUrl = storyboard.proxyUrl else {
return nil
}
// Construct absolute VTT URL
let vttURL: URL?
if proxyUrl.hasPrefix("http://") || proxyUrl.hasPrefix("https://") {
// Already an absolute URL
vttURL = URL(string: proxyUrl)
} else if let baseURL = storyboard.instanceBaseURL {
// Relative URL - prepend base URL
var baseString = baseURL.absoluteString
if baseString.hasSuffix("/"), proxyUrl.hasPrefix("/") {
baseString = String(baseString.dropLast())
}
vttURL = URL(string: baseString + proxyUrl)
} else {
return nil
}
guard let vttURL else {
return nil
}
// Check cache
if let cached = vttCache[vttURL] {
return cached
}
// Check if already loading
guard !loadingVTT.contains(vttURL) else {
// Wait a bit and try cache again
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
return vttCache[vttURL]
}
// Load VTT
loadingVTT.insert(vttURL)
defer { loadingVTT.remove(vttURL) }
do {
let (data, response) = try await URLSession.shared.data(from: vttURL)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200
else {
return nil
}
let entries = parseVTT(data, baseURL: vttURL)
if !entries.isEmpty {
vttCache[vttURL] = entries
}
return entries
} catch {
return nil
}
}
/// Parses WebVTT data into VTTEntry array
/// - Parameters:
/// - data: The VTT file data
/// - baseURL: Base URL for resolving relative image paths
private func parseVTT(_ data: Data, baseURL: URL) -> [VTTEntry] {
guard let text = String(data: data, encoding: .utf8) else {
return []
}
var entries: [VTTEntry] = []
let lines = text.components(separatedBy: .newlines)
var i = 0
while i < lines.count {
let line = lines[i].trimmingCharacters(in: .whitespaces)
// Look for timestamp line: "00:00:00.000 --> 00:00:10.000"
if line.contains("-->") {
let times = line.components(separatedBy: "-->")
if times.count == 2,
let start = parseTimestamp(times[0].trimmingCharacters(in: .whitespaces)),
let end = parseTimestamp(times[1].trimmingCharacters(in: .whitespaces))
{
// Next line is the URL
i += 1
if i < lines.count {
let urlLine = lines[i].trimmingCharacters(in: .whitespaces)
if !urlLine.isEmpty, let (url, cropRect) = parseImageURL(urlLine, baseURL: baseURL) {
entries.append(VTTEntry(
startTime: start,
endTime: end,
imageURL: url,
cropRect: cropRect
))
}
}
}
}
i += 1
}
return entries
}
/// Parses a timestamp string like "00:00:00.000" or "00:00.000" to TimeInterval
private func parseTimestamp(_ str: String) -> TimeInterval? {
let components = str.components(separatedBy: ":")
guard components.count >= 2 else { return nil }
if components.count == 3 {
// HH:MM:SS.mmm
guard let hours = Double(components[0]),
let minutes = Double(components[1]),
let seconds = Double(components[2])
else {
return nil
}
return hours * 3600 + minutes * 60 + seconds
} else {
// MM:SS.mmm
guard let minutes = Double(components[0]),
let seconds = Double(components[1])
else {
return nil
}
return minutes * 60 + seconds
}
}
/// Parses an image URL line, extracting the URL and optional #xywh crop fragment
/// - Parameters:
/// - str: The URL string from VTT (may be relative or absolute)
/// - baseURL: Base URL for resolving relative paths
private func parseImageURL(_ str: String, baseURL: URL) -> (URL, CGRect?)? {
// Split by # to separate URL from fragment
let parts = str.components(separatedBy: "#")
guard let urlString = parts.first, !urlString.isEmpty else {
return nil
}
// Resolve the URL (handle both absolute and relative URLs)
let url: URL?
if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") {
// Already absolute
url = URL(string: urlString)
} else if urlString.hasPrefix("/") {
// Relative to host root - extract scheme and host from baseURL
if let scheme = baseURL.scheme, let host = baseURL.host {
let port = baseURL.port.map { ":\($0)" } ?? ""
url = URL(string: "\(scheme)://\(host)\(port)\(urlString)")
} else {
url = nil
}
} else {
// Relative to current path
url = URL(string: urlString, relativeTo: baseURL)?.absoluteURL
}
guard let resolvedURL = url else {
return nil
}
var cropRect: CGRect?
// Parse #xywh=x,y,w,h fragment if present
if parts.count > 1 {
let fragment = parts[1]
if fragment.hasPrefix("xywh=") {
let coords = fragment.dropFirst(5).components(separatedBy: ",")
if coords.count == 4,
let x = Double(coords[0]),
let y = Double(coords[1]),
let w = Double(coords[2]),
let h = Double(coords[3])
{
cropRect = CGRect(x: x, y: y, width: w, height: h)
}
}
}
return (resolvedURL, cropRect)
}
/// Finds the VTT entry that contains the given time
private func findEntry(for time: TimeInterval, in entries: [VTTEntry]) -> VTTEntry? {
// Binary search would be more efficient for large entry lists,
// but linear search is fine for typical storyboard sizes
for entry in entries {
if time >= entry.startTime, time < entry.endTime {
return entry
}
}
// If time is past all entries, return the last one
if let last = entries.last, time >= last.endTime {
return last
}
return entries.first
}
// MARK: - Sheet Loading
private func loadSheetByURL(_ url: URL) async {
guard sheetCache[url] == nil, !loadingSheets.contains(url) else {
return
}
loadingSheets.insert(url)
defer { loadingSheets.remove(url) }
do {
let data: Data
if url.isFileURL {
// Local file - read directly from disk
data = try Data(contentsOf: url)
} else {
// Network request
let (networkData, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
return
}
data = networkData
}
guard let image = PlatformImage(data: data) else {
return
}
// Evict oldest sheet if cache is full
if sheetCache.count >= maxCachedSheets {
if let oldest = sheetCache.keys.first {
sheetCache.removeValue(forKey: oldest)
}
}
sheetCache[url] = image
} catch {
// Silent failure - storyboard loading is non-critical
}
}
}