From 926dbd9ff31050e2746d449db4cec1f1fa1aef65 Mon Sep 17 00:00:00 2001 From: zhushenwudi <55681140@163.com> Date: Fri, 2 Oct 2026 11:39:34 +0800 Subject: [PATCH] fix pip screen bug --- .../zhushenwudi/lovelivemusicplayer/App.kt | 6 +- .../ui/components/MusicCover.kt | 15 +++-- .../ui/image/LlmpImageCache.kt | 10 +++- iosApp/iosApp/SceneDelegate.swift | 23 +++----- iosApp/iosApp/pip/PipScreenManager.swift | 58 +++++++------------ 5 files changed, 48 insertions(+), 64 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/App.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/App.kt index b87ce41..9d6e2d1 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/App.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/App.kt @@ -276,13 +276,13 @@ private fun ObserveDesktopLyricLifecycle() { DisposableEffect(owner) { val observer = LifecycleEventObserver { _, event -> when (event) { - Lifecycle.Event.ON_PAUSE -> AppContainer.setAppInBackground(true) - Lifecycle.Event.ON_RESUME -> AppContainer.setAppInBackground(false) + Lifecycle.Event.ON_STOP -> AppContainer.setAppInBackground(true) + Lifecycle.Event.ON_START -> AppContainer.setAppInBackground(false) else -> Unit } } owner.lifecycle.addObserver(observer) - if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { AppContainer.setAppInBackground(true) } onDispose { owner.lifecycle.removeObserver(observer) } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/MusicCover.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/MusicCover.kt index 0407214..ca2b987 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/MusicCover.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/MusicCover.kt @@ -223,12 +223,15 @@ private fun CoverAsyncImage( // 进程级 Image 底图:LazyList dispose 会丢弃 remember(Painter),导致 // Loading(null) → 空白,即使磁盘/内存仍有封面。 val baseCacheKey = remember(modelUri, stableCacheKey) { - stableCacheKey?.takeIf { it.isNotBlank() } - ?: if (modelUri.startsWith("http", ignoreCase = true)) { - modelUri.toImageCacheKey() - } else { - modelUri - } + if ( + modelUri.startsWith("http://", ignoreCase = true) || + modelUri.startsWith("https://", ignoreCase = true) + ) { + // 远程封面以 URL 为内容身份:同一专辑在列表、详情和歌曲条目间共享缓存。 + modelUri.toImageCacheKey() + } else { + stableCacheKey?.takeIf { it.isNotBlank() } ?: modelUri + } } val bucketPx = remember(decodeSize, density) { decodeSize?.let { dp -> diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/LlmpImageCache.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/LlmpImageCache.kt index 8685163..d210148 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/LlmpImageCache.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/LlmpImageCache.kt @@ -29,6 +29,7 @@ import coil3.memory.MemoryCache import coil3.network.CacheStrategy import coil3.network.CacheStrategy.ReadResult import coil3.network.CacheStrategy.WriteResult +import coil3.network.DeDupeConcurrentRequestStrategy import coil3.network.NetworkRequest import coil3.network.NetworkResponse import coil3.network.ktor3.KtorNetworkFetcherFactory @@ -193,6 +194,9 @@ fun createLlmpImageLoader(context: PlatformContext): ImageLoader { add( KtorNetworkFetcherFactory( cacheStrategy = { PermanentImageCacheStrategy }, + // Hero/页面切换期间同一封面可能被不同尺寸的组件同时请求。 + // 以磁盘缓存键合并并发网络请求,避免相同 URL 重复下载。 + concurrentRequestStrategy = { DeDupeConcurrentRequestStrategy() }, ), ) } @@ -239,8 +243,10 @@ fun LlmpAsyncImage( val context = LocalPlatformContext.current val density = LocalDensity.current val httpUrl = (model as? String)?.takeIf { it.isHttpUrl() } - val baseCacheKey = stableCacheKey?.takeIf { it.isNotBlank() } - ?: httpUrl?.toImageCacheKey() + // 远程图片按 URL 复用原始字节,避免同一专辑封面因 music/album id 不同而重复下载。 + // stableCacheKey 仅用于没有远程 URL 的资源;解码尺寸仍只区分内存缓存。 + val baseCacheKey = httpUrl?.toImageCacheKey() + ?: stableCacheKey?.takeIf { it.isNotBlank() } val bucketPx = remember(decodeSize, density) { decodeSize?.let { dp -> decodeSizeBucketPx(with(density) { dp.roundToPx() }) diff --git a/iosApp/iosApp/SceneDelegate.swift b/iosApp/iosApp/SceneDelegate.swift index 46c1381..985fc51 100644 --- a/iosApp/iosApp/SceneDelegate.swift +++ b/iosApp/iosApp/SceneDelegate.swift @@ -30,12 +30,6 @@ 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) { @@ -44,9 +38,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { 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.picInPicAutoOpen(nil) - } + pipManager.picInPicAutoOpen(enabled) + pipManager.setPlaying(playing) } func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { @@ -72,7 +65,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { pipManager.addScreenView(on: window) let enabled = UserDefaults(suiteName: "group.com.zhushenwudi.lovelivemusicplayer")? .bool(forKey: "desktopLyricEnabled") ?? false + let playing = UserDefaults(suiteName: "group.com.zhushenwudi.lovelivemusicplayer")? + .bool(forKey: "pipIsPlaying") ?? false pipManager.picInPicAutoOpen(enabled) + pipManager.setPlaying(playing) didSetupPip = true } @@ -91,13 +87,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { self.pipManager.picInPicAutoOpen(enabled) case "playing": let playing = (notification.userInfo?["playing"] as? String) == "true" - if playing { - self.pipManager.picInPicAutoOpen(nil) - } else { - self.pipManager.manualChangePicInPic(needStart: false) - } + self.pipManager.setPlaying(playing) case "show": - self.pipManager.manualChangePicInPic(needStart: true) + // PiP 需要在前台时就准备好,由系统在真正转入后台时自动启动。 + self.pipManager.picInPicAutoOpen(nil) case "hide": self.pipManager.stopPictureInPictureOnly() case "foreground": diff --git a/iosApp/iosApp/pip/PipScreenManager.swift b/iosApp/iosApp/pip/PipScreenManager.swift index 2424325..dca4327 100644 --- a/iosApp/iosApp/pip/PipScreenManager.swift +++ b/iosApp/iosApp/pip/PipScreenManager.swift @@ -13,8 +13,8 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe private let sampleHost = SampleBufferHostView() private var didPrepare = false private var wantsPip = false - private var isOpenPicInPic = false - private var isStartingPip = false + private var isAutoOpenEnabled = false + private var isPlaying = false private var timebase: CMTimebase? private var line1 = "" private var line2 = "" @@ -54,8 +54,14 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe } func picInPicAutoOpen(_ isOpen: Bool?) { - if let isOpen { isOpenPicInPic = isOpen } - if isOpenPicInPic { enqueueCurrentFrame() } + if let isOpen { isAutoOpenEnabled = isOpen } + refreshAutomaticStartEligibility() + if isAutoOpenEnabled { enqueueCurrentFrame() } + } + + func setPlaying(_ playing: Bool) { + isPlaying = playing + refreshAutomaticStartEligibility() } @objc func updatePipScreenView(lyricLine1: String?, lyricLine2: String?, currentLine: Int) { @@ -65,19 +71,7 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe enqueueCurrentFrame() } - func manualChangePicInPic(needStart: Bool) { - if needStart { - wantsPip = true - isOpenPicInPic = true - pipController?.invalidatePlaybackState() - startPipIfPossible(retries: 8) - } else { - stopPictureInPictureOnly() - } - } - func stopPictureInPictureOnly() { - wantsPip = false if pipController?.isPictureInPictureActive == true { pipController?.stopPictureInPicture() } @@ -86,7 +80,6 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe func pictureInPictureControllerDidStartPictureInPicture( _ pictureInPictureController: AVPictureInPictureController ) { - isStartingPip = false if !wantsPip { pictureInPictureController.stopPictureInPicture() return @@ -98,7 +91,6 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe _ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error ) { - isStartingPip = false print("PiP failed to start: \(error)") } @@ -118,7 +110,7 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe func pictureInPictureControllerIsPlaybackPaused( _ pictureInPictureController: AVPictureInPictureController ) -> Bool { - !isOpenPicInPic + !wantsPip } func pictureInPictureController( @@ -153,33 +145,23 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe controller.requiresLinearPlayback = true controller.setValue(1, forKey: "controlsStyle") controller.delegate = self - // 自动开启会和 sceneWillResignActive 里的手动开启抢同一次系统连接,快速上滑时连接会被拆掉。 - controller.canStartPictureInPictureAutomaticallyFromInline = false + // 系统只会在 App 真正转入后台时自动开启;临时 inactive + // (下拉系统界面、未完成的任务切换手势)不会误弹出桌面歌词。 + controller.canStartPictureInPictureAutomaticallyFromInline = true pipController = controller didPrepare = true + refreshAutomaticStartEligibility() } - /// 必须在还没进后台时同步调用。启动过程中再调一次会拆掉系统连接。 - 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 + private func refreshAutomaticStartEligibility() { + wantsPip = isAutoOpenEnabled && isPlaying + pipController?.invalidatePlaybackState() + if !wantsPip { + stopPictureInPictureOnly() } - 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, [])