fix pip screen bugs
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
A1B2C3D4E5F6789012345678 /* InAppWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewController.swift; sourceTree = "<group>"; };
|
||||
B2C3D4E5F67890123456789 /* WebViewContentBlocker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewContentBlocker.swift; sourceTree = "<group>"; };
|
||||
0480DE3601E8C37A2D4745EA /* PipScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PipScreenView.swift; sourceTree = "<group>"; };
|
||||
1285E8A7B7E7359EA11B9591 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PipScreenManager.swift; sourceTree = "<group>"; };
|
||||
30942AA1A2E3A1AB832DD959 /* HomeWidgetExampleBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetExampleBundle.swift; sourceTree = "<group>"; };
|
||||
310E847101B97B7A36776025 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = "<group>"; };
|
||||
4B8F846972E53BC33C1001D3 /* GradientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientView.swift; sourceTree = "<group>"; };
|
||||
59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundIntent.swift; sourceTree = "<group>"; };
|
||||
7B6B3C131D45A3E52B69B531 /* HostBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostBridge.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
B8B1CCBD46108D407DF0C9A3 /* HomeWidgetExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetExample.swift; sourceTree = "<group>"; };
|
||||
CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
E2D6A135D7E87F67C8285D07 /* black.mp4 */ = {isa = PBXFileReference; path = black.mp4; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
D1E2F3A4B5C60718293A4B01 /* QqShareHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QqShareHost.swift; sourceTree = "<group>"; };
|
||||
@@ -137,10 +131,7 @@
|
||||
5B4BD3733F8124940306FE19 /* pip */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2D6A135D7E87F67C8285D07 /* black.mp4 */,
|
||||
4B8F846972E53BC33C1001D3 /* GradientView.swift */,
|
||||
2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */,
|
||||
0480DE3601E8C37A2D4745EA /* PipScreenView.swift */,
|
||||
);
|
||||
path = pip;
|
||||
sourceTree = "<group>";
|
||||
@@ -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 */,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id" : "97ba7140-7c52-4fdd-9ed8-e5a5fd628d17",
|
||||
"targets" : [
|
||||
{
|
||||
"id" : "DD6B14F5-41C7-48A4-AA91-5E72470E1514",
|
||||
"name" : "LLMP"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
guard sourceView.superview !== view else {
|
||||
if !didPrepare { preparePicInPic() }
|
||||
return
|
||||
}
|
||||
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
|
||||
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),
|
||||
height,
|
||||
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()
|
||||
}
|
||||
restartPipRendering()
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Binary file not shown.
+54
-10
@@ -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) }
|
||||
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()
|
||||
if (audioActive) {
|
||||
player.play()
|
||||
startPositionUpdates()
|
||||
} else {
|
||||
playWhenReady = true
|
||||
}
|
||||
} else {
|
||||
player.pause()
|
||||
positionJob?.cancel()
|
||||
|
||||
Reference in New Issue
Block a user