From 88430ba7b68924e683022701fd54283cd7241ece Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 6 May 2026 01:57:49 +0200 Subject: [PATCH] feat(ios): support external display playback close #984 --- ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/Info.plist | 24 ++- .../MpvPlayer/ExternalDisplayManager.swift | 172 ++++++++++++++++++ ios/Runner/MpvPlayer/MpvPipController.swift | 4 +- ios/Runner/MpvPlayer/MpvPlayerCore.swift | 86 ++++++++- ios/Runner/MpvPlayer/MpvPlayerPlugin.swift | 9 +- 6 files changed, 281 insertions(+), 18 deletions(-) create mode 100644 ios/Runner/MpvPlayer/ExternalDisplayManager.swift diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index e6582b58..70befcb9 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */; }; B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */; }; + B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -73,6 +74,7 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = SOURCE_ROOT; }; B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerPluginShared.swift; path = ../shared/apple/MpvPlayer/MpvPlayerPluginShared.swift; sourceTree = SOURCE_ROOT; }; + B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalDisplayManager.swift; sourceTree = ""; }; BB346A1D0705AB171F80B11B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DE48FF2E074644167608B23D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; F3553CDE70191F0FC81A782E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; @@ -121,6 +123,7 @@ children = ( B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */, B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */, + B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */, 6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */, 6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */, 92F969587D0E464D999910F4 /* MpvPipController.swift */, @@ -406,6 +409,7 @@ files = ( B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */, B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */, + B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */, 6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */, 6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */, 92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */, diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index c17fa6c7..6729752d 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -70,7 +70,7 @@ UIApplicationSceneManifest UIApplicationSupportsMultipleScenes - + UISceneConfigurations UIWindowSceneSessionRoleApplication @@ -86,6 +86,28 @@ Main + UIWindowSceneSessionRoleExternalDisplay + + + UISceneClassName + UIWindowScene + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).ExternalDisplaySceneDelegate + UISceneConfigurationName + external-display + + + UIWindowSceneSessionRoleExternalDisplayNonInteractive + + + UISceneClassName + UIWindowScene + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).ExternalDisplaySceneDelegate + UISceneConfigurationName + external-display + + diff --git a/ios/Runner/MpvPlayer/ExternalDisplayManager.swift b/ios/Runner/MpvPlayer/ExternalDisplayManager.swift new file mode 100644 index 00000000..fccfb93e --- /dev/null +++ b/ios/Runner/MpvPlayer/ExternalDisplayManager.swift @@ -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) + } +} diff --git a/ios/Runner/MpvPlayer/MpvPipController.swift b/ios/Runner/MpvPlayer/MpvPipController.swift index 27d9a095..377cdcaf 100644 --- a/ios/Runner/MpvPlayer/MpvPipController.swift +++ b/ios/Runner/MpvPlayer/MpvPipController.swift @@ -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 diff --git a/ios/Runner/MpvPlayer/MpvPlayerCore.swift b/ios/Runner/MpvPlayer/MpvPlayerCore.swift index 28db1942..89370ea8 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerCore.swift @@ -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") } diff --git a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift index 7f1e621c..e8e6b068 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift @@ -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() } }