Files
plezy/ios/Runner/MpvPlayer/MpvPipController.swift
T

552 lines
20 KiB
Swift

import AVFoundation
import AVKit
import UIKit
#if os(tvOS)
// tvOS stub: AVPictureInPictureController has different constraints on tvOS
// and is not supported by the Plezy flow. Provide a no-op shell so callers
// in MpvPlayerPlugin compile unchanged; isSupported reports false so PiP is
// never attempted at runtime.
protocol MpvPipDelegate: AnyObject {
func pipWillStart()
func pipDidStart()
func pipDidStop(restored: Bool)
func pipDidFailToStart(error: Error?)
func pipSetPlaying(_ playing: Bool)
func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void)
var isPipPlaying: Bool { get }
var pipDuration: Double { get }
}
class MpvPipController: NSObject {
static var isSupported: Bool { false }
weak var delegate: MpvPipDelegate?
var isPipActive: Bool { false }
var autoStartEnabled: Bool { false }
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) { super.init() }
func setup(with layer: CALayer, containerView: UIView) {}
func setAutoStart(_ enabled: Bool) {}
func warmLayer(currentTime: Double, isPlaying: Bool) {}
func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) {
completion(false)
}
func stopPip() {}
func invalidatePlaybackState() {}
func syncTimebase(currentTime: Double, isPlaying: Bool) {}
func teardown() {}
}
#else
/// Delegate to notify the plugin of PiP lifecycle events
protocol MpvPipDelegate: AnyObject {
/// Called when PiP is about to start (system or app-initiated)
func pipWillStart()
func pipDidStart()
/// Called when PiP stops. `restored` is true if the user pressed maximize (restore UI).
func pipDidStop(restored: Bool)
func pipDidFailToStart(error: Error?)
/// Forward play/pause commands from PiP overlay to mpv
func pipSetPlaying(_ playing: Bool)
/// Forward skip forward/backward commands from PiP overlay to mpv
func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void)
/// Query whether mpv is currently playing
var isPipPlaying: Bool { get }
/// Get total duration in seconds
var pipDuration: Double { get }
}
protocol MpvPictureInPictureControlling: AnyObject {
var isPictureInPicturePossible: Bool { get }
func startPictureInPicture()
func stopPictureInPicture()
func setAutomaticStart(_ enabled: Bool)
func invalidatePlaybackState()
}
@available(iOS 15.0, *)
extension AVPictureInPictureController: MpvPictureInPictureControlling {
func setAutomaticStart(_ enabled: Bool) {
canStartPictureInPictureAutomaticallyFromInline = enabled
}
}
/// Encapsulates all iOS Picture-in-Picture logic using AVSampleBufferDisplayLayer.
/// Requires iOS 15+ for the ContentSource API; on older versions, `setup()` is a no-op.
class MpvPipController: NSObject {
// MARK: - Properties
private var pipController: MpvPictureInPictureControlling?
private weak var sampleBufferLayer: AVSampleBufferDisplayLayer?
weak var delegate: MpvPipDelegate?
private var startGeneration = 0
private var pendingStartCompletion: ((Bool) -> Void)?
private var startRequested = false
private var systemStartExpected = false
private var hasActiveSession = false
private var restoreRequested = false
private var isTornDown = false
private let readinessOverride: (() -> (possible: Bool, timebase: Bool, frame: Bool))?
private let retryScheduler: (@escaping () -> Void) -> Void
private let startTimeoutScheduler: (@escaping () -> Void) -> Void
private let replacementControllerFactory: ((AVSampleBufferDisplayLayer?) -> MpvPictureInPictureControlling)?
private var autoStartEnabled = false
// MARK: - Initialization
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) {
self.sampleBufferLayer = sampleBufferDisplayLayer
self.readinessOverride = nil
self.retryScheduler = { work in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work)
}
self.startTimeoutScheduler = { work in
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: work)
}
self.replacementControllerFactory = nil
super.init()
setup()
}
init(
controller: MpvPictureInPictureControlling,
sampleBufferDisplayLayer: AVSampleBufferDisplayLayer? = nil,
readiness: @escaping () -> (possible: Bool, timebase: Bool, frame: Bool),
retryScheduler: @escaping (@escaping () -> Void) -> Void,
startTimeoutScheduler: @escaping (@escaping () -> Void) -> Void = { work in
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: work)
},
replacementControllerFactory:
((AVSampleBufferDisplayLayer?) -> MpvPictureInPictureControlling)? = nil
) {
self.pipController = controller
self.sampleBufferLayer = sampleBufferDisplayLayer
self.readinessOverride = readiness
self.retryScheduler = retryScheduler
self.startTimeoutScheduler = startTimeoutScheduler
self.replacementControllerFactory = replacementControllerFactory
super.init()
}
deinit {
if let completion = pendingStartCompletion {
pendingStartCompletion = nil
if Thread.isMainThread {
completion(false)
} else {
DispatchQueue.main.async { completion(false) }
}
}
}
private func setup() {
guard #available(iOS 15.0, *) else { return }
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("[MpvPipController] Failed to configure audio session: \(error)")
}
createPipController()
}
/// Helper that conforms to the iOS 15+ delegate protocols
private var delegateHelper: AnyObject?
private func createPipController() {
guard #available(iOS 15.0, *), let sampleBufferLayer else { return }
let helper = PipDelegateHelper(controller: self)
let contentSource = AVPictureInPictureController.ContentSource(
sampleBufferDisplayLayer: sampleBufferLayer,
playbackDelegate: helper
)
self.delegateHelper = helper
pipController = AVPictureInPictureController(contentSource: contentSource)
(pipController as? AVPictureInPictureController)?.delegate = helper
pipController?.setAutomaticStart(autoStartEnabled)
}
/// Enable/disable system auto-PiP (starts PiP automatically on background transition)
func setAutoStart(_ enabled: Bool) {
guard !isTornDown else { return }
let wasEnabled = autoStartEnabled
autoStartEnabled = enabled
if enabled && !wasEnabled { systemStartExpected = false }
pipController?.setAutomaticStart(enabled)
}
/// MPVKit owns the sample-buffer layer timebase. PiP only reads it.
func syncTimebase(currentTime: Double, isPlaying: Bool) {
}
/// Ensure the layer has MPVKit's renderer-owned timebase before PiP starts.
func warmLayer(currentTime: Double, isPlaying: Bool) {
if sampleBufferLayer?.controlTimebase == nil {
print("[MpvPipController] Waiting for MPVKit renderer timebase before PiP")
}
}
// MARK: - Public API
static var isSupported: Bool {
guard #available(iOS 15.0, *) else { return false }
return AVPictureInPictureController.isPictureInPictureSupported()
}
fileprivate func isCurrentController(_ controller: MpvPictureInPictureControlling) -> Bool {
guard let pipController else { return false }
return ObjectIdentifier(pipController) == ObjectIdentifier(controller)
}
private func retireCurrentPipController() {
if #available(iOS 15.0, *) {
(delegateHelper as? PipDelegateHelper)?.controller = nil
(pipController as? AVPictureInPictureController)?.delegate = nil
}
pipController?.setAutomaticStart(false)
pipController?.stopPictureInPicture()
pipController = nil
delegateHelper = nil
}
private func recreatePipController() {
if let replacementControllerFactory {
pipController = replacementControllerFactory(sampleBufferLayer)
pipController?.setAutomaticStart(autoStartEnabled)
} else if sampleBufferLayer != nil {
createPipController()
}
}
private func finishStart(generation: Int, success: Bool) {
guard generation == startGeneration, let completion = pendingStartCompletion else { return }
pendingStartCompletion = nil
startRequested = false
completion(success)
}
private func cancelPendingStart() {
startGeneration &+= 1
startRequested = false
guard let completion = pendingStartCompletion else { return }
pendingStartCompletion = nil
completion(false)
}
private func readiness(waitForFrame: Bool) -> (possible: Bool, timebase: Bool, frame: Bool) {
if let readinessOverride {
return readinessOverride()
}
let possible = pipController?.isPictureInPicturePossible ?? false
let timebase = sampleBufferLayer?.controlTimebase != nil
let frame: Bool
if !waitForFrame {
frame = true
} else if #available(iOS 17.4, *) {
frame = sampleBufferLayer?.isReadyForDisplay ?? false
} else {
frame = true
}
return (possible, timebase, frame)
}
private func scheduleStartTimeout(
generation: Int,
controllerIdentifier: ObjectIdentifier
) {
startTimeoutScheduler { [weak self] in
guard let self, !isTornDown, generation == startGeneration,
startRequested, let completion = pendingStartCompletion,
let pipController,
ObjectIdentifier(pipController) == controllerIdentifier
else { return }
print("[MpvPipController] PiP start produced no delegate outcome before the deadline")
pendingStartCompletion = nil
startRequested = false
systemStartExpected = false
retireCurrentPipController()
recreatePipController()
completion(false)
}
}
private func retryStart(generation: Int, waitForFrame: Bool, attempts: Int) {
guard !isTornDown, generation == startGeneration, pendingStartCompletion != nil,
let pipController
else { return }
let readiness = readiness(waitForFrame: waitForFrame)
if readiness.possible && readiness.timebase && readiness.frame {
guard !startRequested else { return }
startRequested = true
print("[MpvPipController] vo_avfoundation ready after \(attempts) retries, starting PiP")
pipController.startPictureInPicture()
scheduleStartTimeout(
generation: generation,
controllerIdentifier: ObjectIdentifier(pipController)
)
} else if attempts < 40 {
retryScheduler { [weak self] in
self?.retryStart(
generation: generation, waitForFrame: waitForFrame, attempts: attempts + 1)
}
} else {
print(
"[MpvPipController] PiP not ready after \(attempts) retries "
+ "(possible=\(readiness.possible), timebase=\(readiness.timebase))"
)
finishStart(generation: generation, success: false)
}
}
func pictureInPictureWillStart(from controller: MpvPictureInPictureControlling) {
guard !isTornDown, isCurrentController(controller) else { return }
systemStartExpected = true
delegate?.pipWillStart()
}
func pictureInPictureWillStart() {
guard let pipController else { return }
pictureInPictureWillStart(from: pipController)
}
func pictureInPictureDidStart(from controller: MpvPictureInPictureControlling) {
guard !isTornDown, isCurrentController(controller) else { return }
guard systemStartExpected || pendingStartCompletion != nil else {
controller.stopPictureInPicture()
return
}
hasActiveSession = true
systemStartExpected = false
// Resolve the pending manual method call before the delegate publishes
// PiP state: the plugin's delegate path may suspend the application.
finishStart(generation: startGeneration, success: true)
delegate?.pipDidStart()
}
func pictureInPictureDidStart() {
guard let pipController else { return }
pictureInPictureDidStart(from: pipController)
}
func pictureInPictureFailedToStart(
from controller: MpvPictureInPictureControlling,
error: Error
) {
guard !isTornDown, isCurrentController(controller),
systemStartExpected || pendingStartCompletion != nil
else { return }
systemStartExpected = false
delegate?.pipDidFailToStart(error: error)
finishStart(generation: startGeneration, success: false)
}
func pictureInPictureFailedToStart(error: Error) {
guard let pipController else { return }
pictureInPictureFailedToStart(from: pipController, error: error)
}
func pictureInPictureDidStop(from controller: MpvPictureInPictureControlling) {
guard !isTornDown, isCurrentController(controller), hasActiveSession else { return }
hasActiveSession = false
let restored = restoreRequested
restoreRequested = false
delegate?.pipDidStop(restored: restored)
}
func pictureInPictureDidStop() {
guard let pipController else { return }
pictureInPictureDidStop(from: pipController)
}
func restoreUserInterface(completion: @escaping (Bool) -> Void) {
let canRestore = !isTornDown && hasActiveSession && delegate != nil
restoreRequested = canRestore
completion(canRestore)
}
/// Start PiP. Completion reports the delegate-confirmed terminal outcome.
func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) {
guard !isTornDown, pipController != nil else {
completion(false)
return
}
guard pendingStartCompletion == nil else {
completion(false)
return
}
startGeneration &+= 1
let generation = startGeneration
pendingStartCompletion = completion
startRequested = false
systemStartExpected = false
retryStart(generation: generation, waitForFrame: waitForFrame, attempts: 0)
}
func stopPip() {
cancelPendingStart()
systemStartExpected = false
restoreRequested = false
pipController?.stopPictureInPicture()
}
/// Invalidate the playback state so PiP updates its UI (play/pause button)
func invalidatePlaybackState() {
guard !isTornDown else { return }
pipController?.invalidatePlaybackState()
}
/// Fully tear down PiP without touching the shared inline display layer.
func teardown() {
guard !isTornDown else { return }
cancelPendingStart()
isTornDown = true
systemStartExpected = false
hasActiveSession = false
restoreRequested = false
retireCurrentPipController()
delegate = nil
}
}
// MARK: - PiP Delegate Helper (iOS 15+)
/// Separate class conforming to AVPictureInPictureControllerDelegate and
/// AVPictureInPictureSampleBufferPlaybackDelegate since these require iOS 15+
/// availability for the ContentSource-based delegate methods.
@available(iOS 15.0, *)
private class PipDelegateHelper: NSObject, AVPictureInPictureControllerDelegate,
AVPictureInPictureSampleBufferPlaybackDelegate
{
weak var controller: MpvPipController?
init(controller: MpvPipController) {
self.controller = controller
super.init()
}
// MARK: - AVPictureInPictureControllerDelegate
func pictureInPictureControllerWillStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
print("[MpvPipController] PiP will start")
controller?.pictureInPictureWillStart(from: pictureInPictureController)
}
func pictureInPictureControllerDidStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
print("[MpvPipController] PiP did start")
controller?.pictureInPictureDidStart(from: pictureInPictureController)
}
func pictureInPictureControllerDidStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
print("[MpvPipController] PiP did stop")
controller?.pictureInPictureDidStop(from: pictureInPictureController)
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
failedToStartPictureInPictureWithError error: Error
) {
print("[MpvPipController] PiP failed to start: \(error)")
controller?.pictureInPictureFailedToStart(
from: pictureInPictureController,
error: error
)
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler:
@escaping (Bool) -> Void
) {
print("[MpvPipController] PiP restore user interface")
guard let controller,
controller.isCurrentController(pictureInPictureController)
else {
completionHandler(false)
return
}
controller.restoreUserInterface(completion: completionHandler)
}
func pictureInPictureControllerWillStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
guard controller?.isCurrentController(pictureInPictureController) == true else { return }
print("[MpvPipController] PiP will stop")
}
// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
setPlaying playing: Bool
) {
guard let controller,
controller.isCurrentController(pictureInPictureController)
else { return }
print("[MpvPipController] PiP setPlaying: \(playing)")
controller.delegate?.pipSetPlaying(playing)
}
func pictureInPictureControllerTimeRangeForPlayback(
_ pictureInPictureController: AVPictureInPictureController
) -> CMTimeRange {
guard let controller,
controller.isCurrentController(pictureInPictureController)
else {
return CMTimeRange(start: .zero, duration: CMTime(seconds: 1, preferredTimescale: 1))
}
let duration = controller.delegate?.pipDuration ?? 0
if duration > 0 {
return CMTimeRange(
start: .zero,
duration: CMTime(seconds: duration, preferredTimescale: 1000)
)
}
return CMTimeRange(start: .zero, duration: CMTime(seconds: 1, preferredTimescale: 1))
}
func pictureInPictureControllerIsPlaybackPaused(
_ pictureInPictureController: AVPictureInPictureController
) -> Bool {
guard let controller,
controller.isCurrentController(pictureInPictureController)
else { return true }
return !(controller.delegate?.isPipPlaying ?? false)
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
didTransitionToRenderSize newRenderSize: CMVideoDimensions
) {}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
skipByInterval skipInterval: CMTime,
completion completionHandler: @escaping () -> Void
) {
guard let controller,
controller.isCurrentController(pictureInPictureController)
else {
completionHandler()
return
}
let seconds = CMTimeGetSeconds(skipInterval)
print("[MpvPipController] PiP skip by \(seconds)s")
guard let delegate = controller.delegate else {
completionHandler()
return
}
delegate.pipSkip(byInterval: seconds, completion: completionHandler)
}
}
#endif // !os(tvOS)