mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
1854 lines
73 KiB
Swift
1854 lines
73 KiB
Swift
//
|
|
// MPVBackend.swift
|
|
// Yattee
|
|
//
|
|
// MPV-based player backend supporting all video formats.
|
|
//
|
|
|
|
import Foundation
|
|
import AVFoundation
|
|
import SwiftUI
|
|
import Combine
|
|
import Libmpv
|
|
import CoreMedia
|
|
import CoreVideo
|
|
|
|
#if canImport(UIKit)
|
|
import UIKit
|
|
#elseif canImport(AppKit)
|
|
import AppKit
|
|
#endif
|
|
|
|
/// MPV-based player backend implementation.
|
|
/// Supports all video formats including VP9, AV1, and DASH.
|
|
@MainActor
|
|
final class MPVBackend: PlayerBackend {
|
|
// MARK: - PlayerBackend Properties
|
|
|
|
let backendType: PlayerBackendType = .mpv
|
|
|
|
weak var delegate: PlayerBackendDelegate?
|
|
|
|
private(set) var currentTime: TimeInterval = 0
|
|
private(set) var duration: TimeInterval = 0
|
|
private(set) var bufferedTime: TimeInterval = 0
|
|
private(set) var isReady: Bool = false
|
|
private(set) var isPlaying: Bool = false
|
|
private(set) var hasReachedEOF: Bool = false
|
|
|
|
var rate: Float {
|
|
get { _rate }
|
|
set {
|
|
_rate = newValue
|
|
mpvClient?.setProperty("speed", Double(newValue))
|
|
}
|
|
}
|
|
|
|
var volume: Float {
|
|
get { _volume }
|
|
set {
|
|
_volume = newValue
|
|
mpvClient?.setProperty("volume", Double(newValue * 100))
|
|
}
|
|
}
|
|
|
|
var isMuted: Bool {
|
|
get { _isMuted }
|
|
set {
|
|
_isMuted = newValue
|
|
mpvClient?.setProperty("mute", newValue)
|
|
}
|
|
}
|
|
|
|
/// Panscan value (0.0 = aspect fit with black bars, 1.0 = aspect fill/crop)
|
|
var panscan: Double {
|
|
get { _panscan }
|
|
set {
|
|
_panscan = max(0, min(1, newValue))
|
|
mpvClient?.setProperty("panscan", _panscan)
|
|
}
|
|
}
|
|
|
|
var supportedFormats: Set<StreamFormat> {
|
|
Set(StreamFormat.allCases) // MPV supports all formats
|
|
}
|
|
|
|
// MARK: - Public Properties
|
|
|
|
/// MPV version and build information (available after initialization).
|
|
private(set) var versionInfo: MPVVersionInfo?
|
|
|
|
// MARK: - Private Properties
|
|
|
|
private var mpvClient: MPVClient?
|
|
#if os(macOS)
|
|
private var renderView: MPVOGLView?
|
|
#elseif targetEnvironment(simulator) && (os(iOS) || os(tvOS))
|
|
private var renderView: MPVSoftwareRenderView?
|
|
#else
|
|
private var renderView: MPVRenderView?
|
|
#endif
|
|
|
|
private var _rate: Float = 1.0
|
|
private var _volume: Float = 1.0
|
|
private var _isMuted: Bool = false
|
|
private var _panscan: Double = 0.0
|
|
|
|
private var currentStream: Stream?
|
|
private var currentAudioStream: Stream?
|
|
private var currentCaption: Caption?
|
|
private var pendingAutoplay: Bool = false
|
|
|
|
// Retry mechanism: 4 attempts with increasing timeouts and delays
|
|
// Attempt 1: 3s timeout, 1s delay | Attempt 2: 3s timeout, 3s delay
|
|
// Attempt 3: 10s timeout, 5s delay | Attempt 4: 10s timeout, fail
|
|
private var retryCount = 0
|
|
private let retryDelays: [TimeInterval] = [1, 3, 5] // Delays between attempts (3 delays = 4 attempts)
|
|
private let loadTimeouts: [TimeInterval] = [3, 3, 10, 10] // Timeout per attempt
|
|
private var isInitialLoading = false // Prevents event handler from interfering during load
|
|
private var currentLoadingID: UUID? // Tracks current load operation for cancellation
|
|
private var isWaitingForExternalAudio = false // True when waiting for external audio track to load
|
|
|
|
// Buffer stall detection - triggers stream refresh when buffer stuck at 0% for too long
|
|
private var bufferStallStartTime: Date?
|
|
private let bufferStallTimeout: TimeInterval = 30 // Trigger refresh after 30 seconds of stall
|
|
private var bufferStallCheckTask: Task<Void, Never>?
|
|
|
|
// Video dimensions for aspect ratio detection
|
|
private var videoWidth: Int = 0
|
|
private var videoHeight: Int = 0
|
|
// Cached video FPS to avoid sync fetch on main thread
|
|
private var containerFps: Double = 0
|
|
// Cached cache state to avoid sync fetch on main thread
|
|
private var demuxerCacheTime: Double = 0
|
|
private var cacheBufferingState: Int = 0
|
|
private var pausedForCache: Bool = false
|
|
// Cached codec info for hwdec diagnostics
|
|
private var videoCodec: String = ""
|
|
private var hwdecCurrent: String = ""
|
|
private var hwdecInterop: String = ""
|
|
|
|
// Async initialization tracking
|
|
private var setupTask: Task<Void, Error>?
|
|
private(set) var isSetupComplete = false
|
|
private let setupStartTime = Date()
|
|
|
|
#if os(iOS)
|
|
#if targetEnvironment(simulator)
|
|
private var _playerView: MPVSoftwareRenderView?
|
|
#else
|
|
private var _playerView: MPVRenderView?
|
|
#endif
|
|
var playerView: UIView? { _playerView }
|
|
|
|
// PiP support using AVSampleBufferDisplayLayer bridge
|
|
private var pipBridge: MPVPiPBridge?
|
|
|
|
/// Whether PiP is currently active.
|
|
var isPiPActive: Bool { pipBridge?.isPiPActive ?? false }
|
|
|
|
/// Whether PiP is possible.
|
|
var isPiPPossible: Bool { pipBridge?.isPiPPossible ?? false }
|
|
|
|
/// Callback for when user wants to restore from PiP to main app.
|
|
/// Set by PlayerService to expand the player sheet.
|
|
var onRestoreFromPiP: (() async -> Void)?
|
|
|
|
/// Callback for when PiP starts.
|
|
/// Set by PlayerService to collapse the player sheet.
|
|
var onPiPDidStart: (() -> Void)?
|
|
|
|
/// Pause video rendering (for smooth panscan animation)
|
|
func pauseRendering() {
|
|
MPVLogging.log("MPVBackend.pauseRendering called")
|
|
_playerView?.pauseRendering()
|
|
}
|
|
|
|
/// Resume video rendering
|
|
func resumeRendering() {
|
|
MPVLogging.log("MPVBackend.resumeRendering called")
|
|
_playerView?.resumeRendering()
|
|
}
|
|
#elseif os(tvOS)
|
|
#if targetEnvironment(simulator)
|
|
private var _playerView: MPVSoftwareRenderView?
|
|
#else
|
|
private var _playerView: MPVRenderView?
|
|
#endif
|
|
var playerView: UIView? { _playerView }
|
|
#elseif os(macOS)
|
|
private var _playerView: MPVOGLView?
|
|
var playerView: NSView? { _playerView }
|
|
|
|
// PiP support using AVSampleBufferDisplayLayer bridge
|
|
private var pipBridge: MPVPiPBridge?
|
|
|
|
/// Whether PiP is currently active.
|
|
var isPiPActive: Bool { pipBridge?.isPiPActive ?? false }
|
|
|
|
/// Whether PiP is possible.
|
|
var isPiPPossible: Bool { pipBridge?.isPiPPossible ?? false }
|
|
|
|
/// Callback for when user wants to restore from PiP to main app.
|
|
var onRestoreFromPiP: (() async -> Void)?
|
|
|
|
/// Callback for when PiP starts.
|
|
var onPiPDidStart: (() -> Void)?
|
|
|
|
/// Callback for when PiP stops without restore (user clicked close button in PiP).
|
|
var onPiPDidStopWithoutRestore: (() -> Void)?
|
|
|
|
/// Whether PiP setup has been completed
|
|
private var isPiPSetUp = false
|
|
|
|
/// Reference to player state for updating PiP availability
|
|
private weak var pipPlayerState: PlayerState?
|
|
|
|
/// Reference to container view for updating layer frame
|
|
private weak var pipContainerView: NSView?
|
|
|
|
/// Pause video rendering
|
|
func pauseRendering() {
|
|
MPVLogging.log("MPVBackend.pauseRendering called")
|
|
_playerView?.pauseRendering()
|
|
}
|
|
|
|
/// Resume video rendering
|
|
func resumeRendering() {
|
|
MPVLogging.log("MPVBackend.resumeRendering called")
|
|
_playerView?.resumeRendering()
|
|
}
|
|
#endif
|
|
|
|
// MARK: - Initialization
|
|
|
|
init() {
|
|
// Don't call setupMPV() here - it will be called via beginSetup()
|
|
// This makes init() fast and non-blocking
|
|
}
|
|
|
|
deinit {
|
|
MainActor.assumeIsolated {
|
|
cleanup()
|
|
}
|
|
}
|
|
|
|
/// Begin async initialization (non-blocking).
|
|
/// Call this immediately after creating the backend to start setup in background.
|
|
func beginSetup() {
|
|
guard setupTask == nil else {
|
|
MPVLogging.log("MPVBackend.beginSetup: already started")
|
|
return
|
|
}
|
|
|
|
MPVLogging.log("MPVBackend.beginSetup: starting async setup")
|
|
|
|
setupTask = Task { @MainActor in
|
|
await setupMPVAsync()
|
|
}
|
|
}
|
|
|
|
/// Wait for setup to complete.
|
|
/// Call this before loading streams to ensure backend is ready.
|
|
func waitForSetup() async throws {
|
|
guard let task = setupTask else {
|
|
// Not started yet - begin now
|
|
MPVLogging.log("MPVBackend.waitForSetup: setup not started, beginning now")
|
|
beginSetup()
|
|
guard let task = setupTask else {
|
|
throw MPVRenderError.openGLSetupFailed
|
|
}
|
|
try await task.value
|
|
return
|
|
}
|
|
try await task.value
|
|
}
|
|
|
|
// MARK: - Setup
|
|
|
|
/// Async setup - moves heavy OpenGL initialization off main thread.
|
|
private func setupMPVAsync() async {
|
|
let startTime = Date()
|
|
MPVLogging.log("setupMPVAsync: starting")
|
|
|
|
// Create MPV client (fast, no blocking)
|
|
let client = MPVClient()
|
|
client.delegate = self
|
|
mpvClient = client
|
|
|
|
// Create render view (fast on main thread)
|
|
#if os(macOS)
|
|
let view = MPVOGLView()
|
|
renderView = view
|
|
_playerView = view
|
|
MPVLogging.log("setupMPVAsync: macOS render view created")
|
|
#elseif os(iOS)
|
|
#if targetEnvironment(simulator)
|
|
// Use software rendering in simulator (OpenGL ES not available)
|
|
let view = MPVSoftwareRenderView()
|
|
renderView = view
|
|
_playerView = view
|
|
MPVLogging.log("setupMPVAsync: software render view created (iOS simulator)")
|
|
#else
|
|
let view = MPVRenderView()
|
|
renderView = view
|
|
_playerView = view
|
|
MPVLogging.log("setupMPVAsync: OpenGL render view created (iOS device)")
|
|
#endif
|
|
#elseif os(tvOS)
|
|
#if targetEnvironment(simulator)
|
|
// Use software rendering in simulator (OpenGL ES not available)
|
|
let view = MPVSoftwareRenderView()
|
|
renderView = view
|
|
_playerView = view
|
|
MPVLogging.log("setupMPVAsync: software render view created (tvOS simulator)")
|
|
#else
|
|
let view = MPVRenderView()
|
|
renderView = view
|
|
_playerView = view
|
|
MPVLogging.log("setupMPVAsync: OpenGL render view created (tvOS device)")
|
|
#endif
|
|
#endif
|
|
|
|
// Set up first-frame callback for accurate ready detection
|
|
view.onFirstFrameRendered = { [weak self] in
|
|
self?.handleFirstFrameRendered()
|
|
}
|
|
|
|
#if os(iOS)
|
|
// Set up window callback for PiP setup (view needs to be in window for PiP)
|
|
view.onDidMoveToWindow = { [weak self] containerView in
|
|
guard let self, !self.isPiPSetUp, self.pipPlayerState != nil else { return }
|
|
// Store container reference for updating layer frame after layout
|
|
self.pipContainerView = containerView
|
|
// Complete PiP setup now that view is in a window
|
|
self.setupPiP(in: containerView)
|
|
self.isPiPSetUp = true
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
// Update sampleBufferLayer frame now that container has been laid out
|
|
if let containerView = self?.pipContainerView, containerView.bounds.size != .zero {
|
|
self?.pipBridge?.updateLayerFrame(containerView.bounds)
|
|
}
|
|
self?.pipPlayerState?.isPiPPossible = self?.isPiPPossible ?? false
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// Initialize MPV and setup render view (HEAVY WORK - now async)
|
|
do {
|
|
MPVLogging.log("setupMPVAsync: initializing client")
|
|
try client.initialize()
|
|
|
|
MPVLogging.log("setupMPVAsync: setting up render view (OpenGL)")
|
|
let glSetupStart = Date()
|
|
try await view.setupAsync(with: client) // NEW: async version moves OpenGL off main thread
|
|
let glSetupTime = Date().timeIntervalSince(glSetupStart)
|
|
MPVLogging.log("setupMPVAsync: OpenGL setup complete",
|
|
details: "time=\(String(format: "%.3f", glSetupTime))s")
|
|
|
|
// Capture version info asynchronously
|
|
Task { [weak self] in
|
|
guard let self, let client = self.mpvClient else { return }
|
|
self.versionInfo = await client.getVersionInfoAsync()
|
|
}
|
|
|
|
let totalTime = Date().timeIntervalSince(startTime)
|
|
let timeSinceInit = Date().timeIntervalSince(setupStartTime)
|
|
|
|
LoggingService.shared.logMPV("MPV backend initialized",
|
|
details: "setup=\(String(format: "%.3f", totalTime))s, sinceInit=\(String(format: "%.3f", timeSinceInit))s, gl=\(String(format: "%.3f", glSetupTime))s")
|
|
MPVLogging.log("setupMPVAsync: complete")
|
|
|
|
isSetupComplete = true
|
|
} catch {
|
|
LoggingService.shared.logMPVError("Failed to initialize MPV", error: error)
|
|
MPVLogging.warn("setupMPVAsync: FAILED", details: "\(error)")
|
|
}
|
|
}
|
|
|
|
private func cleanup() {
|
|
// Stop buffer stall detection
|
|
stopBufferStallDetection()
|
|
|
|
#if os(macOS)
|
|
// Clear MPV client callbacks before destroying - these reference the render view
|
|
// which will be deallocated
|
|
mpvClient?.onRenderUpdate = nil
|
|
mpvClient?.onVideoFrameReady = nil
|
|
#endif
|
|
|
|
// Remove player view from its superview to prevent orphaned views
|
|
// covering new player views in the view hierarchy
|
|
_playerView?.removeFromSuperview()
|
|
|
|
mpvClient?.destroy()
|
|
mpvClient = nil
|
|
renderView = nil
|
|
_playerView = nil
|
|
}
|
|
|
|
// MARK: - Playback Control
|
|
|
|
func load(stream: Stream, audioStream: Stream?, autoplay: Bool, useEDL: Bool) async throws {
|
|
// Wait for setup to complete before loading
|
|
MPVLogging.log("MPVBackend.load: waiting for setup")
|
|
try await waitForSetup()
|
|
MPVLogging.log("MPVBackend.load: setup complete, proceeding with load")
|
|
|
|
// Disable EDL for live streams - MPV's EDL doesn't handle live streams properly
|
|
// (live streams have no fixed duration and infinite length, which breaks EDL demuxer)
|
|
let actualUseEDL = useEDL && !stream.isLive
|
|
if stream.isLive && useEDL {
|
|
LoggingService.shared.debug("MPV: Disabling EDL for live stream (live streams not compatible with EDL)", category: .mpv)
|
|
}
|
|
|
|
LoggingService.shared.logMPV("MPV loading stream", details: "\(stream.qualityLabel) - \(stream.format)\n\(stream.url.absoluteString)\nEDL: \(actualUseEDL) (requested: \(useEDL), isLive: \(stream.isLive))\nStream info: videoCodec=\(stream.videoCodec ?? "nil"), audioCodec=\(stream.audioCodec ?? "nil")")
|
|
if let audioStream {
|
|
LoggingService.shared.logMPV("MPV loading separate audio track", details: "\(audioStream.audioLanguage ?? "default") - \(audioStream.format)\n\(audioStream.url.absoluteString)\nAudio codec: \(audioStream.audioCodec ?? "nil")")
|
|
}
|
|
|
|
// Cancel any previous loading operation by changing the ID
|
|
let loadingID = UUID()
|
|
currentLoadingID = loadingID
|
|
|
|
// IMMEDIATELY mark as loading to protect against .stop events from previous stream
|
|
// This must happen before any async work to prevent race conditions where .stop
|
|
// events from the previous stream see isInitialLoading=false and report idle state
|
|
isInitialLoading = true
|
|
|
|
// Store for potential retries
|
|
currentStream = stream
|
|
currentAudioStream = audioStream
|
|
pendingAutoplay = autoplay
|
|
pendingUseEDL = actualUseEDL
|
|
retryCount = 0
|
|
|
|
// Reset retry state in UI
|
|
delegate?.backend(self, didUpdateRetryState: 0, maxRetries: maxRetries, isRetrying: false, exhausted: false)
|
|
|
|
// Try loading with retries
|
|
try await loadWithRetry(stream: stream, audioStream: audioStream, autoplay: autoplay, useEDL: actualUseEDL, loadingID: loadingID)
|
|
}
|
|
|
|
private var pendingUseEDL: Bool = true
|
|
|
|
private func loadWithRetry(stream: Stream, audioStream: Stream?, autoplay: Bool, useEDL: Bool, loadingID: UUID) async throws {
|
|
// Check if this load operation was cancelled (a new load started)
|
|
guard currentLoadingID == loadingID else {
|
|
LoggingService.shared.debug("MPV load operation cancelled - newer load in progress", category: .mpv)
|
|
throw CancellationError()
|
|
}
|
|
|
|
// Reset state (but keep videoWidth/videoHeight for smooth aspect ratio transition)
|
|
isReady = false
|
|
isInitialLoading = true
|
|
isSeeking = false
|
|
hasDisplayedVideo = false
|
|
hasStartedPlayback = false
|
|
hasReachedEOF = false
|
|
pendingPlayAfterSeek = false
|
|
// Note: pendingInitialSeek is NOT reset here - it's set by prepareForInitialSeek()
|
|
// before load() is called, and cleared when seek() starts
|
|
currentTime = 0
|
|
duration = 0
|
|
bufferedTime = 0
|
|
// When loading with external audio (non-EDL mode), wait for PLAYBACK_RESTART after audio is added
|
|
// With EDL, both streams load atomically so no waiting needed
|
|
isWaitingForExternalAudio = audioStream != nil && !useEDL
|
|
|
|
// Clear any subtitles from previous video
|
|
currentCaption = nil
|
|
mpvClient?.removeAllSubtitlesAsync()
|
|
|
|
// Reset first-frame tracking for new content
|
|
renderView?.resetFirstFrameTracking()
|
|
|
|
// Pause MPV before loading new content to prevent audio from playing
|
|
// before the thumbnail hides. This is critical when reusing the backend
|
|
// for a new video - without this, MPV may output audio during buffering.
|
|
mpvClient?.pause()
|
|
|
|
// Clear the render view to black to hide any old frame from previous video
|
|
renderView?.clearToBlack()
|
|
|
|
// Load the stream
|
|
do {
|
|
try mpvClient?.loadFile(stream.url, audioURL: audioStream?.url, httpHeaders: stream.httpHeaders, useEDL: useEDL)
|
|
|
|
// Give MPV a moment to process the loadfile command
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
|
|
// Log diagnostics asynchronously (background priority) - don't block video loading
|
|
// These are non-critical diagnostic logs that shouldn't impact UI responsiveness
|
|
Task.detached(priority: .background) { [weak self] in
|
|
guard let mpvClient = await self?.mpvClient else { return }
|
|
let idleActive = await mpvClient.getFlagAsync("idle-active")
|
|
let coreIdle = await mpvClient.getFlagAsync("core-idle")
|
|
let seeking = await mpvClient.getFlagAsync("seeking")
|
|
LoggingService.shared.debug(
|
|
"MPV: After loadfile - idle-active=\(idleActive?.description ?? "nil"), core-idle=\(coreIdle?.description ?? "nil"), seeking=\(seeking?.description ?? "nil")",
|
|
category: .mpv
|
|
)
|
|
}
|
|
|
|
// Wait for file to be loaded
|
|
try await waitForReady(loadingID: loadingID)
|
|
|
|
// Check again after waiting
|
|
guard currentLoadingID == loadingID else {
|
|
LoggingService.shared.debug("MPV load operation cancelled after ready - newer load in progress", category: .mpv)
|
|
throw CancellationError()
|
|
}
|
|
|
|
// Reset retry state on success
|
|
isInitialLoading = false
|
|
resetRetryState()
|
|
|
|
if autoplay {
|
|
play()
|
|
}
|
|
|
|
LoggingService.shared.logMPV("MPV stream loaded successfully")
|
|
} catch is CancellationError {
|
|
// Re-throw cancellation errors without retry
|
|
// Only reset isInitialLoading if we're still the active load operation
|
|
// A newer load may have already set isInitialLoading=true
|
|
if currentLoadingID == loadingID {
|
|
isInitialLoading = false
|
|
}
|
|
throw CancellationError()
|
|
} catch {
|
|
// Check if cancelled before retrying
|
|
guard currentLoadingID == loadingID else {
|
|
LoggingService.shared.debug("MPV load operation cancelled - newer load in progress", category: .mpv)
|
|
// Don't reset isInitialLoading - a newer load owns it now
|
|
throw CancellationError()
|
|
}
|
|
|
|
// Retry if we haven't exhausted attempts
|
|
if retryCount < maxRetries {
|
|
let delay = retryDelays[min(retryCount, retryDelays.count - 1)]
|
|
retryCount += 1
|
|
|
|
// Notify delegate that retry is starting
|
|
delegate?.backend(self, didUpdateRetryState: retryCount, maxRetries: maxRetries, isRetrying: true, exhausted: false)
|
|
|
|
let nextTimeout = loadTimeouts[min(retryCount, loadTimeouts.count - 1)]
|
|
LoggingService.shared.warning("MPV stream load retry", category: .mpv, details: "Waiting \(Int(delay))s before attempt \(retryCount + 1)/\(maxRetries + 1) (timeout: \(Int(nextTimeout))s)")
|
|
|
|
// Wait before retrying
|
|
try await Task.sleep(for: .seconds(delay))
|
|
|
|
// Check if cancelled after delay
|
|
guard currentLoadingID == loadingID, !Task.isCancelled else {
|
|
LoggingService.shared.debug("MPV load operation cancelled during retry delay", category: .mpv)
|
|
// Don't reset isInitialLoading - a newer load owns it now
|
|
throw CancellationError()
|
|
}
|
|
try await loadWithRetry(stream: stream, audioStream: audioStream, autoplay: autoplay, useEDL: useEDL, loadingID: loadingID)
|
|
} else {
|
|
// All retries exhausted
|
|
isInitialLoading = false
|
|
LoggingService.shared.logMPVError("MPV stream load failed after \(retryDelays.count + 1) attempts")
|
|
|
|
// Notify delegate that all retries exhausted
|
|
delegate?.backend(self, didUpdateRetryState: maxRetries, maxRetries: maxRetries, isRetrying: false, exhausted: true)
|
|
|
|
resetRetryState()
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Maximum number of retries
|
|
private var maxRetries: Int {
|
|
retryDelays.count
|
|
}
|
|
|
|
/// Timeout for current attempt
|
|
private var currentLoadTimeout: TimeInterval {
|
|
loadTimeouts[min(retryCount, loadTimeouts.count - 1)]
|
|
}
|
|
|
|
private func resetRetryState() {
|
|
retryCount = 0
|
|
currentStream = nil
|
|
currentAudioStream = nil
|
|
pendingAutoplay = false
|
|
delegate?.backend(self, didUpdateRetryState: 0, maxRetries: maxRetries, isRetrying: false, exhausted: false)
|
|
}
|
|
|
|
func play() {
|
|
guard isReady else {
|
|
// Not ready yet - set pending flag to auto-play when ready
|
|
LoggingService.shared.debug("MPV: play() called while not ready, setting pendingPlayAfterSeek", category: .mpv)
|
|
pendingPlayAfterSeek = true
|
|
return
|
|
}
|
|
|
|
LoggingService.shared.debug("MPV: play() called, isReady=\(isReady), cacheTime=\(demuxerCacheTime)s", category: .mpv)
|
|
mpvClient?.play()
|
|
isPlaying = true
|
|
delegate?.backend(self, didChangeState: .playing)
|
|
}
|
|
|
|
/// Wait for sufficient buffer before starting playback.
|
|
/// This prevents the initial pause/stutter that occurs when MPV starts playing
|
|
/// before enough content is buffered.
|
|
/// - Parameters:
|
|
/// - minimumBuffer: Minimum buffer time required before playback starts (default 3.0 seconds)
|
|
/// - timeout: Maximum time to wait for buffer (default 5 seconds)
|
|
/// - Returns: The buffer time when wait completed
|
|
func waitForBuffer(minimumBuffer: Double = 3.0, timeout: TimeInterval = 5.0) async -> Double {
|
|
let startTime = Date()
|
|
var lastLogTime = startTime
|
|
|
|
// First, wait for any pending seek to complete
|
|
// After a seek, the buffer at the new position needs to refill
|
|
while isSeeking {
|
|
if Date().timeIntervalSince(startTime) >= timeout {
|
|
LoggingService.shared.debug("MPV: Buffer wait timeout while waiting for seek", category: .mpv)
|
|
return 0
|
|
}
|
|
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
|
}
|
|
|
|
LoggingService.shared.debug("MPV: Seek complete, now waiting for buffer to fill", category: .mpv)
|
|
|
|
// After seek completes, give the demuxer a moment to start filling the buffer
|
|
// at the new position. Without this, we might read stale cache values.
|
|
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
|
|
|
// Fast-path: If cache is already satisfied, return immediately
|
|
// This handles fully buffered content (short videos, previously buffered, etc.)
|
|
let initialBufferingState = cacheBufferingState
|
|
let initialPausedForCache = pausedForCache
|
|
if initialBufferingState >= 100 && !initialPausedForCache {
|
|
return demuxerCacheTime
|
|
}
|
|
|
|
// Capture the initial cache time as a baseline
|
|
// demuxer-cache-time returns total cached content from file start, not from seek position
|
|
// So when seeking to 100s, cache might already be 100s even though buffer at that position is empty
|
|
let initialCacheTime = demuxerCacheTime
|
|
let targetCacheTime = initialCacheTime + minimumBuffer
|
|
|
|
// Now wait for sufficient buffer at the current position
|
|
// We check multiple conditions:
|
|
// 1. demuxer-cache-time: seconds of video buffered (relative to baseline)
|
|
// 2. cache-buffering-state: 0-100% of how full the cache is until MPV will unpause
|
|
// 3. paused-for-cache: whether MPV would pause due to insufficient cache
|
|
var lastCacheTime: Double = initialCacheTime
|
|
var noProgressCount = 0
|
|
|
|
while true {
|
|
// Use cached values (updated via property observation) to avoid sync fetch on main thread
|
|
let cacheTime = demuxerCacheTime
|
|
let bufferingState = cacheBufferingState
|
|
let isPausedForCache = pausedForCache
|
|
|
|
// Calculate progress as percentage towards our minimum buffer target
|
|
// Use the delta from initial cache time to show meaningful progress after seeks
|
|
let bufferedSinceStart = max(0, cacheTime - initialCacheTime)
|
|
let bufferProgress = min(Int((bufferedSinceStart / minimumBuffer) * 100), 99)
|
|
delegate?.backend(self, didUpdateBufferProgress: bufferProgress)
|
|
|
|
// Log progress every 0.5 seconds
|
|
if Date().timeIntervalSince(lastLogTime) >= 0.5 {
|
|
LoggingService.shared.debug("MPV: Waiting for buffer... cacheTime=\(String(format: "%.2f", cacheTime))s (delta=\(String(format: "%.2f", bufferedSinceStart))s), bufferingState=\(bufferingState)%, pausedForCache=\(isPausedForCache), target=\(minimumBuffer)s, progress=\(bufferProgress)%", category: .mpv)
|
|
lastLogTime = Date()
|
|
}
|
|
|
|
// Primary condition: We have enough cached time relative to where we started
|
|
let hasEnoughTime = cacheTime >= targetCacheTime
|
|
|
|
if hasEnoughTime {
|
|
LoggingService.shared.debug("MPV: Buffer ready, cacheTime=\(String(format: "%.2f", cacheTime))s (delta=\(String(format: "%.2f", bufferedSinceStart))s), bufferingState=\(bufferingState)%, reason=enough time", category: .mpv)
|
|
return cacheTime
|
|
}
|
|
|
|
// Track if buffer isn't growing (video fully downloaded or other issue)
|
|
if cacheTime <= lastCacheTime {
|
|
noProgressCount += 1
|
|
} else {
|
|
noProgressCount = 0
|
|
lastCacheTime = cacheTime
|
|
}
|
|
|
|
// Fallback: If MPV's cache is satisfied AND buffer isn't growing for 0.5s (10 checks),
|
|
// the video is likely short/fully buffered, so proceed
|
|
let cacheIsSatisfied = bufferingState >= 100 && !isPausedForCache
|
|
if cacheIsSatisfied && noProgressCount >= 10 {
|
|
LoggingService.shared.debug("MPV: Buffer ready, cacheTime=\(String(format: "%.2f", cacheTime))s (delta=\(String(format: "%.2f", bufferedSinceStart))s), bufferingState=\(bufferingState)%, reason=cache satisfied (no progress)", category: .mpv)
|
|
return cacheTime
|
|
}
|
|
|
|
// Check timeout
|
|
if Date().timeIntervalSince(startTime) >= timeout {
|
|
LoggingService.shared.debug("MPV: Buffer wait timeout, proceeding with cacheTime=\(String(format: "%.2f", cacheTime))s, bufferingState=\(bufferingState)%", category: .mpv)
|
|
return cacheTime
|
|
}
|
|
|
|
// Wait a bit before checking again
|
|
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
|
}
|
|
}
|
|
|
|
func pause() {
|
|
mpvClient?.pause()
|
|
isPlaying = false
|
|
delegate?.backend(self, didChangeState: .paused)
|
|
}
|
|
|
|
func stop() {
|
|
#if os(iOS) || os(macOS)
|
|
// Stop PiP if active before stopping playback
|
|
if pipBridge?.isPiPActive == true {
|
|
stopPiP()
|
|
}
|
|
// Flush PiP buffer to clear stale frames when reusing backend
|
|
pipBridge?.flushBuffer()
|
|
// Immediately reset PiP-related state (don't wait for async callbacks)
|
|
// This ensures the next video can start with clean state
|
|
_playerView?.isPiPActive = false
|
|
_playerView?.captureFramesForPiP = false
|
|
pipPlayerState?.pipState = .inactive
|
|
// Note: Don't clear onFrameReady/onFirstFrameRendered callbacks - they're
|
|
// set once in setupMPV() and needed for subsequent video loads when reusing backend
|
|
#endif
|
|
|
|
#if os(macOS)
|
|
// On macOS, also clean up PiP bridge callbacks to prevent crashes
|
|
// during window close
|
|
pipBridge?.onPiPStatusChanged = nil
|
|
pipBridge?.onPiPWillStart = nil
|
|
pipBridge?.onPiPWillStop = nil
|
|
pipBridge?.onPiPRenderSizeChanged = nil
|
|
|
|
// NOTE: Do NOT clear mpvClient?.onRenderUpdate here!
|
|
// The onRenderUpdate callback is set once during setup and needs to persist
|
|
// across video changes. Clearing it here would break rendering for subsequent
|
|
// videos since setup() is only called once per backend lifetime.
|
|
// The callback will be properly cleared in cleanup() when the backend is destroyed.
|
|
#endif
|
|
|
|
// Stop buffer stall detection
|
|
stopBufferStallDetection()
|
|
|
|
mpvClient?.stop()
|
|
isPlaying = false
|
|
isReady = false
|
|
hasReachedEOF = false
|
|
pendingInitialSeek = false
|
|
currentTime = 0
|
|
duration = 0
|
|
bufferedTime = 0
|
|
currentStream = nil
|
|
delegate?.backend(self, didChangeState: .idle)
|
|
}
|
|
|
|
func prepareForInitialSeek() {
|
|
// Signal that an initial seek will be performed after load completes.
|
|
// This defers backendDidBecomeReady until the seek completes.
|
|
pendingInitialSeek = true
|
|
LoggingService.shared.debug("MPV: Preparing for initial seek", category: .mpv)
|
|
}
|
|
|
|
func seek(to time: TimeInterval, showLoading: Bool = false) async {
|
|
// Clear pending initial seek flag - we're now doing the seek
|
|
pendingInitialSeek = false
|
|
|
|
// Clear EOF state when seeking (e.g., restart from beginning)
|
|
hasReachedEOF = false
|
|
|
|
// For initial resume seeks (before playback has really started),
|
|
// or when explicitly requested (e.g., SponsorBlock intro skip),
|
|
// reset ready state so we keep showing loading until video at new position is visible
|
|
let shouldShowLoading = !hasStartedPlayback || showLoading
|
|
|
|
if shouldShowLoading {
|
|
LoggingService.shared.debug("MPV: Seek with loading state - resetting for new position", category: .mpv)
|
|
hasDisplayedVideo = false
|
|
hasStartedPlayback = false
|
|
renderView?.resetFirstFrameTracking()
|
|
|
|
// Pause MPV during seek to prevent brief playback at wrong position
|
|
if isPlaying {
|
|
mpvClient?.pause()
|
|
pendingPlayAfterSeek = true
|
|
}
|
|
|
|
if isReady {
|
|
isReady = false
|
|
// Tell UI to go back to loading state
|
|
delegate?.backend(self, didChangeState: .loading)
|
|
}
|
|
}
|
|
|
|
// Set seeking immediately - don't wait for MPV's property update
|
|
isSeeking = true
|
|
|
|
// Use async seek to avoid blocking the main thread
|
|
// Seek completion is tracked via MPV's "seeking" property observation
|
|
mpvClient?.seekAsync(to: time)
|
|
currentTime = time
|
|
delegate?.backend(self, didUpdateTime: time)
|
|
}
|
|
|
|
/// Tracks if we need to resume playback after initial seek completes
|
|
private var pendingPlayAfterSeek = false
|
|
|
|
/// Tracks if MPV is currently seeking
|
|
private var isSeeking = false
|
|
|
|
/// Tracks if we've actually displayed video (first frame rendered)
|
|
private var hasDisplayedVideo = false
|
|
|
|
/// Tracks if real playback has started (user has seen video playing at intended position)
|
|
/// This distinguishes initial resume seeks from user-initiated seeks during playback
|
|
private var hasStartedPlayback = false
|
|
|
|
/// Tracks if an initial seek is pending after load completes
|
|
/// When true, handleFirstFrameRendered won't call backendDidBecomeReady until seek completes
|
|
private var pendingInitialSeek = false
|
|
|
|
|
|
// MARK: - Backend Switching
|
|
|
|
func captureState() -> BackendState {
|
|
BackendState(
|
|
currentTime: currentTime,
|
|
duration: duration,
|
|
rate: _rate,
|
|
volume: _volume,
|
|
isMuted: _isMuted,
|
|
isPlaying: isPlaying
|
|
)
|
|
}
|
|
|
|
func restore(state: BackendState) async {
|
|
_rate = state.rate
|
|
_volume = state.volume
|
|
_isMuted = state.isMuted
|
|
|
|
// Apply to MPV
|
|
mpvClient?.setProperty("speed", Double(state.rate))
|
|
mpvClient?.setProperty("volume", Double(state.volume * 100))
|
|
mpvClient?.setProperty("mute", state.isMuted)
|
|
|
|
if state.currentTime > 0 {
|
|
await seek(to: state.currentTime)
|
|
}
|
|
|
|
if state.isPlaying {
|
|
play()
|
|
}
|
|
}
|
|
|
|
func prepareForHandoff() {
|
|
mpvClient?.pause()
|
|
isPlaying = false
|
|
}
|
|
|
|
// MARK: - Subtitles
|
|
|
|
/// Load and display a caption/subtitle track.
|
|
/// - Parameter caption: The caption to load, or nil to disable subtitles
|
|
func loadCaption(_ caption: Caption?) {
|
|
// Remove any existing subtitles first (async to not block UI)
|
|
mpvClient?.removeAllSubtitlesAsync()
|
|
|
|
guard let caption else {
|
|
// Disable subtitles
|
|
mpvClient?.disableSubtitles()
|
|
currentCaption = nil
|
|
LoggingService.shared.debug("MPV: Subtitles disabled", category: .mpv)
|
|
return
|
|
}
|
|
|
|
// Load the new subtitle asynchronously to avoid blocking UI during download
|
|
LoggingService.shared.debug("MPV: Loading subtitle: \(caption.displayName)", category: .mpv)
|
|
mpvClient?.addSubtitleAsync(caption.url, select: true)
|
|
currentCaption = caption
|
|
}
|
|
|
|
/// Get the currently loaded caption.
|
|
func getCurrentCaption() -> Caption? {
|
|
currentCaption
|
|
}
|
|
|
|
/// Update subtitle appearance settings on the active MPV instance.
|
|
/// Call this after changing subtitle settings to apply them immediately without restarting playback.
|
|
func updateSubtitleSettings() {
|
|
mpvClient?.updateSubtitleSettings()
|
|
}
|
|
|
|
/// Get the actual video track dimensions from MPV.
|
|
/// Returns (width, height) or nil if not available.
|
|
func getVideoSize() -> (width: Int, height: Int)? {
|
|
// Use cached values (updated via property observation) to avoid sync fetch
|
|
guard videoWidth > 0, videoHeight > 0 else {
|
|
return nil
|
|
}
|
|
return (videoWidth, videoHeight)
|
|
}
|
|
|
|
/// Get debug statistics from MPV for the debug overlay.
|
|
/// Uses batch fetch to minimize lock contention (single sync block instead of ~25 separate calls).
|
|
func getDebugStats() -> MPVDebugStats {
|
|
var stats = MPVDebugStats()
|
|
|
|
// Fetch all properties in a single sync block
|
|
guard let props = mpvClient?.getDebugProperties() else {
|
|
return stats
|
|
}
|
|
|
|
// Video info
|
|
stats.videoCodec = props.videoCodec
|
|
stats.hwdecCurrent = props.hwdecCurrent
|
|
stats.width = props.width
|
|
stats.height = props.height
|
|
stats.fps = props.containerFps
|
|
stats.estimatedVfFps = props.estimatedVfFps
|
|
|
|
// Audio info
|
|
stats.audioCodec = props.audioCodecName
|
|
stats.audioSampleRate = props.audioSampleRate
|
|
stats.audioChannels = props.audioChannels
|
|
|
|
// Playback stats
|
|
stats.droppedFrameCount = props.frameDropCount
|
|
stats.mistimedFrameCount = props.mistimedFrameCount
|
|
stats.delayedFrameCount = props.voDelayedFrameCount
|
|
stats.avSync = props.avsync
|
|
stats.estimatedFrameNumber = props.estimatedFrameNumber
|
|
|
|
// Cache/Network
|
|
if let cacheState = props.cacheState {
|
|
stats.cacheDuration = cacheState.cacheDuration
|
|
stats.cacheBytes = cacheState.totalBytes
|
|
stats.networkSpeed = cacheState.inputRate
|
|
}
|
|
stats.demuxerCacheDuration = props.demuxerCacheDuration
|
|
|
|
// Container
|
|
stats.fileFormat = props.fileFormat
|
|
stats.containerFps = props.containerFps
|
|
|
|
// Video Sync stats (for tvOS frame timing diagnostics)
|
|
#if os(tvOS)
|
|
stats.videoSync = props.videoSync
|
|
stats.displayFps = props.displayFps
|
|
stats.vsyncJitter = props.vsyncJitter
|
|
stats.videoSpeedCorrection = props.videoSpeedCorrection
|
|
stats.audioSpeedCorrection = props.audioSpeedCorrection
|
|
stats.framedrop = props.framedrop
|
|
stats.displayLinkFps = renderView?.displayLinkTargetFPS
|
|
#endif
|
|
|
|
return stats
|
|
}
|
|
|
|
// MARK: - Background Playback
|
|
|
|
func handleScenePhase(_ phase: ScenePhase, backgroundEnabled: Bool, isPiPActive: Bool) {
|
|
#if os(iOS)
|
|
let pipActive = self.isPiPActive || isPiPActive
|
|
#else
|
|
let pipActive = isPiPActive
|
|
#endif
|
|
|
|
MPVLogging.logAppLifecycle("handleScenePhase(\(phase))",
|
|
isPiPActive: pipActive, isRendering: nil)
|
|
|
|
guard backgroundEnabled, !pipActive else {
|
|
MPVLogging.log("handleScenePhase: skipping (bgEnabled:\(backgroundEnabled) pip:\(pipActive))")
|
|
return
|
|
}
|
|
|
|
switch phase {
|
|
case .background:
|
|
// Just pause rendering - don't touch video track or output properties
|
|
// This preserves the demuxer cache and avoids rebuffering on resume
|
|
// Same approach as handlePlayerSheetVisibility
|
|
LoggingService.shared.debug("MPV: Pausing rendering for background", category: .mpv)
|
|
MPVLogging.log("handleScenePhase: pausing rendering for background")
|
|
#if os(macOS)
|
|
_playerView?.pauseRendering()
|
|
#elseif targetEnvironment(simulator)
|
|
(playerView as? MPVSoftwareRenderView)?.pauseRendering()
|
|
#else
|
|
(playerView as? MPVRenderView)?.pauseRendering()
|
|
#endif
|
|
|
|
case .active:
|
|
// Resume rendering
|
|
LoggingService.shared.debug("MPV: Resuming rendering for foreground", category: .mpv)
|
|
MPVLogging.log("handleScenePhase: resuming rendering for foreground")
|
|
#if os(macOS)
|
|
_playerView?.resumeRendering()
|
|
#elseif targetEnvironment(simulator)
|
|
(playerView as? MPVSoftwareRenderView)?.resumeRendering()
|
|
#else
|
|
(playerView as? MPVRenderView)?.resumeRendering()
|
|
#endif
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
/// Handles player sheet visibility changes for background audio playback.
|
|
/// - Parameter isVisible: true when sheet appears, false when it disappears
|
|
///
|
|
/// Note: We only pause/resume rendering, NOT disable the video track.
|
|
/// Disabling the video track (`vid=no`) stops video decoding while audio continues,
|
|
/// causing severe A/V desync when video is re-enabled. Instead, we keep decoding
|
|
/// running but pause the display link to save GPU while maintaining sync.
|
|
func handlePlayerSheetVisibility(isVisible: Bool) {
|
|
#if os(iOS)
|
|
let hasFirstFrame = hasDisplayedVideo
|
|
let currentlyPlaying = isPlaying
|
|
let ready = isReady
|
|
LoggingService.shared.debug("MPV: handlePlayerSheetVisibility(isVisible=\(isVisible)) - hasDisplayedVideo=\(hasFirstFrame), isPlaying=\(currentlyPlaying), isReady=\(ready), _playerView=\(_playerView != nil)", category: .mpv)
|
|
MPVLogging.log("handlePlayerSheetVisibility(isVisible:\(isVisible))",
|
|
details: "hasFirstFrame:\(hasFirstFrame) playing:\(currentlyPlaying) ready:\(ready)")
|
|
|
|
if isVisible {
|
|
LoggingService.shared.debug("MPV: Resuming rendering for sheet display", category: .mpv)
|
|
MPVLogging.log("handlePlayerSheetVisibility: resuming rendering")
|
|
_playerView?.resumeRendering()
|
|
} else {
|
|
LoggingService.shared.debug("MPV: Pausing rendering - sheet dismissed", category: .mpv)
|
|
MPVLogging.log("handlePlayerSheetVisibility: pausing rendering")
|
|
_playerView?.pauseRendering()
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Picture-in-Picture (iOS)
|
|
|
|
#if os(iOS)
|
|
/// Tracks whether PiP has been set up
|
|
private var isPiPSetUp = false
|
|
|
|
/// Reference to the player state for updating isPiPPossible
|
|
private weak var pipPlayerState: PlayerState?
|
|
|
|
/// Reference to the container view for updating PiP layer frame after layout
|
|
private weak var pipContainerView: UIView?
|
|
|
|
/// Set up PiP if not already set up.
|
|
/// Called from the representable when the view is updated.
|
|
func setupPiPIfNeeded(in containerView: UIView, playerState: PlayerState?) {
|
|
// Always store playerState so we have it when onDidMoveToWindow fires
|
|
pipPlayerState = playerState
|
|
|
|
// Only proceed with actual setup if not already done and view is in window
|
|
guard !isPiPSetUp, containerView.window != nil else { return }
|
|
|
|
// Store container reference for updating layer frame after layout
|
|
pipContainerView = containerView
|
|
|
|
setupPiP(in: containerView)
|
|
isPiPSetUp = true
|
|
|
|
// Update player state after a short delay to let PiP controller initialize
|
|
// and container to be laid out with proper bounds
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
// Update sampleBufferLayer frame now that container has been laid out
|
|
if let containerView = self?.pipContainerView, containerView.bounds.size != .zero {
|
|
self?.pipBridge?.updateLayerFrame(containerView.bounds)
|
|
}
|
|
playerState?.isPiPPossible = self?.isPiPPossible ?? false
|
|
}
|
|
}
|
|
|
|
/// Set up PiP with the given container view.
|
|
/// - Parameters:
|
|
/// - containerView: The view to embed the PiP layer in
|
|
func setupPiP(in containerView: UIView) {
|
|
// Create PiP bridge if needed
|
|
if pipBridge == nil {
|
|
pipBridge = MPVPiPBridge()
|
|
}
|
|
|
|
guard let pipBridge else { return }
|
|
|
|
// Set up the bridge with this backend and the container
|
|
pipBridge.setup(backend: self, in: containerView)
|
|
|
|
// Use the restore callback set by PlayerService
|
|
pipBridge.onRestoreUserInterface = { [weak self] in
|
|
await self?.onRestoreFromPiP?()
|
|
}
|
|
|
|
// Update render view's PiP status to control main view rendering
|
|
pipBridge.onPiPStatusChanged = { [weak self] isActive in
|
|
self?._playerView?.isPiPActive = isActive
|
|
// Also update player state
|
|
self?.pipPlayerState?.pipState = isActive ? .active : .inactive
|
|
if isActive {
|
|
// Notify that PiP started (to collapse player sheet)
|
|
self?.onPiPDidStart?()
|
|
} else {
|
|
// Disable frame capture when PiP stops (after animation completes)
|
|
self?._playerView?.captureFramesForPiP = false
|
|
}
|
|
}
|
|
|
|
// Clear main view to black immediately when PiP starts animating
|
|
pipBridge.onPiPWillStart = { [weak self] in
|
|
// Enable frame capture - this handles system-triggered PiP (when user minimizes app)
|
|
// where captureFramesForPiP wasn't set beforehand
|
|
self?._playerView?.captureFramesForPiP = true
|
|
// Stop presenting to main view immediately
|
|
self?._playerView?.isPiPActive = true
|
|
// Clear to black
|
|
self?._playerView?.clearMainViewForPiP()
|
|
}
|
|
|
|
// Note: Main view rendering resumes in onPiPStatusChanged(false) after animation completes.
|
|
// This allows the system "playing in PiP" placeholder to show during close animation.
|
|
pipBridge.onPiPWillStop = { [weak self] in
|
|
// Keep isPiPActive = true so main view shows placeholder during close animation
|
|
_ = self // Silence unused warning
|
|
}
|
|
|
|
// Connect frame capture from render view to PiP bridge
|
|
_playerView?.onFrameReady = { [weak self] pixelBuffer, presentationTime in
|
|
self?.enqueueFrameForPiP(pixelBuffer, presentationTime: presentationTime)
|
|
}
|
|
|
|
LoggingService.shared.debug("MPV: PiP setup complete", category: .mpv)
|
|
}
|
|
|
|
/// Start Picture-in-Picture.
|
|
func startPiP() {
|
|
guard pipBridge != nil else {
|
|
LoggingService.shared.warning("MPV: Cannot start PiP - not set up", category: .mpv)
|
|
return
|
|
}
|
|
|
|
startPiPInternal()
|
|
}
|
|
|
|
/// Internal method to actually start PiP after any fullscreen handling
|
|
private func startPiPInternal() {
|
|
// Enable frame capture for PiP - start capturing BEFORE requesting PiP
|
|
// so the sample buffer layer has content
|
|
_playerView?.captureFramesForPiP = true
|
|
|
|
// Wait a moment for frames to be captured, then start PiP
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
self?.pipBridge?.startPiP()
|
|
}
|
|
}
|
|
|
|
/// Stop Picture-in-Picture.
|
|
func stopPiP() {
|
|
pipBridge?.stopPiP()
|
|
// Frame capture is disabled in onPiPStatusChanged callback after animation completes
|
|
}
|
|
|
|
/// Toggle Picture-in-Picture.
|
|
func togglePiP() {
|
|
if isPiPActive {
|
|
stopPiP()
|
|
} else {
|
|
startPiP()
|
|
}
|
|
}
|
|
|
|
/// Called by render view when a frame is ready for PiP display.
|
|
func enqueueFrameForPiP(_ pixelBuffer: CVPixelBuffer, presentationTime: CMTime) {
|
|
guard let pipBridge else { return }
|
|
|
|
// Update playback state for PiP controls
|
|
pipBridge.updatePlaybackState(
|
|
duration: duration,
|
|
currentTime: currentTime,
|
|
isPaused: !isPlaying
|
|
)
|
|
|
|
pipBridge.enqueueFrame(pixelBuffer, presentationTime: presentationTime)
|
|
}
|
|
|
|
/// Clean up PiP resources.
|
|
func cleanupPiP() {
|
|
_playerView?.captureFramesForPiP = false
|
|
_playerView?.onFrameReady = nil
|
|
pipBridge?.cleanup()
|
|
pipBridge = nil
|
|
isPiPSetUp = false
|
|
}
|
|
|
|
/// Move the PiP layer to a new container view.
|
|
/// Call this before starting PiP from fullscreen to ensure the layer
|
|
/// is in the main window's view hierarchy.
|
|
func movePiPLayer(to containerView: UIView) {
|
|
pipBridge?.moveLayer(to: containerView)
|
|
}
|
|
|
|
#elseif os(macOS)
|
|
|
|
// MARK: - Picture-in-Picture (macOS)
|
|
|
|
/// Set up PiP if not already set up.
|
|
/// Called from the representable when the view is updated.
|
|
func setupPiPIfNeeded(in containerView: NSView, playerState: PlayerState?) {
|
|
// Always store playerState so we have it for later
|
|
pipPlayerState = playerState
|
|
|
|
// Only proceed with actual setup if not already done and view is in window
|
|
guard !isPiPSetUp, containerView.window != nil else {
|
|
// If already set up but playerState changed, update isPiPPossible
|
|
if isPiPSetUp, let playerState {
|
|
playerState.isPiPPossible = isPiPPossible
|
|
}
|
|
return
|
|
}
|
|
|
|
// Store container reference for updating layer frame after layout
|
|
pipContainerView = containerView
|
|
|
|
setupPiP(in: containerView)
|
|
isPiPSetUp = true
|
|
|
|
// Update sampleBufferLayer frame after a short delay now that container has been laid out
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
|
if let containerView = self?.pipContainerView, containerView.bounds.size != .zero {
|
|
// On macOS, use the method that calculates frame relative to window's content view
|
|
self?.pipBridge?.updateLayerFrame(for: containerView)
|
|
}
|
|
}
|
|
// Note: playerState.isPiPPossible is updated via KVO observation in pipBridge.onPiPPossibleChanged
|
|
}
|
|
|
|
/// Set up PiP with the given container view.
|
|
func setupPiP(in containerView: NSView) {
|
|
// Create PiP bridge if needed
|
|
if pipBridge == nil {
|
|
pipBridge = MPVPiPBridge()
|
|
}
|
|
|
|
guard let pipBridge else { return }
|
|
|
|
// Set up the bridge with this backend and the container
|
|
pipBridge.setup(backend: self, in: containerView)
|
|
|
|
// Use the restore callback set by PlayerService
|
|
pipBridge.onRestoreUserInterface = { [weak self] in
|
|
await self?.onRestoreFromPiP?()
|
|
}
|
|
|
|
// Update render view's PiP status to control main view rendering
|
|
pipBridge.onPiPStatusChanged = { [weak self] isActive in
|
|
LoggingService.shared.debug("MPVBackend (macOS): onPiPStatusChanged isActive=\(isActive), onPiPDidStart=\(self?.onPiPDidStart != nil ? "set" : "nil")", category: .mpv)
|
|
self?._playerView?.isPiPActive = isActive
|
|
self?.pipPlayerState?.pipState = isActive ? .active : .inactive
|
|
if isActive {
|
|
self?.onPiPDidStart?()
|
|
} else {
|
|
self?._playerView?.captureFramesForPiP = false
|
|
}
|
|
}
|
|
|
|
// Clear main view to black immediately when PiP starts animating
|
|
pipBridge.onPiPWillStart = { [weak self] in
|
|
self?._playerView?.captureFramesForPiP = true
|
|
self?._playerView?.isPiPActive = true
|
|
self?._playerView?.clearMainViewForPiP()
|
|
}
|
|
|
|
pipBridge.onPiPWillStop = { [weak self] in
|
|
_ = self
|
|
}
|
|
|
|
// Clean up hidden window when PiP is closed (not restored)
|
|
pipBridge.onPiPDidStopWithoutRestore = { [weak self] in
|
|
self?.onPiPDidStopWithoutRestore?()
|
|
}
|
|
|
|
// Connect frame capture from render view to PiP bridge
|
|
_playerView?.onFrameReady = { [weak self] pixelBuffer, presentationTime in
|
|
self?.enqueueFrameForPiP(pixelBuffer, presentationTime: presentationTime)
|
|
}
|
|
|
|
// Update playerState when isPiPPossible changes (via KVO)
|
|
pipBridge.onPiPPossibleChanged = { [weak self] isPossible in
|
|
self?.pipPlayerState?.isPiPPossible = isPossible
|
|
}
|
|
|
|
// Update render view's PiP capture size when PiP window size changes
|
|
pipBridge.onPiPRenderSizeChanged = { [weak self] size in
|
|
self?._playerView?.updatePiPTargetSize(size)
|
|
}
|
|
|
|
// Manually notify current state now that callbacks are set up
|
|
pipBridge.notifyPiPPossibleState()
|
|
}
|
|
|
|
/// Start Picture-in-Picture.
|
|
func startPiP() {
|
|
guard pipBridge != nil else {
|
|
LoggingService.shared.warning("MPV (macOS): Cannot start PiP - not set up", category: .mpv)
|
|
return
|
|
}
|
|
|
|
// Enable frame capture for PiP - start capturing BEFORE requesting PiP
|
|
_playerView?.captureFramesForPiP = true
|
|
|
|
// Wait a moment for frames to be captured, then start PiP
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
self?.pipBridge?.startPiP()
|
|
}
|
|
}
|
|
|
|
/// Stop Picture-in-Picture.
|
|
func stopPiP() {
|
|
pipBridge?.stopPiP()
|
|
}
|
|
|
|
/// Toggle Picture-in-Picture.
|
|
func togglePiP() {
|
|
if isPiPActive {
|
|
stopPiP()
|
|
} else {
|
|
startPiP()
|
|
}
|
|
}
|
|
|
|
/// Called by render view when a frame is ready for PiP display.
|
|
func enqueueFrameForPiP(_ pixelBuffer: CVPixelBuffer, presentationTime: CMTime) {
|
|
guard let pipBridge else { return }
|
|
|
|
pipBridge.updatePlaybackState(
|
|
duration: duration,
|
|
currentTime: currentTime,
|
|
isPaused: !isPlaying
|
|
)
|
|
|
|
pipBridge.enqueueFrame(pixelBuffer, presentationTime: presentationTime)
|
|
}
|
|
|
|
/// Clean up PiP resources.
|
|
func cleanupPiP() {
|
|
_playerView?.captureFramesForPiP = false
|
|
_playerView?.onFrameReady = nil
|
|
pipBridge?.cleanup()
|
|
pipBridge = nil
|
|
isPiPSetUp = false
|
|
}
|
|
|
|
/// Move the PiP layer to a new container view.
|
|
func movePiPLayer(to containerView: NSView) {
|
|
pipBridge?.moveLayer(to: containerView)
|
|
}
|
|
#endif
|
|
|
|
// MARK: - Private Methods
|
|
|
|
/// Called when the render view has rendered its first frame.
|
|
/// This is used to track that we've actually displayed video content.
|
|
private func handleFirstFrameRendered() {
|
|
// Verify this callback belongs to a current load operation
|
|
// (defends against stale callbacks from a previous video)
|
|
guard currentLoadingID != nil else {
|
|
LoggingService.shared.debug("MPV: Ignoring stale first frame callback (no active load)", category: .mpv)
|
|
return
|
|
}
|
|
|
|
hasDisplayedVideo = true
|
|
LoggingService.shared.debug("MPV: First frame rendered, hasDisplayedVideo = true, pendingInitialSeek = \(pendingInitialSeek), cacheTime = \(demuxerCacheTime)s", category: .mpv)
|
|
|
|
// If we're waiting to mark ready after a resume seek, do it now
|
|
// Don't mark ready if an initial seek is pending - wait until seek completes
|
|
if !isReady && !isSeeking && !pendingInitialSeek {
|
|
isReady = true
|
|
hasStartedPlayback = true
|
|
|
|
// Resume playback if we paused for initial seek
|
|
if pendingPlayAfterSeek {
|
|
pendingPlayAfterSeek = false
|
|
mpvClient?.play()
|
|
LoggingService.shared.debug("MPV: First frame rendered, resuming playback after initial seek", category: .mpv)
|
|
}
|
|
|
|
// Notify UI that we're ready to show video
|
|
delegate?.backendDidBecomeReady(self)
|
|
delegate?.backend(self, didChangeState: isPlaying ? .playing : .ready)
|
|
}
|
|
}
|
|
|
|
private func waitForReady(loadingID: UUID) async throws {
|
|
let start = Date()
|
|
let timeout = currentLoadTimeout
|
|
|
|
LoggingService.shared.debug("MPV: Waiting for stream ready (timeout: \(Int(timeout))s, attempt \(retryCount + 1)/\(maxRetries + 1))", category: .mpv)
|
|
|
|
// Wait for video to be ready
|
|
while !isReady {
|
|
// Check if this load was cancelled
|
|
guard currentLoadingID == loadingID else {
|
|
throw CancellationError()
|
|
}
|
|
|
|
if Date().timeIntervalSince(start) > timeout {
|
|
throw BackendError.loadFailed("Timeout waiting for MPV to load stream (\(Int(timeout))s)")
|
|
}
|
|
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
}
|
|
|
|
// If we have an external audio track, wait for PLAYBACK_RESTART after audio is added
|
|
if isWaitingForExternalAudio {
|
|
LoggingService.shared.debug("MPV: Waiting for external audio track to load", category: .mpv)
|
|
while isWaitingForExternalAudio {
|
|
guard currentLoadingID == loadingID else {
|
|
throw CancellationError()
|
|
}
|
|
|
|
if Date().timeIntervalSince(start) > timeout {
|
|
throw BackendError.loadFailed("Timeout waiting for audio track (\(Int(timeout))s)")
|
|
}
|
|
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
}
|
|
LoggingService.shared.debug("MPV: External audio track loaded", category: .mpv)
|
|
}
|
|
|
|
delegate?.backendDidBecomeReady(self)
|
|
delegate?.backend(self, didChangeState: .ready)
|
|
}
|
|
}
|
|
|
|
// MARK: - MPVClientDelegate
|
|
|
|
extension MPVBackend: MPVClientDelegate {
|
|
nonisolated func mpvClient(_ client: MPVClient, didUpdateProperty property: String, value: Any?) {
|
|
Task { @MainActor [weak self] in
|
|
self?.handlePropertyChange(property: property, value: value)
|
|
}
|
|
}
|
|
|
|
nonisolated func mpvClient(_ client: MPVClient, didReceiveEvent event: mpv_event_id) {
|
|
Task { @MainActor [weak self] in
|
|
self?.handleEvent(event)
|
|
}
|
|
}
|
|
|
|
nonisolated func mpvClient(_ client: MPVClient, didUpdateCacheState cacheState: MPVCacheState) {
|
|
// Cache state is used for buffer display on seek bar - no action needed here
|
|
}
|
|
|
|
nonisolated func mpvClientDidEndFile(_ client: MPVClient, reason: MPVEndFileReason) {
|
|
Task { @MainActor [weak self] in
|
|
self?.handleEndFile(reason: reason)
|
|
}
|
|
}
|
|
|
|
// MARK: - Event Handlers
|
|
|
|
private func handlePropertyChange(property: String, value: Any?) {
|
|
switch property {
|
|
case "time-pos":
|
|
if let time = value as? Double, time >= 0 {
|
|
currentTime = time
|
|
renderView?.updateTimePosition(time)
|
|
delegate?.backend(self, didUpdateTime: time)
|
|
// Ready state is now handled by handleFirstFrameRendered()
|
|
}
|
|
|
|
case "duration":
|
|
if let dur = value as? Double, dur > 0 {
|
|
duration = dur
|
|
delegate?.backend(self, didUpdateDuration: dur)
|
|
|
|
// Audio-only streams may become ready once duration is known
|
|
checkAndMarkReadyIfAudioOnlyDetected()
|
|
}
|
|
|
|
case "pause":
|
|
if let paused = value as? Bool {
|
|
LoggingService.shared.debug("MPV: pause = \(paused), isReady = \(isReady), hasReachedEOF = \(hasReachedEOF)", category: .mpv)
|
|
isPlaying = !paused
|
|
// Only send state changes after we're ready (have actual video)
|
|
// This prevents premature transition from loading to playing
|
|
// Don't override ended state when MPV pauses at EOF (keep-open=yes)
|
|
if isReady && !hasReachedEOF {
|
|
delegate?.backend(self, didChangeState: paused ? .paused : .playing)
|
|
}
|
|
}
|
|
|
|
case "demuxer-cache-time":
|
|
if let cached = value as? Double, cached >= 0 {
|
|
demuxerCacheTime = cached
|
|
bufferedTime = currentTime + cached
|
|
delegate?.backend(self, didUpdateBufferedTime: bufferedTime)
|
|
}
|
|
|
|
case "speed":
|
|
if let speed = value as? Double {
|
|
_rate = Float(speed)
|
|
}
|
|
|
|
case "volume":
|
|
if let vol = value as? Double {
|
|
_volume = Float(vol / 100)
|
|
}
|
|
|
|
case "mute":
|
|
if let muted = value as? Bool {
|
|
_isMuted = muted
|
|
}
|
|
|
|
case "core-idle":
|
|
// core-idle indicates MPV is processing, but doesn't mean frames are ready
|
|
break
|
|
|
|
case "eof-reached":
|
|
// With keep-open=yes, MPV sends eof-reached=true instead of end-file event
|
|
if let eofReached = value as? Bool {
|
|
hasReachedEOF = eofReached
|
|
if eofReached {
|
|
LoggingService.shared.debug("MPV: EOF reached", category: .mpv)
|
|
isPlaying = false
|
|
delegate?.backend(self, didChangeState: .ended)
|
|
delegate?.backendDidFinishPlaying(self)
|
|
}
|
|
}
|
|
|
|
case "seeking":
|
|
if let seeking = value as? Bool {
|
|
isSeeking = seeking
|
|
LoggingService.shared.debug("MPV: seeking = \(seeking), hasDisplayedVideo = \(hasDisplayedVideo), isReady = \(isReady)", category: .mpv)
|
|
|
|
// When seeking completes, check if we can mark ready
|
|
if !seeking {
|
|
// Try to mark ready based on video size (fallback for render callback)
|
|
checkAndMarkReadyIfVideoAvailable()
|
|
|
|
// If we already displayed a frame before seek started, we can mark ready now
|
|
// This handles the case where handleFirstFrameRendered was deferred due to pendingInitialSeek
|
|
if hasDisplayedVideo && !isReady {
|
|
LoggingService.shared.debug("MPV: Seek completed with displayed video, marking ready", category: .mpv)
|
|
isReady = true
|
|
hasStartedPlayback = true
|
|
|
|
// Resume playback if we paused for initial seek
|
|
if pendingPlayAfterSeek {
|
|
pendingPlayAfterSeek = false
|
|
mpvClient?.play()
|
|
}
|
|
|
|
delegate?.backendDidBecomeReady(self)
|
|
delegate?.backend(self, didChangeState: isPlaying ? .playing : .ready)
|
|
}
|
|
}
|
|
}
|
|
|
|
case "width":
|
|
if let width = value as? Int64, width > 0 {
|
|
videoWidth = Int(width)
|
|
notifyVideoSizeIfReady()
|
|
}
|
|
|
|
case "height":
|
|
if let height = value as? Int64, height > 0 {
|
|
videoHeight = Int(height)
|
|
notifyVideoSizeIfReady()
|
|
}
|
|
|
|
case "container-fps":
|
|
if let fps = value as? Double, fps > 0 {
|
|
containerFps = fps
|
|
updateRenderViewFPS()
|
|
}
|
|
|
|
case "paused-for-cache":
|
|
if let isPausedForCache = value as? Bool {
|
|
pausedForCache = isPausedForCache
|
|
// Use cached values to avoid sync fetch on main thread
|
|
LoggingService.shared.debug("MPV: paused-for-cache = \(isPausedForCache), cache-time = \(demuxerCacheTime)s", category: .mpv)
|
|
|
|
// Check for buffer stall condition
|
|
if isPausedForCache && cacheBufferingState == 0 && !isInitialLoading {
|
|
startBufferStallDetection()
|
|
} else {
|
|
stopBufferStallDetection()
|
|
}
|
|
}
|
|
|
|
case "cache-buffering-state":
|
|
if let state = value as? Int64 {
|
|
cacheBufferingState = Int(state)
|
|
LoggingService.shared.debug("MPV: cache-buffering-state = \(state)%", category: .mpv)
|
|
delegate?.backend(self, didUpdateBufferProgress: Int(state))
|
|
|
|
// If buffer is no longer at 0%, cancel stall detection
|
|
if state > 0 {
|
|
stopBufferStallDetection()
|
|
}
|
|
}
|
|
|
|
case "video-codec":
|
|
if let codec = value as? String {
|
|
videoCodec = codec
|
|
}
|
|
|
|
case "hwdec-current":
|
|
if let hwdec = value as? String {
|
|
hwdecCurrent = hwdec
|
|
}
|
|
|
|
case "hwdec-interop":
|
|
if let interop = value as? String {
|
|
hwdecInterop = interop
|
|
}
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
/// Notify delegate of video size when both dimensions are available
|
|
private func notifyVideoSizeIfReady() {
|
|
guard videoWidth > 0, videoHeight > 0 else { return }
|
|
LoggingService.shared.debug("MPV: Video size detected: \(videoWidth)x\(videoHeight)", category: .mpv)
|
|
delegate?.backend(self, didUpdateVideoSize: videoWidth, height: videoHeight)
|
|
|
|
// Update PiP bridge with video aspect ratio for proper window sizing
|
|
#if os(iOS) || os(macOS)
|
|
let aspectRatio = CGFloat(videoWidth) / CGFloat(videoHeight)
|
|
pipBridge?.updateVideoAspectRatio(aspectRatio)
|
|
#endif
|
|
|
|
// Update render view with video content dimensions for accurate PiP capture
|
|
// (avoids capturing letterbox/pillarbox black bars)
|
|
#if os(iOS) || os(macOS)
|
|
renderView?.videoContentWidth = videoWidth
|
|
renderView?.videoContentHeight = videoHeight
|
|
#endif
|
|
|
|
// Update render view with video FPS for display link frame rate matching
|
|
updateRenderViewFPS()
|
|
|
|
// Use video size detection as a fallback signal that video is ready
|
|
// This handles cases where the render view's first-frame callback doesn't fire
|
|
checkAndMarkReadyIfVideoAvailable()
|
|
}
|
|
|
|
/// Update render view's video FPS for display link frame rate matching
|
|
private func updateRenderViewFPS() {
|
|
// Use cached container-fps (set via property observation to avoid sync fetch on main thread)
|
|
guard containerFps > 0 else { return }
|
|
renderView?.videoFPS = containerFps
|
|
LoggingService.shared.debug("MPV: Video FPS detected: \(containerFps)", category: .mpv)
|
|
}
|
|
|
|
/// Mark as ready if we have video dimensions and aren't seeking
|
|
/// This is a fallback for when the render view's first-frame callback doesn't fire
|
|
private func checkAndMarkReadyIfVideoAvailable() {
|
|
guard !isReady, !isSeeking, videoWidth > 0, videoHeight > 0 else { return }
|
|
|
|
LoggingService.shared.debug("MPV: Marking ready based on video size detection", category: .mpv)
|
|
isReady = true
|
|
hasDisplayedVideo = true
|
|
hasStartedPlayback = true
|
|
|
|
// Resume playback if we paused for initial seek
|
|
if pendingPlayAfterSeek {
|
|
pendingPlayAfterSeek = false
|
|
mpvClient?.play()
|
|
LoggingService.shared.debug("MPV: Resuming playback after video size detected", category: .mpv)
|
|
}
|
|
|
|
// Notify UI that we're ready to show video
|
|
delegate?.backendDidBecomeReady(self)
|
|
delegate?.backend(self, didChangeState: isPlaying ? .playing : .ready)
|
|
}
|
|
|
|
/// Mark as ready if this is an audio-only stream detected by MPV
|
|
/// Audio-only streams (like SoundCloud) have no video codec and zero dimensions
|
|
/// This is a fallback when no video frames or dimensions are available
|
|
private func checkAndMarkReadyIfAudioOnlyDetected() {
|
|
guard !isReady, !isSeeking else { return }
|
|
// Detect audio-only: no video codec and no video dimensions from MPV
|
|
guard videoCodec.isEmpty, videoWidth == 0, videoHeight == 0 else { return }
|
|
// Ensure stream metadata is loaded
|
|
guard duration > 0 else { return }
|
|
|
|
LoggingService.shared.debug("MPV: Marking ready for audio-only stream (no video track detected)", category: .mpv)
|
|
isReady = true
|
|
hasStartedPlayback = true
|
|
|
|
// Resume playback if we paused for initial seek
|
|
if pendingPlayAfterSeek {
|
|
pendingPlayAfterSeek = false
|
|
mpvClient?.play()
|
|
LoggingService.shared.debug("MPV: Resuming playback after audio-only stream ready", category: .mpv)
|
|
}
|
|
|
|
delegate?.backendDidBecomeReady(self)
|
|
delegate?.backend(self, didChangeState: isPlaying ? .playing : .ready)
|
|
}
|
|
|
|
private func handleEvent(_ event: mpv_event_id) {
|
|
switch event {
|
|
case MPV_EVENT_FILE_LOADED:
|
|
LoggingService.shared.debug("MPV: File loaded", category: .mpv)
|
|
// Log hwdec diagnostics on tvOS (use cached values to avoid sync fetch)
|
|
#if os(tvOS)
|
|
let codec = videoCodec.isEmpty ? "unknown" : videoCodec
|
|
let hwdec = hwdecCurrent.isEmpty ? "none" : hwdecCurrent
|
|
let interop = hwdecInterop.isEmpty ? "none" : hwdecInterop
|
|
LoggingService.shared.debug("MPV: Video codec: \(codec), hwdec-current: \(hwdec), hwdec-interop: \(interop)", category: .mpv)
|
|
#endif
|
|
#if os(iOS) || os(tvOS)
|
|
reactivateAudioSession()
|
|
#endif
|
|
|
|
case MPV_EVENT_PLAYBACK_RESTART:
|
|
// PLAYBACK_RESTART fires when seek/load completes and playback can begin
|
|
// When loading with external audio, this signals the audio track is ready
|
|
LoggingService.shared.debug("MPV: Playback restart event (waitingForAudio=\(isWaitingForExternalAudio))", category: .mpv)
|
|
if isWaitingForExternalAudio {
|
|
isWaitingForExternalAudio = false
|
|
}
|
|
|
|
// Audio-only streams become ready when playback can begin
|
|
// (no video frames or dimensions to wait for)
|
|
checkAndMarkReadyIfAudioOnlyDetected()
|
|
|
|
#if os(iOS) || os(tvOS)
|
|
reactivateAudioSession()
|
|
#endif
|
|
|
|
case MPV_EVENT_SEEK:
|
|
LoggingService.shared.debug("MPV: Seek completed", category: .mpv)
|
|
|
|
case MPV_EVENT_AUDIO_RECONFIG:
|
|
LoggingService.shared.debug("MPV: Audio reconfigured", category: .mpv)
|
|
#if os(iOS) || os(tvOS)
|
|
reactivateAudioSession()
|
|
#endif
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
// MARK: - Audio Session
|
|
|
|
#if os(iOS) || os(tvOS)
|
|
/// Ensures audio session is active for Now Playing integration.
|
|
/// Call this before setting up Now Playing info to ensure the system
|
|
/// recognizes the app as an active media source.
|
|
func ensureAudioSessionActive() {
|
|
reactivateAudioSession()
|
|
}
|
|
|
|
/// Re-activates the audio session to ensure Now Playing integration works.
|
|
/// MPV's audio handling can cause iOS to lose track of the active audio source,
|
|
/// so we need to re-activate the session at key playback events.
|
|
private func reactivateAudioSession() {
|
|
do {
|
|
let session = AVAudioSession.sharedInstance()
|
|
try session.setCategory(.playback, mode: .moviePlayback, options: [])
|
|
try session.setActive(true, options: [])
|
|
LoggingService.shared.debug("MPV: Audio session reactivated for Now Playing", category: .mpv)
|
|
} catch {
|
|
LoggingService.shared.error("MPV: Failed to reactivate audio session: \(error.localizedDescription)", category: .mpv)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private func handleEndFile(reason: MPVEndFileReason) {
|
|
switch reason {
|
|
case .eof:
|
|
LoggingService.shared.debug("MPV: End of file", category: .mpv)
|
|
isPlaying = false
|
|
delegate?.backend(self, didChangeState: .ended)
|
|
delegate?.backendDidFinishPlaying(self)
|
|
|
|
case .error:
|
|
// During initial loading, error handling is done in waitForReady() / loadWithRetry()
|
|
// Only report errors here if we're not in initial loading (e.g., mid-playback failure)
|
|
if !isInitialLoading {
|
|
LoggingService.shared.logMPVError("MPV: Playback error")
|
|
|
|
// Mid-playback errors are likely due to expired stream URLs
|
|
// Request stream refresh to attempt recovery
|
|
LoggingService.shared.logMPV("MPV: Requesting stream refresh for mid-playback error")
|
|
delegate?.backend(self, didRequestStreamRefresh: currentTime)
|
|
} else {
|
|
LoggingService.shared.debug("MPV: Load error (will retry)", category: .mpv)
|
|
}
|
|
|
|
case .stop:
|
|
// During initial loading (e.g., stream switch), the previous file ends with .stop
|
|
// Don't report idle state as we're loading the new stream
|
|
if !isInitialLoading {
|
|
LoggingService.shared.debug("MPV: Playback stopped", category: .mpv)
|
|
isPlaying = false
|
|
delegate?.backend(self, didChangeState: .idle)
|
|
} else {
|
|
LoggingService.shared.debug("MPV: Previous stream stopped (loading new stream)", category: .mpv)
|
|
}
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
// MARK: - Buffer Stall Detection
|
|
|
|
/// Start monitoring for buffer stall (buffer stuck at 0% for too long).
|
|
/// Called when paused-for-cache becomes true with 0% buffer during playback.
|
|
private func startBufferStallDetection() {
|
|
// Don't restart if already tracking
|
|
guard bufferStallStartTime == nil else { return }
|
|
|
|
bufferStallStartTime = Date()
|
|
LoggingService.shared.debug("MPV: Buffer stall detection started", category: .mpv)
|
|
|
|
// Start a periodic check task
|
|
bufferStallCheckTask = Task { @MainActor [weak self] in
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .seconds(5)) // Check every 5 seconds
|
|
|
|
guard let self, !Task.isCancelled else { return }
|
|
guard let stallStart = self.bufferStallStartTime else { return }
|
|
|
|
let stallDuration = Date().timeIntervalSince(stallStart)
|
|
// Use cached values (updated via property observation) to avoid sync fetch
|
|
let bufferingState = self.cacheBufferingState
|
|
let isPausedForCache = self.pausedForCache
|
|
|
|
// Log stall progress
|
|
LoggingService.shared.debug("MPV: Buffer stall check - duration=\(Int(stallDuration))s, buffering=\(bufferingState)%, pausedForCache=\(isPausedForCache)", category: .mpv)
|
|
|
|
// If buffer is still at 0% and paused for cache after timeout, trigger refresh
|
|
if stallDuration >= self.bufferStallTimeout && bufferingState == 0 && isPausedForCache {
|
|
LoggingService.shared.logMPV("MPV: Buffer stalled for \(Int(stallDuration))s, requesting stream refresh")
|
|
self.stopBufferStallDetection()
|
|
self.delegate?.backend(self, didRequestStreamRefresh: self.currentTime)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stop buffer stall monitoring.
|
|
/// Called when buffer recovers, playback resumes, or refresh is triggered.
|
|
private func stopBufferStallDetection() {
|
|
guard bufferStallStartTime != nil else { return }
|
|
|
|
bufferStallCheckTask?.cancel()
|
|
bufferStallCheckTask = nil
|
|
bufferStallStartTime = nil
|
|
LoggingService.shared.debug("MPV: Buffer stall detection stopped", category: .mpv)
|
|
}
|
|
}
|