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

898 lines
28 KiB
Swift

import AVFoundation
#if os(tvOS)
import AVKit
#endif
import QuartzCore
import UIKit
/// Core MPV player using AVFoundation sample-buffer rendering for iOS/tvOS.
class MpvPlayerCore: MpvPlayerCoreBase {
private var containerView: UIView?
private weak var window: UIWindow?
private var mainBlankView: UIView?
private var isVisible = false
private static var activeDisplayCriteriaKey: String?
private var lastDisplayCriteriaMutation: DisplayCriteriaMutation = .skipped
#if os(tvOS)
private var displayModeSwitchWaiter: DisplayModeSwitchWaiter?
private var displayModeSwitchWaiterGeneration = 0
#endif
var isPipStarting = false
private static func log(_ message: String) {
NSLog("[MpvPlayerCore] %@", message)
}
func initialize(in window: UIWindow) -> Bool {
guard !isInitialized else {
print("[MpvPlayerCore] Already initialized")
return true
}
self.window = window
let container = UIView(frame: window.bounds)
container.backgroundColor = .black
container.isUserInteractionEnabled = false
let layer = MpvVideoLayer()
layer.frame = container.bounds
layer.contentsScale = window.screen.nativeScale
layer.isOpaque = true
layer.backgroundColor = UIColor.black.cgColor
layer.videoGravity = .resizeAspect
container.layer.addSublayer(layer)
containerView = container
videoLayer = layer
window.insertSubview(container, at: 0)
guard setupMpv() else {
print("[MpvPlayerCore] Failed to setup MPV")
layer.removeFromSuperlayer()
container.removeFromSuperview()
videoLayer = nil
containerView = nil
return false
}
setupNotifications()
#if os(iOS)
ExternalDisplayManager.shared.attach(core: self)
#endif
isInitialized = true
print("[MpvPlayerCore] Initialized successfully with MPV")
return true
}
var sampleBufferDisplayLayer: MpvVideoLayer? { videoLayer }
func setVisible(_ visible: Bool) {
guard containerView != nil else { return }
isVisible = visible
if visible { refreshExternalDisplayAttachment() }
setContainerHidden(!visible)
if !visible { mainBlankView?.isHidden = true }
}
func updateFrame(_ frame: CGRect? = nil) {
guard let videoLayer, let containerView else { return }
withoutLayerAnimations {
if let frame {
containerView.frame = frame
videoLayer.frame = containerView.bounds
} else if let superview = containerView.superview {
containerView.frame = superview.bounds
videoLayer.frame = containerView.bounds
} else if let window {
containerView.frame = window.bounds
videoLayer.frame = containerView.bounds
}
mainBlankView?.frame = window?.bounds ?? .zero
let screen = containerView.window?.screen ?? window?.screen ?? UIScreen.main
let scale = screen.nativeScale > 0 ? screen.nativeScale : screen.scale
videoLayer.contentsScale = scale
}
#if os(iOS)
updateEDRMode(sigPeak: lastSigPeak)
#endif
}
func externalDisplayDidChange() {
refreshExternalDisplayAttachment()
}
private func refreshExternalDisplayAttachment() {
guard containerView != nil else { return }
let externalSuperview = externalVideoSuperview
if let externalSuperview {
moveContainerView(to: externalSuperview)
setMainBlankViewVisible(true)
} else if isVisible, let window {
moveContainerView(to: window)
setMainBlankViewVisible(false)
} else {
setMainBlankViewVisible(false)
}
setContainerHidden(!isVisible)
updateFrame()
}
private var externalVideoSuperview: UIView? {
#if os(iOS)
isVisible && !isPipActive && !isPipStarting
? ExternalDisplayManager.shared.videoSuperview
: nil
#else
nil
#endif
}
private func moveContainerView(to superview: UIView) {
guard let containerView else { return }
withoutLayerAnimations {
if containerView.superview !== superview {
containerView.removeFromSuperview()
superview.insertSubview(containerView, at: 0)
} else if superview.subviews.first !== containerView {
superview.insertSubview(containerView, at: 0)
}
containerView.frame = superview.bounds
containerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
}
}
private func setMainBlankViewVisible(_ visible: Bool) {
guard visible, let window else {
mainBlankView?.removeFromSuperview()
mainBlankView = nil
return
}
let blankView = mainBlankView ?? UIView(frame: window.bounds)
withoutLayerAnimations {
blankView.backgroundColor = .black
blankView.isUserInteractionEnabled = false
blankView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
blankView.frame = window.bounds
if blankView.superview !== window {
blankView.removeFromSuperview()
window.insertSubview(blankView, at: 0)
} else if window.subviews.first !== blankView {
window.insertSubview(blankView, at: 0)
}
blankView.isHidden = false
}
mainBlankView = blankView
}
private func setContainerHidden(_ hidden: Bool) {
withoutLayerAnimations {
containerView?.isHidden = hidden
}
}
private func withoutLayerAnimations(_ updates: () -> Void) {
CATransaction.begin()
CATransaction.setDisableActions(true)
updates()
CATransaction.commit()
}
/// Nudge mpv to present the current paused frame after leaving PiP.
func forceDraw() {
command(["seek", "0", "relative+exact"])
}
private func restoreVideoPresentation() {
guard !isPipActive else { return }
refreshExternalDisplayAttachment()
recoverDisplayLayerIfNeeded()
updateEDRMode(sigPeak: lastSigPeak)
if isPaused { forceDraw() }
}
private func recoverDisplayLayerIfNeeded() {
guard let videoLayer else { return }
var requiresFlush = false
if #available(iOS 14.0, tvOS 14.0, *) {
requiresFlush = videoLayer.requiresFlushToResumeDecoding
}
let status = videoLayer.status
guard requiresFlush || status == .failed else { return }
videoLayer.flush()
}
override func updateEDRMode(sigPeak: Double) {
#if os(iOS)
guard let videoLayer else { return }
let shouldEnableEDR = hdrEnabled && sigPeak > 1.0
if #available(iOS 26.0, *) {
withoutLayerAnimations {
videoLayer.preferredDynamicRange = shouldEnableEDR ? .high : .standard
}
} else if #available(iOS 17.0, *) {
withoutLayerAnimations {
videoLayer.wantsExtendedDynamicRangeContent = shouldEnableEDR
}
}
#endif
}
@discardableResult
override func updateDisplayCriteria(
doviProfile: Int64,
doviLevel: Int64,
doviCompatibilityId: Int64?,
fps: Double,
width: Int32,
height: Int32,
sigPeak: Double,
gamma: String?,
primaries: String?,
colorMatrix: String?
) -> Bool {
#if os(tvOS)
lastDisplayCriteriaMutation = .skipped
guard let window = containerView?.window ?? self.window else { return false }
let displayManager = window.avDisplayManager
if width <= 0 || height <= 0 {
clearDisplayCriteria(displayManager, reason: "no video dimensions")
return false
}
let refreshRate = Float(fps > 0 ? fps : 0)
let sourceHasDolbyVision = doviProfile > 0
guard
refreshRate > 0 || sourceHasDolbyVision || sigPeak > 0 || gamma != nil || primaries != nil || colorMatrix != nil
else {
clearDisplayCriteria(displayManager, reason: "no display metadata")
return false
}
let sourceBaseRange = Self.resolveBaseDisplayDynamicRange(
sigPeak: sigPeak,
gamma: gamma,
primaries: primaries,
colorMatrix: colorMatrix,
doviCompatibilityId: doviCompatibilityId
)
let sourceRange: DisplayDynamicRange = sourceHasDolbyVision ? .dolbyVision : sourceBaseRange
// The HDR toggle (`hdrEnabled`) is authoritative on tvOS: when it's off,
// drive the HDMI link in SDR regardless of the source — Dolby Vision
// included — so turning HDR off actually leaves DV mode (issue #1262).
// This is the only path that gates the HDMI mode on tvOS;
// target-colorspace-hint is inert in the avfoundation VO and EDR is iOS.
var displayRange: DisplayDynamicRange
if !hdrEnabled {
displayRange = .sdr
} else if sourceHasDolbyVision {
displayRange = .dolbyVision
} else {
displayRange = Self.supportedDisplayDynamicRange(for: sourceBaseRange)
}
guard displayManager.isDisplayCriteriaMatchingEnabled else {
clearDisplayCriteria(displayManager, reason: "matching disabled")
return false
}
guard #available(tvOS 17.0, *) else {
clearDisplayCriteria(displayManager, reason: "display criteria unavailable")
return false
}
var formatDescription = Self.makeDisplayFormatDescription(
dynamicRange: displayRange,
width: width,
height: height,
doviProfile: doviProfile,
doviLevel: doviLevel,
doviCompatibilityId: doviCompatibilityId)
if formatDescription == nil, sourceHasDolbyVision, hdrEnabled {
displayRange = sourceBaseRange
formatDescription = Self.makeDisplayFormatDescription(
dynamicRange: displayRange,
width: width,
height: height,
doviProfile: doviProfile,
doviLevel: doviLevel,
doviCompatibilityId: doviCompatibilityId)
}
guard let formatDescription else {
clearDisplayCriteria(displayManager, reason: "format description failed")
return false
}
let criteriaKey =
"\(displayRange.rawValue)|\(refreshRate)|\(width)x\(height)|\(doviProfile)|\(doviLevel)|\(doviCompatibilityId ?? -1)"
if Self.activeDisplayCriteriaKey == criteriaKey && displayManager.preferredDisplayCriteria != nil {
lastDisplayCriteriaMutation = .unchanged
return true
}
let displayCriteria = AVDisplayCriteria(
refreshRate: refreshRate,
formatDescription: formatDescription
)
displayManager.preferredDisplayCriteria = displayCriteria
Self.activeDisplayCriteriaKey = criteriaKey
lastDisplayCriteriaMutation = .set
Self.log(
"preferredDisplayCriteria set to \(displayRange.rawValue) (source: \(sourceRange.rawValue), fps: \(refreshRate), \(width)x\(height), DV profile: \(doviProfile), level: \(doviLevel), compat: \(doviCompatibilityId ?? -1))"
)
return true
#else
return false
#endif
}
func setServerDisplayCriteriaForPlayback(
_ criteria: ServerDisplayCriteria?,
extraDelayMs: Int,
completion: @escaping () -> Void
) {
let apply = { [weak self] in
guard let self else {
completion()
return
}
self.setServerDisplayCriteria(criteria) { [weak self] applied in
guard let self else {
completion()
return
}
#if os(tvOS)
guard applied || self.lastDisplayCriteriaMutation == .cleared else {
completion()
return
}
self.waitForDisplayModeSwitchIfNeeded(extraDelayMs: extraDelayMs, completion: completion)
#else
completion()
#endif
}
}
if Thread.isMainThread {
apply()
} else {
DispatchQueue.main.async(execute: apply)
}
}
private enum DisplayCriteriaMutation {
case skipped
case unchanged
case set
case cleared
}
#if os(tvOS)
private enum DisplayDynamicRange: String {
case sdr = "SDR"
case hdr10 = "HDR10"
case hlg = "HLG"
case dolbyVision = "Dolby Vision"
}
private func clearDisplayCriteria(_ displayManager: AVDisplayManager, reason: String) {
if Self.activeDisplayCriteriaKey != nil || displayManager.preferredDisplayCriteria != nil {
displayManager.preferredDisplayCriteria = nil
Self.activeDisplayCriteriaKey = nil
lastDisplayCriteriaMutation = .cleared
Self.log("preferredDisplayCriteria cleared (\(reason))")
} else {
lastDisplayCriteriaMutation = .unchanged
}
}
private func waitForDisplayModeSwitchIfNeeded(extraDelayMs: Int, completion: @escaping () -> Void) {
guard let window = containerView?.window ?? self.window else {
completion()
return
}
let displayManager = window.avDisplayManager
let mutation = lastDisplayCriteriaMutation
let shouldWaitForStart = mutation == .set || mutation == .cleared
if !shouldWaitForStart && !displayManager.isDisplayModeSwitchInProgress {
completion()
return
}
displayModeSwitchWaiter?.cancel(complete: true)
displayModeSwitchWaiterGeneration += 1
let waiterGeneration = displayModeSwitchWaiterGeneration
let waiter = DisplayModeSwitchWaiter(
displayManager: displayManager,
shouldWaitForStart: shouldWaitForStart,
extraDelayMs: extraDelayMs
) { [weak self] in
if let self, self.displayModeSwitchWaiterGeneration == waiterGeneration {
self.displayModeSwitchWaiter = nil
}
completion()
}
displayModeSwitchWaiter = waiter
waiter.start()
}
private final class DisplayModeSwitchWaiter {
private weak var displayManager: AVDisplayManager?
private let shouldWaitForStart: Bool
private let extraDelayMs: Int
private let completion: () -> Void
private var startObserver: NSObjectProtocol?
private var endObserver: NSObjectProtocol?
private var startWatchdog: DispatchWorkItem?
private var endWatchdog: DispatchWorkItem?
private var settleWorkItem: DispatchWorkItem?
private var finished = false
private var completionDelivered = false
private static let startWindowMs = 500
private static let switchWatchdogMs = 8000
private static let settleMs = 200
init(
displayManager: AVDisplayManager,
shouldWaitForStart: Bool,
extraDelayMs: Int,
completion: @escaping () -> Void
) {
self.displayManager = displayManager
self.shouldWaitForStart = shouldWaitForStart
self.extraDelayMs = max(0, min(extraDelayMs, 10_000))
self.completion = completion
}
func start() {
guard let displayManager else {
finish(waited: false, reason: "manager unavailable")
return
}
if displayManager.isDisplayModeSwitchInProgress {
beginWaitingForEnd(reason: "already in progress")
return
}
guard shouldWaitForStart else {
finish(waited: false, reason: "no switch in progress")
return
}
let center = NotificationCenter.default
startObserver = center.addObserver(
forName: .AVDisplayManagerModeSwitchStart,
object: nil,
queue: .main
) { [weak self] _ in
self?.beginWaitingForEnd(reason: "start notification")
}
let watchdog = DispatchWorkItem { [weak self] in
guard let self else { return }
if self.displayManager?.isDisplayModeSwitchInProgress == true {
self.beginWaitingForEnd(reason: "progress poll")
} else {
self.finish(waited: false, reason: "start watchdog")
}
}
startWatchdog = watchdog
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Self.startWindowMs), execute: watchdog)
}
func cancel(complete: Bool = false) {
finished = true
cleanup()
if complete { completeOnce() }
}
private func beginWaitingForEnd(reason: String) {
guard !finished else { return }
startWatchdog?.cancel()
startWatchdog = nil
if let startObserver {
NotificationCenter.default.removeObserver(startObserver)
self.startObserver = nil
}
guard displayManager?.isDisplayModeSwitchInProgress == true else {
finish(waited: true, reason: "ended before wait (\(reason))")
return
}
let center = NotificationCenter.default
endObserver = center.addObserver(
forName: .AVDisplayManagerModeSwitchEnd,
object: nil,
queue: .main
) { [weak self] _ in
self?.finish(waited: true, reason: "end notification")
}
let watchdog = DispatchWorkItem { [weak self] in
self?.finish(waited: true, reason: "end watchdog")
}
endWatchdog = watchdog
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Self.switchWatchdogMs), execute: watchdog)
}
private func finish(waited: Bool, reason: String) {
guard !finished else { return }
finished = true
cleanup()
let delayMs = waited ? Self.settleMs + extraDelayMs : 0
MpvPlayerCore.log(
"display mode switch wait complete "
+ "(\(reason), waited: \(waited), extraDelayMs: \(extraDelayMs))"
)
guard delayMs > 0 else {
completeOnce()
return
}
let workItem = DispatchWorkItem { [weak self] in
self?.completeOnce()
}
settleWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem)
}
private func completeOnce() {
guard !completionDelivered else { return }
completionDelivered = true
settleWorkItem?.cancel()
settleWorkItem = nil
completion()
}
private func cleanup() {
startWatchdog?.cancel()
endWatchdog?.cancel()
settleWorkItem?.cancel()
startWatchdog = nil
endWatchdog = nil
settleWorkItem = nil
if let startObserver {
NotificationCenter.default.removeObserver(startObserver)
self.startObserver = nil
}
if let endObserver {
NotificationCenter.default.removeObserver(endObserver)
self.endObserver = nil
}
}
deinit {
cancel()
}
}
private static func resolveBaseDisplayDynamicRange(
sigPeak: Double,
gamma: String?,
primaries: String?,
colorMatrix: String?,
doviCompatibilityId: Int64?
) -> DisplayDynamicRange {
let normalizedGamma = normalizeColorTag(gamma)
let normalizedPrimaries = normalizeColorTag(primaries)
let normalizedColorMatrix = normalizeColorTag(colorMatrix)
if normalizedGamma.contains("hlg") || normalizedGamma.contains("arib") {
return .hlg
}
if normalizedGamma.contains("pq") || normalizedGamma.contains("smpte2084")
|| normalizedGamma.contains("st2084") || sigPeak > 1.0
|| normalizedPrimaries.contains("bt2020") || normalizedColorMatrix.contains("bt2020")
{
return .hdr10
}
switch doviCompatibilityId {
case 1, 6:
return .hdr10
case 4:
return .hlg
case 2:
return .sdr
default:
break
}
return .sdr
}
private static func normalizeColorTag(_ value: String?) -> String {
value?.lowercased().filter { $0.isLetter || $0.isNumber } ?? ""
}
private static func supportedDisplayDynamicRange(for range: DisplayDynamicRange) -> DisplayDynamicRange {
let availableModes = AVPlayer.availableHDRModes
switch range {
case .dolbyVision:
if availableModes.contains(.dolbyVision) { return .dolbyVision }
if availableModes.contains(.hdr10) { return .hdr10 }
if availableModes.contains(.hlg) { return .hlg }
return .sdr
case .hdr10:
return availableModes.contains(.hdr10) ? .hdr10 : .sdr
case .hlg:
return availableModes.contains(.hlg) ? .hlg : .sdr
case .sdr:
return .sdr
}
}
private static func makeDisplayFormatDescription(
dynamicRange: DisplayDynamicRange,
width: Int32,
height: Int32,
doviProfile: Int64,
doviLevel: Int64,
doviCompatibilityId: Int64?
) -> CMVideoFormatDescription? {
if dynamicRange == .dolbyVision {
// Profile 8.x always carries a compatibility id; profile 5 has none.
// We assume bl_signal_compatibility_id = 1 (HDR10 base) for profile 8
// because mpv does not expose the compat id and that's by far the
// most common case.
let fallbackCompat: Int64 = doviProfile == 8 ? 1 : 0
let compat = UInt8(truncatingIfNeeded: doviCompatibilityId ?? fallbackCompat)
return makeDolbyVisionFormatDescription(
width: width,
height: height,
profile: UInt8(truncatingIfNeeded: doviProfile),
level: UInt8(truncatingIfNeeded: doviLevel),
compatibility: compat
)
}
let extensions: [CFString: Any]
switch dynamicRange {
case .hdr10:
extensions = [
kCMFormatDescriptionExtension_ColorPrimaries:
kCMFormatDescriptionColorPrimaries_ITU_R_2020,
kCMFormatDescriptionExtension_TransferFunction:
kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ,
kCMFormatDescriptionExtension_YCbCrMatrix:
kCMFormatDescriptionYCbCrMatrix_ITU_R_2020,
]
case .hlg:
extensions = [
kCMFormatDescriptionExtension_ColorPrimaries:
kCMFormatDescriptionColorPrimaries_ITU_R_2020,
kCMFormatDescriptionExtension_TransferFunction:
kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG,
kCMFormatDescriptionExtension_YCbCrMatrix:
kCMFormatDescriptionYCbCrMatrix_ITU_R_2020,
]
case .sdr:
extensions = [
kCMFormatDescriptionExtension_ColorPrimaries:
kCMFormatDescriptionColorPrimaries_ITU_R_709_2,
kCMFormatDescriptionExtension_TransferFunction:
kCMFormatDescriptionTransferFunction_ITU_R_709_2,
kCMFormatDescriptionExtension_YCbCrMatrix:
kCMFormatDescriptionYCbCrMatrix_ITU_R_709_2,
]
case .dolbyVision:
return nil
}
var fd: CMVideoFormatDescription?
let status = CMVideoFormatDescriptionCreate(
allocator: kCFAllocatorDefault,
codecType: kCMVideoCodecType_HEVC,
width: width,
height: height,
extensions: extensions as CFDictionary,
formatDescriptionOut: &fd
)
return status == noErr ? fd : nil
}
/// Build a synthetic 'dvh1' `CMVideoFormatDescription` from the Dolby Vision
/// metadata mpv exposes. Used solely as a hint object for
/// `AVDisplayCriteria(refreshRate:formatDescription:)` — it is never
/// enqueued onto the sample-buffer layer.
private static func makeDolbyVisionFormatDescription(
width: Int32,
height: Int32,
profile: UInt8,
level: UInt8,
compatibility: UInt8
) -> CMVideoFormatDescription? {
// 24-byte Dolby Vision configuration record (dvcC ≤ profile 7, dvvC ≥ 8).
// Layout from ETSI TS 103 572 §7.1.1 — same packing as FFmpeg's
// videotoolbox_dovi_extradata_create (in 0002 patch):
// [0] dv_version_major (= 1)
// [1] dv_version_minor (= 0)
// [2..3] big-endian uint16: profile<<9 | level<<3 | rpu<<2 | el<<1 | bl
// [4] compatibility<<4 | md_compression<<2
// [5..23] reserved zero
var dovi = [UInt8](repeating: 0, count: 24)
dovi[0] = 1
dovi[1] = 0
let flags: UInt16 =
(UInt16(profile) & 0x7f) << 9
| (UInt16(level) & 0x3f) << 3
| (1 << 2) // rpu_present_flag
| (1 << 0) // bl_present_flag
dovi[2] = UInt8((flags >> 8) & 0xff)
dovi[3] = UInt8(flags & 0xff)
dovi[4] = (compatibility & 0x0f) << 4
// CoreMedia carries codec-specific boxes under
// kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms.
let recordKey: CFString = (profile > 7 ? "dvvC" : "dvcC") as CFString
let atoms: [CFString: Any] = [recordKey: Data(dovi) as CFData]
let extensions: [CFString: Any] = [
kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms: atoms as CFDictionary,
kCMFormatDescriptionExtension_ColorPrimaries:
kCMFormatDescriptionColorPrimaries_ITU_R_2020,
kCMFormatDescriptionExtension_TransferFunction:
kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ,
kCMFormatDescriptionExtension_YCbCrMatrix:
kCMFormatDescriptionYCbCrMatrix_ITU_R_2020,
]
var fd: CMVideoFormatDescription?
let status = CMVideoFormatDescriptionCreate(
allocator: kCFAllocatorDefault,
codecType: kCMVideoCodecType_DolbyVisionHEVC, // 'dvh1'
width: width,
height: height,
extensions: extensions as CFDictionary,
formatDescriptionOut: &fd
)
return status == noErr ? fd : nil
}
#endif
func dispose(preserveDisplayCriteria: Bool = false) {
guard beginDisposal() else { return }
#if os(tvOS)
if preserveDisplayCriteria {
Self.log("dispose preserving display criteria (key: \(Self.activeDisplayCriteriaKey ?? "nil"))")
}
#endif
// Reset the HDMI mode hint synchronously while self is still alive
// and on main. An async-to-main dispatch here would be drained after
// dealloc (the plugin sets playerCore = nil right after this call
// returns), leaving the link stuck at the last clip's refresh rate.
// During video-to-video replacement, keep the hint so tvOS doesn't
// renegotiate back to default before the replacement route can set its
// next criteria.
if !preserveDisplayCriteria {
updateDisplayCriteria(
doviProfile: 0, doviLevel: 0, doviCompatibilityId: nil,
fps: 0, width: 0, height: 0, sigPeak: 0,
gamma: nil, primaries: nil, colorMatrix: nil)
}
#if os(tvOS)
displayModeSwitchWaiter?.cancel(complete: true)
displayModeSwitchWaiter = nil
#endif
NotificationCenter.default.removeObserver(self)
#if os(iOS)
ExternalDisplayManager.shared.detach(core: self)
#endif
disposeSharedState(destroySynchronously: false)
videoLayer?.removeFromSuperlayer()
videoLayer = nil
containerView?.removeFromSuperview()
containerView = nil
mainBlankView?.removeFromSuperview()
mainBlankView = nil
isInitialized = false
Self.log("Disposed")
}
deinit {
dispose()
}
private func setupNotifications() {
#if os(iOS)
let scene = window?.windowScene
NotificationCenter.default.addObserver(
self,
selector: #selector(enterBackground),
name: UIScene.didEnterBackgroundNotification,
object: scene
)
NotificationCenter.default.addObserver(
self,
selector: #selector(enterForeground),
name: UIScene.willEnterForegroundNotification,
object: scene
)
NotificationCenter.default.addObserver(
self,
selector: #selector(sceneDidActivate),
name: UIScene.didActivateNotification,
object: scene
)
#else
NotificationCenter.default.addObserver(
self,
selector: #selector(enterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(enterForeground),
name: UIApplication.willEnterForegroundNotification,
object: nil
)
#endif
}
@objc private func enterBackground() {
setBackgrounded(true)
if isPipActive || isPipStarting {
print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video")
return
}
print("[MpvPlayerCore] Entering background - disabling video")
setProperty("vid", value: "no")
}
@objc private func enterForeground() {
setBackgrounded(false)
if isPipActive {
print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore")
return
}
print("[MpvPlayerCore] Entering foreground - enabling video")
setProperty("vid", value: "auto")
}
#if os(iOS)
@objc private func sceneDidActivate() {
setBackgrounded(false)
if isPipActive {
return
}
setProperty("vid", value: "auto")
restoreVideoPresentation()
}
#endif
}