mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
552 lines
20 KiB
Swift
552 lines
20 KiB
Swift
//
|
|
// 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) }
|
|
}
|
|
}
|