Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

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