// // 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 { 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 { 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" } } }