mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 22:02:10 +00:00
Hide the toggle on macOS, where apps are never suspended and playback always continues regardless of the setting. Pause playback on iOS when backgrounding with the setting disabled — the audio background mode previously kept mpv playing, making the off state a no-op. Move the continue-playing policy into PlayerService and make the backend's background render pause unconditional (PiP-gated only): it is a GPU optimization that should not depend on the setting. Also ungate the player sheet visibility handlers, which manage foreground mini player rendering unrelated to background playback.
315 lines
9.3 KiB
Swift
315 lines
9.3 KiB
Swift
//
|
|
// 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.
|
|
/// Backends should pause rendering while the app is in the background;
|
|
/// whether playback itself continues is decided by PlayerService.
|
|
/// - Parameters:
|
|
/// - phase: The new scene phase
|
|
/// - isPiPActive: Whether Picture-in-Picture is currently active
|
|
func handleScenePhase(_ phase: ScenePhase, 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, 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"
|
|
}
|
|
}
|
|
}
|