mirror of
https://github.com/yattee/yattee.git
synced 2026-08-08 00:01:29 +00:00
Yattee v2 rewrite
This commit is contained in:
551
Yattee/Services/Player/BackendSwitcher.swift
Normal file
551
Yattee/Services/Player/BackendSwitcher.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
292
Yattee/Services/Player/DeArrowAPI.swift
Normal file
292
Yattee/Services/Player/DeArrowAPI.swift
Normal 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
|
||||
}
|
||||
}
|
||||
1944
Yattee/Services/Player/MPV/MPVClient.swift
Normal file
1944
Yattee/Services/Player/MPV/MPVClient.swift
Normal file
File diff suppressed because it is too large
Load Diff
290
Yattee/Services/Player/MPV/MPVLogging.swift
Normal file
290
Yattee/Services/Player/MPV/MPVLogging.swift
Normal 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
|
||||
}
|
||||
}
|
||||
293
Yattee/Services/Player/MPV/MPVOGLView.swift
Normal file
293
Yattee/Services/Player/MPV/MPVOGLView.swift
Normal 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
|
||||
862
Yattee/Services/Player/MPV/MPVOpenGLLayer.swift
Normal file
862
Yattee/Services/Player/MPV/MPVOpenGLLayer.swift
Normal 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), ¤tFBO)
|
||||
|
||||
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
|
||||
986
Yattee/Services/Player/MPV/MPVPiPBridge.swift
Normal file
986
Yattee/Services/Player/MPV/MPVPiPBridge.swift
Normal 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
|
||||
1220
Yattee/Services/Player/MPV/MPVRenderView.swift
Normal file
1220
Yattee/Services/Player/MPV/MPVRenderView.swift
Normal file
File diff suppressed because it is too large
Load Diff
502
Yattee/Services/Player/MPV/MPVSoftwareRenderView.swift
Normal file
502
Yattee/Services/Player/MPV/MPVSoftwareRenderView.swift
Normal 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
|
||||
1853
Yattee/Services/Player/MPVBackend.swift
Normal file
1853
Yattee/Services/Player/MPVBackend.swift
Normal file
File diff suppressed because it is too large
Load Diff
430
Yattee/Services/Player/NowPlayingService.swift
Normal file
430
Yattee/Services/Player/NowPlayingService.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
313
Yattee/Services/Player/PlayerBackend.swift
Normal file
313
Yattee/Services/Player/PlayerBackend.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
2609
Yattee/Services/Player/PlayerService.swift
Normal file
2609
Yattee/Services/Player/PlayerService.swift
Normal file
File diff suppressed because it is too large
Load Diff
670
Yattee/Services/Player/PlayerState.swift
Normal file
670
Yattee/Services/Player/PlayerState.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
460
Yattee/Services/Player/QueueManager.swift
Normal file
460
Yattee/Services/Player/QueueManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
86
Yattee/Services/Player/ReturnYouTubeDislikeAPI.swift
Normal file
86
Yattee/Services/Player/ReturnYouTubeDislikeAPI.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
213
Yattee/Services/Player/SponsorBlockAPI.swift
Normal file
213
Yattee/Services/Player/SponsorBlockAPI.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
399
Yattee/Services/Player/StoryboardService.swift
Normal file
399
Yattee/Services/Player/StoryboardService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user