feat(ios): support external display playback

close #984
This commit is contained in:
edde746
2026-05-06 02:17:20 +02:00
parent 25a2187bf4
commit 88430ba7b6
6 changed files with 281 additions and 18 deletions
+23 -1
View File
@@ -70,7 +70,7 @@
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
@@ -86,6 +86,28 @@
<string>Main</string>
</dict>
</array>
<key>UIWindowSceneSessionRoleExternalDisplay</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).ExternalDisplaySceneDelegate</string>
<key>UISceneConfigurationName</key>
<string>external-display</string>
</dict>
</array>
<key>UIWindowSceneSessionRoleExternalDisplayNonInteractive</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).ExternalDisplaySceneDelegate</string>
<key>UISceneConfigurationName</key>
<string>external-display</string>
</dict>
</array>
</dict>
</dict>
</dict>
@@ -0,0 +1,172 @@
import UIKit
final class ExternalDisplayManager {
static let shared = ExternalDisplayManager()
private weak var activeCore: MpvPlayerCore?
private var externalScene: UIWindowScene?
private var externalWindow: UIWindow?
private var externalRootViewController: UIViewController?
private init() {}
static func isExternalDisplayRole(_ role: UISceneSession.Role) -> Bool {
role.rawValue == "UIWindowSceneSessionRoleExternalDisplay"
|| role.rawValue == "UIWindowSceneSessionRoleExternalDisplayNonInteractive"
}
static var hasActiveApplicationScene: Bool {
applicationWindowScenes.contains { $0.activationState == .foregroundActive }
}
static func mainApplicationWindow() -> UIWindow? {
let scenes = applicationWindowScenes.sorted {
scenePriority($0.activationState) < scenePriority($1.activationState)
}
for scene in scenes {
if let window = scene.windows.first(where: { $0.isKeyWindow }) {
return window
}
}
for scene in scenes {
if let window = scene.windows.first(where: { !$0.isHidden }) {
return window
}
}
return scenes.first?.windows.first
}
private static var applicationWindowScenes: [UIWindowScene] {
UIApplication.shared.connectedScenes.compactMap { scene in
guard let windowScene = scene as? UIWindowScene,
!isExternalDisplayRole(windowScene.session.role)
else { return nil }
return windowScene
}
}
private static func scenePriority(_ state: UIScene.ActivationState) -> Int {
switch state {
case .foregroundActive:
return 0
case .foregroundInactive:
return 1
case .background:
return 2
case .unattached:
return 3
@unknown default:
return 4
}
}
var videoSuperview: UIView? {
externalRootViewController?.view
}
func attach(core: MpvPlayerCore) {
activeCore = core
discoverExistingExternalScene()
activeCore?.externalDisplayDidChange()
}
func detach(core: MpvPlayerCore) {
if activeCore === core {
activeCore = nil
}
}
@discardableResult
func connect(scene: UIWindowScene) -> UIWindow? {
guard Self.isExternalDisplayRole(scene.session.role) else { return nil }
externalScene = scene
let window = ensureWindow(for: scene)
activeCore?.externalDisplayDidChange()
print("[ExternalDisplayManager] External display connected")
return window
}
func disconnect(scene: UIScene) {
guard scene === externalScene else { return }
externalWindow?.isHidden = true
externalWindow = nil
externalRootViewController = nil
externalScene = nil
activeCore?.externalDisplayDidChange()
print("[ExternalDisplayManager] External display disconnected")
}
func update(scene: UIWindowScene) {
guard scene === externalScene else { return }
externalRootViewController?.view.frame = scene.coordinateSpace.bounds
activeCore?.externalDisplayDidChange()
}
private func discoverExistingExternalScene() {
guard externalScene == nil else { return }
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene,
Self.isExternalDisplayRole(windowScene.session.role)
else { continue }
_ = connect(scene: windowScene)
return
}
}
private func ensureWindow(for scene: UIWindowScene) -> UIWindow {
if let existing = externalWindow, existing.windowScene === scene {
return existing
}
externalWindow?.isHidden = true
let rootViewController = UIViewController()
rootViewController.view.frame = scene.coordinateSpace.bounds
rootViewController.view.backgroundColor = .black
let window = UIWindow(windowScene: scene)
window.rootViewController = rootViewController
window.backgroundColor = .black
window.isHidden = false
externalRootViewController = rootViewController
externalWindow = window
return window
}
}
class ExternalDisplaySceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene,
ExternalDisplayManager.isExternalDisplayRole(session.role)
else { return }
window = ExternalDisplayManager.shared.connect(scene: windowScene)
}
func sceneDidDisconnect(_ scene: UIScene) {
ExternalDisplayManager.shared.disconnect(scene: scene)
window = nil
}
func windowScene(
_ windowScene: UIWindowScene,
didUpdate previousCoordinateSpace: UICoordinateSpace,
interfaceOrientation previousInterfaceOrientation: UIInterfaceOrientation,
traitCollection previousTraitCollection: UITraitCollection
) {
ExternalDisplayManager.shared.update(scene: windowScene)
}
}
+1 -3
View File
@@ -95,9 +95,7 @@ import UIKit
// The sample buffer layer must be in a visible view hierarchy for
// isPictureInPicturePossible to become true.
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first(where: { $0.isKeyWindow })
{
if let window = ExternalDisplayManager.mainApplicationWindow() {
let view = UIView(frame: window.bounds)
view.clipsToBounds = true
view.isUserInteractionEnabled = false
+79 -7
View File
@@ -6,6 +6,8 @@ class MpvPlayerCore: MpvPlayerCoreBase {
private var containerView: UIView?
private weak var window: UIWindow?
private var mainBlankView: UIView?
private var isVisible = false
var isPipStarting = false
@@ -23,7 +25,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
let layer = MpvMetalLayer()
layer.frame = container.bounds
layer.contentsScale = UIScreen.main.nativeScale
layer.contentsScale = window.screen.nativeScale
layer.framebufferOnly = true
layer.backgroundColor = UIColor.black.cgColor
@@ -43,6 +45,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}
setupNotifications()
ExternalDisplayManager.shared.attach(core: self)
isInitialized = true
print("[MpvPlayerCore] Initialized successfully with MPV")
@@ -87,12 +90,10 @@ class MpvPlayerCore: MpvPlayerCoreBase {
func setVisible(_ visible: Bool) {
guard let containerView else { return }
if visible {
containerView.removeFromSuperview()
window?.insertSubview(containerView, at: 0)
}
isVisible = visible
if visible { refreshExternalDisplayAttachment() }
containerView.isHidden = !visible
if !visible { mainBlankView?.isHidden = true }
}
func updateFrame(_ frame: CGRect? = nil) {
@@ -101,18 +102,86 @@ class MpvPlayerCore: MpvPlayerCoreBase {
if let frame {
containerView.frame = frame
metalLayer.frame = containerView.bounds
} else if let superview = containerView.superview {
containerView.frame = superview.bounds
metalLayer.frame = containerView.bounds
} else if let window {
containerView.frame = window.bounds
metalLayer.frame = containerView.bounds
}
let scale = UIScreen.main.nativeScale
mainBlankView?.frame = window?.bounds ?? .zero
let screen = containerView.window?.screen ?? window?.screen ?? UIScreen.main
let scale = screen.nativeScale > 0 ? screen.nativeScale : screen.scale
metalLayer.contentsScale = scale
metalLayer.drawableSize = CGSize(
width: metalLayer.frame.width * scale,
height: metalLayer.frame.height * scale
)
}
func externalDisplayDidChange() {
refreshExternalDisplayAttachment()
}
private func refreshExternalDisplayAttachment() {
guard let containerView else { return }
let externalSuperview =
isVisible && !isPipActive && !isPipStarting
? ExternalDisplayManager.shared.videoSuperview
: nil
if let externalSuperview {
moveContainerView(to: externalSuperview)
setMainBlankViewVisible(true)
} else if isVisible, let window {
moveContainerView(to: window)
setMainBlankViewVisible(false)
} else {
setMainBlankViewVisible(false)
}
containerView.isHidden = !isVisible
updateFrame()
}
private func moveContainerView(to superview: UIView) {
guard let containerView else { return }
if containerView.superview !== superview {
containerView.removeFromSuperview()
}
containerView.frame = superview.bounds
containerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
superview.insertSubview(containerView, at: 0)
}
private func setMainBlankViewVisible(_ visible: Bool) {
guard visible, let window else {
mainBlankView?.removeFromSuperview()
mainBlankView = nil
return
}
let blankView = mainBlankView ?? UIView(frame: window.bounds)
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 {
window.insertSubview(blankView, at: 0)
}
blankView.isHidden = false
mainBlankView = blankView
}
/// Nudge mpv to present the current paused frame after switching back from PiP.
func forceDraw() {
command(["seek", "0", "relative+exact"])
@@ -138,12 +207,15 @@ class MpvPlayerCore: MpvPlayerCoreBase {
func dispose() {
NotificationCenter.default.removeObserver(self)
ExternalDisplayManager.shared.detach(core: self)
disposeSharedState(destroySynchronously: false)
metalLayer?.removeFromSuperlayer()
metalLayer = nil
containerView?.removeFromSuperview()
containerView = nil
mainBlankView?.removeFromSuperview()
mainBlankView = nil
isInitialized = false
print("[MpvPlayerCore] Disposed")
}
+2 -7
View File
@@ -123,7 +123,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
}
private var isSceneActive: Bool {
UIApplication.shared.connectedScenes.contains { $0.activationState == .foregroundActive }
ExternalDisplayManager.hasActiveApplicationScene
}
private func restoreInlinePlayerAfterPip() {
@@ -362,12 +362,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
// MARK: - Helpers
private func findKeyWindow() -> UIWindow? {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first(where: { $0.isKeyWindow })
else {
return nil
}
return window
ExternalDisplayManager.mainApplicationWindow()
}
}