Files
yattee/Yattee/Services/Player/PlayerBackend.swift
Arkadiusz Fal 4ec9b936a6 Fix background playback setting behavior across platforms
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.
2026-07-18 16:28:19 +02:00

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