mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user