From 027171cf59cc0a2e63d75d9b13ef56b0f0e32e83 Mon Sep 17 00:00:00 2001 From: zhushenwudi <55681140@163.com> Date: Sat, 26 Sep 2026 17:46:16 +0800 Subject: [PATCH] fix pip screen bugs --- iosApp/iosApp.xcodeproj/project.pbxproj | 12 - .../xcshareddata/xcodecloud/manifest.json | 9 + iosApp/iosApp/AppDelegate.swift | 31 ++ iosApp/iosApp/SceneDelegate.swift | 14 +- iosApp/iosApp/pip/GradientView.swift | 55 -- iosApp/iosApp/pip/PipScreenManager.swift | 478 +++++++++++------- iosApp/iosApp/pip/PipScreenView.swift | 110 ---- iosApp/iosApp/pip/black.mp4 | Bin 10193 -> 0 bytes .../player/PlayerController.ios.kt | 72 ++- 9 files changed, 400 insertions(+), 381 deletions(-) create mode 100644 iosApp/iosApp.xcodeproj/xcshareddata/xcodecloud/manifest.json delete mode 100644 iosApp/iosApp/pip/GradientView.swift delete mode 100644 iosApp/iosApp/pip/PipScreenView.swift delete mode 100644 iosApp/iosApp/pip/black.mp4 diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 51942e6..7242816 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -7,10 +7,8 @@ objects = { /* Begin PBXBuildFile section */ - 02E790E4779E846963D3DC78 /* PipScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0480DE3601E8C37A2D4745EA /* PipScreenView.swift */; }; 22EA1CB33A17DCC6FC82406D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F543015C899423D4B01CEB2E /* Assets.xcassets */; }; 2D0291E964949C7C8D4DF324 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 88A292AA7607F14E8C66291E /* Assets.xcassets */; }; - 4701F6E8155C8E093C8CF81B /* GradientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8F846972E53BC33C1001D3 /* GradientView.swift */; }; 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */; }; 5A902F589276392B4CBC0015 /* HomeWidgetExampleBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30942AA1A2E3A1AB832DD959 /* HomeWidgetExampleBundle.swift */; }; 76ED3117C0B97CD5572EB808 /* HomeWidgetExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B1CCBD46108D407DF0C9A3 /* HomeWidgetExample.swift */; }; @@ -22,7 +20,6 @@ 93FF51B22016695CB74F0AD6 /* HostBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B6B3C131D45A3E52B69B531 /* HostBridge.swift */; }; A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */; }; C8353E6C8BE98C27FD768A59 /* WidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E6AF7C77765E074EE1C7E597 /* WidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - EAC4398962E17AAE02F2DB68 /* black.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = E2D6A135D7E87F67C8285D07 /* black.mp4 */; }; F9A23E2F5F7514E5D6A3D9C0 /* BackgroundIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */; }; FA6C12E55F9D41237B4BDAF1 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310E847101B97B7A36776025 /* CarPlaySceneDelegate.swift */; }; FDE52B8D57A097A60A4CF366 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6C1F485E0CF8BB70C9DDD2 /* AppDelegate.swift */; }; @@ -60,12 +57,10 @@ 0142951DE661E55DC3207327 /* WidgetAppUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetAppUtils.swift; sourceTree = ""; }; A1B2C3D4E5F6789012345678 /* InAppWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewController.swift; sourceTree = ""; }; B2C3D4E5F67890123456789 /* WebViewContentBlocker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewContentBlocker.swift; sourceTree = ""; }; - 0480DE3601E8C37A2D4745EA /* PipScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PipScreenView.swift; sourceTree = ""; }; 1285E8A7B7E7359EA11B9591 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PipScreenManager.swift; sourceTree = ""; }; 30942AA1A2E3A1AB832DD959 /* HomeWidgetExampleBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetExampleBundle.swift; sourceTree = ""; }; 310E847101B97B7A36776025 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = ""; }; - 4B8F846972E53BC33C1001D3 /* GradientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientView.swift; sourceTree = ""; }; 59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundIntent.swift; sourceTree = ""; }; 7B6B3C131D45A3E52B69B531 /* HostBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostBridge.swift; sourceTree = ""; }; 86038AE1032E284E79B32AD6 /* iosApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -73,7 +68,6 @@ 8B6C1F485E0CF8BB70C9DDD2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; B8B1CCBD46108D407DF0C9A3 /* HomeWidgetExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetExample.swift; sourceTree = ""; }; CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - E2D6A135D7E87F67C8285D07 /* black.mp4 */ = {isa = PBXFileReference; path = black.mp4; sourceTree = ""; }; E6AF7C77765E074EE1C7E597 /* WidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F543015C899423D4B01CEB2E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D1E2F3A4B5C60718293A4B01 /* QqShareHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QqShareHost.swift; sourceTree = ""; }; @@ -137,10 +131,7 @@ 5B4BD3733F8124940306FE19 /* pip */ = { isa = PBXGroup; children = ( - E2D6A135D7E87F67C8285D07 /* black.mp4 */, - 4B8F846972E53BC33C1001D3 /* GradientView.swift */, 2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */, - 0480DE3601E8C37A2D4745EA /* PipScreenView.swift */, ); path = pip; sourceTree = ""; @@ -265,7 +256,6 @@ buildActionMask = 2147483647; files = ( 22EA1CB33A17DCC6FC82406D /* Assets.xcassets in Resources */, - EAC4398962E17AAE02F2DB68 /* black.mp4 in Resources */, D1E2F3A4B5C60718293A4B06 /* TencentOpenApi_IOS_Bundle.bundle in Resources */, I18N0PLIST0BUILD0FILE001 /* InfoPlist.strings in Resources */, ); @@ -344,10 +334,8 @@ FDE52B8D57A097A60A4CF366 /* AppDelegate.swift in Sources */, FA6C12E55F9D41237B4BDAF1 /* CarPlaySceneDelegate.swift in Sources */, 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */, - 4701F6E8155C8E093C8CF81B /* GradientView.swift in Sources */, 93FF51B22016695CB74F0AD6 /* HostBridge.swift in Sources */, A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */, - 02E790E4779E846963D3DC78 /* PipScreenView.swift in Sources */, 88ABACE0259DE83261737B99 /* SceneDelegate.swift in Sources */, 8070A4B4DD1D3DD823A8F923 /* WidgetAppUtils.swift in Sources */, B7C8D9E0F1A234567890ABCD /* InAppWebViewController.swift in Sources */, diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcodecloud/manifest.json b/iosApp/iosApp.xcodeproj/xcshareddata/xcodecloud/manifest.json new file mode 100644 index 0000000..2ebc747 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/xcshareddata/xcodecloud/manifest.json @@ -0,0 +1,9 @@ +{ + "id" : "97ba7140-7c52-4fdd-9ed8-e5a5fd628d17", + "targets" : [ + { + "id" : "DD6B14F5-41C7-48A4-AA91-5E72470E1514", + "name" : "LLMP" + } + ] +} \ No newline at end of file diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index c5a15c1..8f92f50 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -1,3 +1,4 @@ +import AVFoundation import UIKit import WidgetKit @@ -93,9 +94,39 @@ class AppDelegate: UIResponder, UIApplicationDelegate { guard let message = notification.userInfo?["message"] as? String else { return } HostBridge.shared.showToast(message) } + NotificationCenter.default.addObserver( + forName: Notification.Name("llmpPrepareAudioSession"), + object: nil, + queue: .main + ) { [weak self] _ in + self?.activatePlaybackSession() + } QqShareHost.shared.prepare() } + /// iOS 27 起主线程同步 setActive 会报 Hang Risk,改走异步激活。 + private func activatePlaybackSession() { + let session = AVAudioSession.sharedInstance() + try? session.setCategory(.playback) + if #available(iOS 27.0, *) { + session.activate(options: []) { activated, error in + DispatchQueue.main.async { + NotificationCenter.default.post( + name: Notification.Name("llmpAudioSessionReady"), + object: nil, + userInfo: ["ok": activated && error == nil] + ) + } + } + } else { + try? session.setActive(true) + NotificationCenter.default.post( + name: Notification.Name("llmpAudioSessionReady"), + object: nil + ) + } + } + func application( _ app: UIApplication, open url: URL, diff --git a/iosApp/iosApp/SceneDelegate.swift b/iosApp/iosApp/SceneDelegate.swift index 09d5b99..46c1381 100644 --- a/iosApp/iosApp/SceneDelegate.swift +++ b/iosApp/iosApp/SceneDelegate.swift @@ -27,8 +27,19 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } + func sceneWillResignActive(_ scene: UIScene) { + // 键盘还在时,系统会把键盘场景挂到画中画上;连接一旦被拆掉就会报 scene 不存在。 + window?.endEditing(true) + let defaults = UserDefaults(suiteName: "group.com.zhushenwudi.lovelivemusicplayer") + let playing = defaults?.bool(forKey: "pipIsPlaying") ?? false + let enabled = defaults?.bool(forKey: "desktopLyricEnabled") ?? false + if playing && enabled { + pipManager.manualChangePicInPic(needStart: true) + } + } + func sceneDidBecomeActive(_ scene: UIScene) { - // 前台不显示桌面歌词:关掉 PiP,但播放中仍保持 dummy,方便下次进后台自动弹出。 + // 前台不显示桌面歌词。开关仍开着时,下次进后台再弹出。 pipManager.stopPictureInPictureOnly() let defaults = UserDefaults(suiteName: "group.com.zhushenwudi.lovelivemusicplayer") let playing = defaults?.bool(forKey: "pipIsPlaying") ?? false @@ -86,7 +97,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { self.pipManager.manualChangePicInPic(needStart: false) } case "show": - self.pipManager.picInPicAutoOpen(nil) self.pipManager.manualChangePicInPic(needStart: true) case "hide": self.pipManager.stopPictureInPictureOnly() diff --git a/iosApp/iosApp/pip/GradientView.swift b/iosApp/iosApp/pip/GradientView.swift deleted file mode 100644 index 11e1ca4..0000000 --- a/iosApp/iosApp/pip/GradientView.swift +++ /dev/null @@ -1,55 +0,0 @@ -import UIKit - -class GradientLayer: CAGradientLayer { - private static let animationKey = "moveAnimation" - - let colorOne = UIColor(red: 0.24, green: 0.67, blue: 0.97, alpha: 1).cgColor - let colorTwo = UIColor(red: 0.81, green: 0.03, blue: 0.33, alpha: 1).cgColor - let colorThree = UIColor(red: 0.96, green: 0.71, blue: 0.20, alpha: 1).cgColor - - var currentGradient = 0 - var gradientSet = [[CGColor]]() - - func animateGradient() { - guard !gradientSet.isEmpty else { return } - guard animation(forKey: Self.animationKey) == nil else { return } - - colors = gradientSet[currentGradient] - let keyframes = CAKeyframeAnimation(keyPath: "colors") - keyframes.values = gradientSet + [gradientSet[0]] - keyframes.keyTimes = [ - NSNumber(value: 0), - NSNumber(value: 1.0 / 3.0), - NSNumber(value: 2.0 / 3.0), - NSNumber(value: 1), - ] - keyframes.duration = 15 - keyframes.repeatCount = .infinity - keyframes.calculationMode = .linear - keyframes.isRemovedOnCompletion = false - add(keyframes, forKey: Self.animationKey) - } - - func createGradientView() { - gradientSet = [ - [colorOne, colorTwo], - [colorTwo, colorThree], - [colorThree, colorOne], - ] - currentGradient = 0 - colors = gradientSet[currentGradient] - startPoint = CGPoint(x: 0, y: 0) - endPoint = CGPoint(x: 1, y: 1) - drawsAsynchronously = true - } - - func removeAnimation() { - removeAnimation(forKey: Self.animationKey) - } - - /// Core Animation 在锁屏时可能丢弃动画,解锁后显式重建。 - func restartAnimation() { - removeAnimation() - animateGradient() - } -} diff --git a/iosApp/iosApp/pip/PipScreenManager.swift b/iosApp/iosApp/pip/PipScreenManager.swift index 304d869..2424325 100644 --- a/iosApp/iosApp/pip/PipScreenManager.swift +++ b/iosApp/iosApp/pip/PipScreenManager.swift @@ -1,211 +1,83 @@ +import AVFoundation import AVKit +import CoreMedia +import CoreVideo import UIKit @MainActor -class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDelegate { - // 4:1 能保持最大宽度,同时比 5:1 的歌词条更接近原来的高度。 - private static let pipAspectRatio: CGFloat = 2000.0 / 500.0 +class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDelegate, AVPictureInPictureSampleBufferPlaybackDelegate { + private static let renderSize = CGSize(width: 800, height: 200) - private var firstWindow: UIWindow? private var pipController: AVPictureInPictureController? private let sourceView = UIView() - private var sourceHeightConstraint: NSLayoutConstraint? - private var picInPicView = PipScreenView() - private var isOpenPicInPic = false - private var suspectedWindows: [UIWindow] = [] + private let sampleHost = SampleBufferHostView() private var didPrepare = false - - override init() { - super.init() - isOpenPicInPic = false - } - - deinit { - NotificationCenter.default.removeObserver(self) - } + private var wantsPip = false + private var isOpenPicInPic = false + private var isStartingPip = false + private var timebase: CMTimebase? + private var line1 = "" + private var line2 = "" + private var currentLine = -1 func addScreenView(on view: UIView) { - if sourceView.superview !== view { - sourceView.removeFromSuperview() - sourceView.backgroundColor = .clear - sourceView.isUserInteractionEnabled = false - sourceView.clipsToBounds = true - sourceView.translatesAutoresizingMaskIntoConstraints = false - // 放在 UIWindow 最底层:仍处于可视区域,可触发自动 PiP,但不会盖住 App。 - view.insertSubview(sourceView, at: 0) - let height = sourceView.heightAnchor.constraint( - equalTo: sourceView.widthAnchor, - multiplier: 1.0 / Self.pipAspectRatio - ) - sourceHeightConstraint = height - NSLayoutConstraint.activate([ - sourceView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - sourceView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - sourceView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - height, - ]) + guard sourceView.superview !== view else { + if !didPrepare { preparePicInPic() } + return } + sourceView.removeFromSuperview() + sourceView.backgroundColor = .clear + sourceView.isUserInteractionEnabled = false + sourceView.clipsToBounds = true + sourceView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(sourceView) + sampleHost.translatesAutoresizingMaskIntoConstraints = false + sampleHost.isUserInteractionEnabled = false + sourceView.addSubview(sampleHost) + NSLayoutConstraint.activate([ + sourceView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + sourceView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + sourceView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + sourceView.heightAnchor.constraint(equalToConstant: 1), + sampleHost.leadingAnchor.constraint(equalTo: sourceView.leadingAnchor), + sampleHost.trailingAnchor.constraint(equalTo: sourceView.trailingAnchor), + sampleHost.topAnchor.constraint(equalTo: sourceView.topAnchor), + sampleHost.bottomAnchor.constraint(equalTo: sourceView.bottomAnchor), + ]) view.layoutIfNeeded() - if !didPrepare { - preparePicInPic(on: sourceView) - } else { - pipController?.playerLayer.frame = sourceView.bounds - } + if !didPrepare { preparePicInPic() } } func removeScreenView() { + wantsPip = false sourceView.removeFromSuperview() - sourceHeightConstraint = nil - NotificationCenter.default.removeObserver(self) - suspectedWindows.removeAll() - } - - private func preparePicInPic(on view: UIView) { - do { - try AVAudioSession.sharedInstance().setCategory(.playback) - try AVAudioSession.sharedInstance().setActive(true) - } catch { - print("Error setting up audio session: \(error)") - } - - guard let url = Bundle.main.url(forResource: "black", withExtension: "mp4") else { - print("Missing black.mp4 in app bundle — add from Flutter ios/Runner or create a 1-frame black video.") - return - } - - let item = AVPlayerItem(asset: AVAsset(url: url)) - let player = AVPlayer(playerItem: item) - let playerLayer = AVPlayerLayer(player: player) - view.layoutIfNeeded() - playerLayer.frame = view.bounds - playerLayer.backgroundColor = UIColor.black.cgColor - playerLayer.videoGravity = .resizeAspectFill - view.layer.insertSublayer(playerLayer, at: 0) - - pipController = AVPictureInPictureController(playerLayer: playerLayer) - if #available(iOS 14.0, *) { - pipController?.requiresLinearPlayback = true - } - pipController?.setValue(1, forKey: "controlsStyle") - pipController?.delegate = self - if #available(iOS 14.2, *) { - pipController?.canStartPictureInPictureAutomaticallyFromInline = true - } - didPrepare = true - - NotificationCenter.default.addObserver( - self, - selector: #selector(playerItemDidReachEnd), - name: .AVPlayerItemDidPlayToEndTime, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(windowDidBecomeVisible), - name: UIWindow.didBecomeVisibleNotification, - object: nil - ) - for name in [ - UIApplication.didEnterBackgroundNotification, - UIApplication.willEnterForegroundNotification, - UIApplication.didBecomeActiveNotification, - UIApplication.protectedDataDidBecomeAvailableNotification, - UIApplication.significantTimeChangeNotification, - ] { - NotificationCenter.default.addObserver( - self, - selector: #selector(applicationLifecycleDidChange), - name: name, - object: nil - ) - } } func picInPicAutoOpen(_ isOpen: Bool?) { if let isOpen { isOpenPicInPic = isOpen } - let isPlayingNow = pipController?.playerLayer.player?.timeControlStatus == .playing - if isOpenPicInPic { - if !isPlayingNow { pipController?.playerLayer.player?.play() } - } else if isPlayingNow { - pipController?.playerLayer.player?.pause() - } + if isOpenPicInPic { enqueueCurrentFrame() } } @objc func updatePipScreenView(lyricLine1: String?, lyricLine2: String?, currentLine: Int) { - picInPicView.updateContent(lyricLine1: lyricLine1, lyricLine2: lyricLine2, currentLine: currentLine) - } - - @objc private func windowDidBecomeVisible(notification: Notification) { - guard let object = notification.object else { return } - if String(describing: type(of: object)) == "PGHostedWindow" { - firstWindow = notification.object as? UIWindow - NotificationCenter.default.removeObserver(self, name: UIWindow.didBecomeVisibleNotification, object: nil) - restartPipRendering() - } else if let targetWindow = object as? UIWindow { - suspectedWindows.append(targetWindow) - } - } - - @objc private func applicationLifecycleDidChange(_ notification: Notification) { - restartPipRendering() - } - - private func restartPipRendering() { - if isOpenPicInPic, - pipController?.playerLayer.player?.timeControlStatus != .playing - { - pipController?.playerLayer.player?.play() - } - - picInPicView.setNeedsLayout() - picInPicView.layoutIfNeeded() - picInPicView.gradientLayer.restartAnimation() - - // 解锁后 PGHostedWindow 会异步恢复,再补一次以避开系统重建窗口的时序。 - Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 300_000_000) - guard let self else { return } - self.picInPicView.setNeedsLayout() - self.picInPicView.layoutIfNeeded() - self.picInPicView.gradientLayer.restartAnimation() - } - } - - private func filterTargetWindow() -> UIWindow? { - for window in suspectedWindows where String(describing: type(of: window)) == "PGHostedWindow" { - return window - } - for window in suspectedWindows where window.windowLevel == UIWindow.Level(rawValue: -10_000_000) { - return window - } - for window in suspectedWindows where window.frame.size.height < 300 { - return window - } - return suspectedWindows.first - } - - @objc private func playerItemDidReachEnd(notification: Notification) { - guard let item = notification.object as? AVPlayerItem, - item === pipController?.playerLayer.player?.currentItem - else { return } - pipController?.playerLayer.player?.seek(to: .zero) - pipController?.playerLayer.player?.play() + if let lyricLine1 { line1 = lyricLine1 } + if let lyricLine2 { line2 = lyricLine2 } + self.currentLine = currentLine + enqueueCurrentFrame() } func manualChangePicInPic(needStart: Bool) { if needStart { - pipController?.playerLayer.player?.play() - if pipController?.isPictureInPictureActive == false { - pipController?.startPictureInPicture() - } + wantsPip = true + isOpenPicInPic = true + pipController?.invalidatePlaybackState() + startPipIfPossible(retries: 8) } else { stopPictureInPictureOnly() - pipController?.playerLayer.player?.pause() } } - /// 只关 PiP,dummy player 继续播,方便下次进后台自动弹出。 func stopPictureInPictureOnly() { + wantsPip = false if pipController?.isPictureInPictureActive == true { pipController?.stopPictureInPicture() } @@ -214,20 +86,250 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe func pictureInPictureControllerDidStartPictureInPicture( _ pictureInPictureController: AVPictureInPictureController ) { - if firstWindow == nil { - firstWindow = filterTargetWindow() - suspectedWindows.removeAll() + isStartingPip = false + if !wantsPip { + pictureInPictureController.stopPictureInPicture() + return } - if let firstWindow { - firstWindow.addSubview(picInPicView) - picInPicView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - picInPicView.topAnchor.constraint(equalTo: firstWindow.topAnchor), - picInPicView.leadingAnchor.constraint(equalTo: firstWindow.leadingAnchor), - picInPicView.trailingAnchor.constraint(equalTo: firstWindow.trailingAnchor), - picInPicView.bottomAnchor.constraint(equalTo: firstWindow.bottomAnchor), - ]) + enqueueCurrentFrame() + } + + func pictureInPictureController( + _ pictureInPictureController: AVPictureInPictureController, + failedToStartPictureInPictureWithError error: Error + ) { + isStartingPip = false + print("PiP failed to start: \(error)") + } + + func pictureInPictureController( + _ pictureInPictureController: AVPictureInPictureController, + setPlaying playing: Bool + ) { + if playing { enqueueCurrentFrame() } + } + + func pictureInPictureControllerTimeRangeForPlayback( + _ pictureInPictureController: AVPictureInPictureController + ) -> CMTimeRange { + CMTimeRange(start: .zero, duration: .positiveInfinity) + } + + func pictureInPictureControllerIsPlaybackPaused( + _ pictureInPictureController: AVPictureInPictureController + ) -> Bool { + !isOpenPicInPic + } + + func pictureInPictureController( + _ pictureInPictureController: AVPictureInPictureController, + didTransitionToRenderSize newRenderSize: CMVideoDimensions + ) { + enqueueCurrentFrame() + } + + func pictureInPictureController( + _ pictureInPictureController: AVPictureInPictureController, + skipByInterval skipInterval: CMTime, + completion completionHandler: @escaping () -> Void + ) { + completionHandler() + } + + private func preparePicInPic() { + let layer = sampleHost.sampleLayer + layer.videoGravity = .resizeAspect + layer.backgroundColor = UIColor.black.cgColor + if let timebase = makeHostTimebase() { + self.timebase = timebase + layer.controlTimebase = timebase } - restartPipRendering() + enqueueCurrentFrame() + + let controller = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource( + sampleBufferDisplayLayer: layer, + playbackDelegate: self + )) + controller.requiresLinearPlayback = true + controller.setValue(1, forKey: "controlsStyle") + controller.delegate = self + // 自动开启会和 sceneWillResignActive 里的手动开启抢同一次系统连接,快速上滑时连接会被拆掉。 + controller.canStartPictureInPictureAutomaticallyFromInline = false + pipController = controller + didPrepare = true + } + + /// 必须在还没进后台时同步调用。启动过程中再调一次会拆掉系统连接。 + private func startPipIfPossible(retries: Int) { + guard wantsPip, let pip = pipController else { return } + if pip.isPictureInPictureActive || isStartingPip { return } + guard UIApplication.shared.applicationState != .background else { return } + guard pip.isPictureInPicturePossible else { + if retries > 0 { + DispatchQueue.main.async { [weak self] in + self?.startPipIfPossible(retries: retries - 1) + } + } else { + print("PiP not possible after retries") + } + return + } + isStartingPip = true + pip.startPictureInPicture() + } + + private func enqueueCurrentFrame() { + if isStartingPip { return } + guard didPrepare || sampleHost.superview != nil else { return } + guard let pixelBuffer = makePixelBuffer(size: Self.renderSize) else { return } + CVPixelBufferLockBaseAddress(pixelBuffer, []) + if let context = CGContext( + data: CVPixelBufferGetBaseAddress(pixelBuffer), + width: CVPixelBufferGetWidth(pixelBuffer), + height: CVPixelBufferGetHeight(pixelBuffer), + bitsPerComponent: 8, + bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer), + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue + ) { + let height = CGFloat(CVPixelBufferGetHeight(pixelBuffer)) + let width = CGFloat(CVPixelBufferGetWidth(pixelBuffer)) + context.translateBy(x: 0, y: height) + context.scaleBy(x: 1, y: -1) + context.scaleBy(x: width / Self.renderSize.width, y: height / Self.renderSize.height) + drawLyrics(in: context, size: Self.renderSize) + } + CVPixelBufferUnlockBaseAddress(pixelBuffer, []) + + let pts = timebase.map { CMTimebaseGetTime($0) } ?? .zero + guard let sample = makeSampleBuffer(from: pixelBuffer, pts: pts) else { return } + markDisplayImmediately(sample) + let layer = sampleHost.sampleLayer + if layer.status == .failed { + layer.flush() + } + guard layer.isReadyForMoreMediaData else { return } + layer.enqueue(sample) + } + + private func drawLyrics(in context: CGContext, size: CGSize) { + UIGraphicsPushContext(context) + UIColor.black.setFill() + UIRectFill(CGRect(origin: .zero, size: size)) + let font = UIFont.boldSystemFont(ofSize: 34) + let style = NSMutableParagraphStyle() + style.lineBreakMode = .byTruncatingTail + let primary = UIColor.white + let dim = UIColor.white.withAlphaComponent(0.45) + let textWidth = size.width - 48 + if currentLine == -1 { + let shown = line1.isEmpty ? " " : line1 + (shown as NSString).draw( + in: CGRect(x: 24, y: (size.height - 48) / 2, width: textWidth, height: 48), + withAttributes: [.font: font, .foregroundColor: primary, .paragraphStyle: style] + ) + } else { + (line1 as NSString).draw( + in: CGRect(x: 24, y: 36, width: textWidth, height: 56), + withAttributes: [ + .font: font, + .foregroundColor: currentLine == 2 ? dim : primary, + .paragraphStyle: style, + ] + ) + (line2 as NSString).draw( + in: CGRect(x: 24, y: 108, width: textWidth, height: 56), + withAttributes: [ + .font: font, + .foregroundColor: currentLine == 1 ? dim : primary, + .paragraphStyle: style, + ] + ) + } + UIGraphicsPopContext() + } + + private func markDisplayImmediately(_ sample: CMSampleBuffer) { + guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: true), + CFArrayGetCount(attachments) > 0 else { return } + let dict = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self) + CFDictionarySetValue( + dict, + Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately as NSString).toOpaque(), + Unmanaged.passUnretained(kCFBooleanTrue).toOpaque() + ) + } + + private func makeHostTimebase() -> CMTimebase? { + var timebase: CMTimebase? + let status = CMTimebaseCreateWithSourceClock( + allocator: kCFAllocatorDefault, + sourceClock: CMClockGetHostTimeClock(), + timebaseOut: &timebase + ) + guard status == noErr, let timebase else { return nil } + CMTimebaseSetTime(timebase, time: .zero) + CMTimebaseSetRate(timebase, rate: 1) + return timebase + } + + private func makePixelBuffer(size: CGSize) -> CVPixelBuffer? { + let width = evenPixel(size.width) + let height = evenPixel(size.height) + guard width > 0, height > 0 else { return nil } + let attrs: [CFString: Any] = [ + kCVPixelBufferCGImageCompatibilityKey: true, + kCVPixelBufferCGBitmapContextCompatibilityKey: true, + kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary, + ] + var pixelBuffer: CVPixelBuffer? + let status = CVPixelBufferCreate( + kCFAllocatorDefault, + width, + height, + kCVPixelFormatType_32BGRA, + attrs as CFDictionary, + &pixelBuffer + ) + guard status == kCVReturnSuccess else { return nil } + return pixelBuffer + } + + private func makeSampleBuffer(from pixelBuffer: CVPixelBuffer, pts: CMTime) -> CMSampleBuffer? { + var format: CMVideoFormatDescription? + let formatStatus = CMVideoFormatDescriptionCreateForImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBuffer, + formatDescriptionOut: &format + ) + guard formatStatus == noErr, let format else { return nil } + var timing = CMSampleTimingInfo( + duration: CMTime(value: 1, timescale: 30), + presentationTimeStamp: pts, + decodeTimeStamp: .invalid + ) + var sample: CMSampleBuffer? + let status = CMSampleBufferCreateReadyWithImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBuffer, + formatDescription: format, + sampleTiming: &timing, + sampleBufferOut: &sample + ) + guard status == noErr else { return nil } + return sample + } + + private func evenPixel(_ value: CGFloat) -> Int { + let rounded = max(Int(value.rounded()), 2) + return rounded.isMultiple(of: 2) ? rounded : rounded - 1 + } +} + +private final class SampleBufferHostView: UIView { + override class var layerClass: AnyClass { AVSampleBufferDisplayLayer.self } + + var sampleLayer: AVSampleBufferDisplayLayer { + layer as! AVSampleBufferDisplayLayer } } diff --git a/iosApp/iosApp/pip/PipScreenView.swift b/iosApp/iosApp/pip/PipScreenView.swift deleted file mode 100644 index b154ea4..0000000 --- a/iosApp/iosApp/pip/PipScreenView.swift +++ /dev/null @@ -1,110 +0,0 @@ -import UIKit - -class PipScreenView: UIView { - private let bgView = UIView() - private var lyricLine1 = UITextView() - private var lyricLine2 = UITextView() - private var noLyricLine = UITextView() - private var icon = UIImageView(image: UIImage(named: "AppIcon")) - - let gradientLayer = GradientLayer() - - override init(frame: CGRect) { - super.init(frame: frame) - setupView() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setupView() - } - - private func setupView() { - backgroundColor = .clear - layer.cornerRadius = 8 - layer.masksToBounds = true - translatesAutoresizingMaskIntoConstraints = false - - bgView.translatesAutoresizingMaskIntoConstraints = false - addSubview(bgView) - - gradientLayer.createGradientView() - layer.insertSublayer(gradientLayer, at: 0) - - for line in [lyricLine1, lyricLine2, noLyricLine] { - line.backgroundColor = .clear - line.textColor = .white - line.translatesAutoresizingMaskIntoConstraints = false - line.isScrollEnabled = false - line.isEditable = false - } - noLyricLine.textAlignment = .center - noLyricLine.isHidden = true - - let font = UIFont.boldSystemFont(ofSize: 18) - lyricLine1.font = font - lyricLine2.font = font - noLyricLine.font = font - - bgView.addSubview(lyricLine1) - bgView.addSubview(lyricLine2) - bgView.addSubview(noLyricLine) - - icon.contentMode = .scaleAspectFit - icon.translatesAutoresizingMaskIntoConstraints = false - bgView.addSubview(icon) - - NSLayoutConstraint.activate([ - bgView.topAnchor.constraint(equalTo: topAnchor), - bgView.leadingAnchor.constraint(equalTo: leadingAnchor), - bgView.trailingAnchor.constraint(equalTo: trailingAnchor), - bgView.bottomAnchor.constraint(equalTo: bottomAnchor), - - lyricLine1.topAnchor.constraint(equalTo: bgView.topAnchor, constant: 2), - lyricLine1.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 10), - lyricLine1.trailingAnchor.constraint(equalTo: bgView.trailingAnchor, constant: -10), - lyricLine1.heightAnchor.constraint(equalToConstant: 55), - - lyricLine2.topAnchor.constraint(equalTo: lyricLine1.bottomAnchor, constant: 2), - lyricLine2.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 10), - lyricLine2.trailingAnchor.constraint(equalTo: bgView.trailingAnchor, constant: -10), - lyricLine2.heightAnchor.constraint(equalToConstant: 55), - - noLyricLine.centerYAnchor.constraint(equalTo: bgView.centerYAnchor), - noLyricLine.leadingAnchor.constraint(equalTo: bgView.leadingAnchor), - noLyricLine.trailingAnchor.constraint(equalTo: bgView.trailingAnchor), - noLyricLine.heightAnchor.constraint(equalToConstant: 38), - - icon.bottomAnchor.constraint(equalTo: bgView.bottomAnchor, constant: -10), - icon.trailingAnchor.constraint(equalTo: bgView.trailingAnchor, constant: -10), - icon.widthAnchor.constraint(equalToConstant: 30), - icon.heightAnchor.constraint(equalToConstant: 30), - ]) - } - - @objc func updateContent(lyricLine1: String?, lyricLine2: String?, currentLine: Int) { - if let lyricLine1 { self.lyricLine1.text = lyricLine1 } - if let lyricLine2 { self.lyricLine2.text = lyricLine2 } - self.lyricLine1.textColor = currentLine == 2 - ? UIColor.white.withAlphaComponent(0.4) : .white - self.lyricLine2.textColor = currentLine == 1 - ? UIColor.white.withAlphaComponent(0.4) : .white - - if currentLine == -1 { - noLyricLine.text = lyricLine1 - self.lyricLine1.isHidden = true - self.lyricLine2.isHidden = true - noLyricLine.isHidden = false - } else { - self.lyricLine1.isHidden = false - self.lyricLine2.isHidden = false - noLyricLine.isHidden = true - } - } - - override func layoutSubviews() { - super.layoutSubviews() - gradientLayer.frame = bounds - gradientLayer.animateGradient() - } -} diff --git a/iosApp/iosApp/pip/black.mp4 b/iosApp/iosApp/pip/black.mp4 deleted file mode 100644 index 7f6b02b989b59a28a99a56a3f35a94cf928cafe2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10193 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 diff --git a/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerController.ios.kt b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerController.ios.kt index bdda829..70b4c44 100644 --- a/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerController.ios.kt +++ b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerController.ios.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import platform.AVFAudio.AVAudioSession -import platform.AVFAudio.AVAudioSessionCategoryPlayback import platform.AVFAudio.AVAudioSessionInterruptionNotification import platform.AVFAudio.AVAudioSessionInterruptionOptionKey import platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume @@ -24,7 +23,6 @@ import platform.AVFAudio.AVAudioSessionInterruptionTypeKey import platform.AVFAudio.AVAudioSessionRouteChangeNotification import platform.AVFAudio.AVAudioSessionRouteChangeReasonKey import platform.AVFAudio.AVAudioSessionRouteChangeReasonOldDeviceUnavailable -import platform.AVFAudio.setActive import platform.AVFoundation.AVPlayer import platform.AVFoundation.AVPlayerItem import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification @@ -131,6 +129,9 @@ actual class PlayerController { private var lastEndMark: TimeSource.Monotonic.ValueTimeMark? = null private var audioReady = false + private var audioActive = false + private var playWhenReady = false + private var audioSessionReadyObserver: Any? = null /** CarPlay / 锁屏 Now Playing 标题用的当前歌词;null 表示显示歌名。 */ private var nowPlayingLyric: String? = null @@ -143,18 +144,50 @@ actual class PlayerController { observePlaybackFailed() observeAudioRouteChanges() observeAudioInterruptions() + observeAudioSessionReady() } private fun ensureAudioReady() { - if (audioReady) return + if (audioActive || audioReady) return audioReady = true - runCatching { - val session = AVAudioSession.sharedInstance() - session.setCategory(AVAudioSessionCategoryPlayback, error = null) - session.setActive(true, error = null) - }.onFailure { AppLog.e(TAG, "ensureAudioReady session fail", it) } runCatching { setupRemoteCommands() } .onFailure { AppLog.e(TAG, "ensureAudioReady remoteCommands fail", it) } + NSNotificationCenter.defaultCenter.postNotificationName( + aName = "llmpPrepareAudioSession", + `object` = null, + userInfo = null, + ) + } + + private fun observeAudioSessionReady() { + audioSessionReadyObserver = NSNotificationCenter.defaultCenter.addObserverForName( + name = "llmpAudioSessionReady", + `object` = null, + queue = null, + ) { notification: NSNotification? -> + val ok = (notification?.userInfo?.get("ok") as? NSNumber)?.boolValue ?: true + dispatch_async(dispatch_get_main_queue()) { + if (!ok) { + audioReady = false + playWhenReady = false + AppLog.e(TAG, "audio session activate failed") + if (_state.value.isPlaying) { + player.pause() + _state.update { it.copy(isPlaying = false) } + } + return@dispatch_async + } + audioActive = true + if (playWhenReady && _state.value.isPlaying) { + playWhenReady = false + player.play() + startPositionUpdates() + updateNowPlayingInfo() + } else { + playWhenReady = false + } + } + } } private fun setupRemoteCommands() { @@ -270,7 +303,7 @@ actual class PlayerController { queue = null, ) { notification: NSNotification? -> val ended = notification?.`object` as? AVPlayerItem - // 桌面歌词 dummy black.mp4 也会发 DidPlayToEnd;只处理本控制器的音乐 item。 + // 只处理本控制器的音乐 item。 // Kotlin/Native 的 === 对 ObjC 对象包装器不可靠,须用 isEqual 比较指针。 if (ended == null || !isMusicItem(ended)) { return@addObserverForName @@ -614,17 +647,24 @@ actual class PlayerController { ensureAudioReady() val current = _state.value.current if (loadedUri.isNullOrBlank() && current != null) { + playWhenReady = false AppLog.e(TAG, "play() abort reason=noLoadedUri id=${current.musicId}") _state.update { it.copy(isPlaying = false, error = "No playable URI") } return } - player.play() _state.update { it.copy(isPlaying = true, error = null) } - startPositionUpdates() - updateNowPlayingInfo() + if (audioActive) { + player.play() + startPositionUpdates() + updateNowPlayingInfo() + } else { + playWhenReady = true + updateNowPlayingInfo() + } } actual fun pause() { + playWhenReady = false resumeAfterInterruption = false endScrub() player.pause() @@ -908,8 +948,12 @@ actual class PlayerController { watchItemStatus(item, path, music) if (autoPlay) { ensureAudioReady() - player.play() - startPositionUpdates() + if (audioActive) { + player.play() + startPositionUpdates() + } else { + playWhenReady = true + } } else { player.pause() positionJob?.cancel()