Compare commits

..
2 Commits
Author SHA1 Message Date
zhushenwudi e3ce4aa9fd fix only bigger than phone can land Orientation 2026-10-02 14:45:32 +08:00
zhushenwudi 926dbd9ff3 fix pip screen bug 2026-10-02 14:32:04 +08:00
11 changed files with 80 additions and 69 deletions
@@ -24,6 +24,7 @@ import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidShareBridge
import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidDesktopLyricController import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidDesktopLyricController
import com.zhushenwudi.lovelivemusicplayer.native.platform.installAndroidPlatformServices import com.zhushenwudi.lovelivemusicplayer.native.platform.installAndroidPlatformServices
import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
import com.zhushenwudi.lovelivemusicplayer.native.util.applyScreenOrientationPolicy
import com.zhushenwudi.lovelivemusicplayer.platform.AndroidUiHost import com.zhushenwudi.lovelivemusicplayer.platform.AndroidUiHost
import com.zhushenwudi.lovelivemusicplayer.platform.PlatformServices import com.zhushenwudi.lovelivemusicplayer.platform.PlatformServices
import com.zhushenwudi.lovelivemusicplayer.platform.initAndroidFileSystem import com.zhushenwudi.lovelivemusicplayer.platform.initAndroidFileSystem
@@ -36,6 +37,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
applyScreenOrientationPolicy()
enableEdgeToEdge() enableEdgeToEdge()
AndroidUiHost.activity = this AndroidUiHost.activity = this
initAndroidFileSystem(applicationContext) initAndroidFileSystem(applicationContext)
@@ -5,6 +5,8 @@ import androidx.activity.ComponentActivity
import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions import com.journeyapps.barcodescanner.ScanOptions
import com.zhushenwudi.lovelivemusicplayer.i18n.I18n import com.zhushenwudi.lovelivemusicplayer.i18n.I18n
import com.zhushenwudi.lovelivemusicplayer.native.util.applyScreenOrientationPolicy
import com.zhushenwudi.lovelivemusicplayer.native.util.isLargeScreenDevice
import com.zhushenwudi.lovelivemusicplayer.platform.AndroidUiHost import com.zhushenwudi.lovelivemusicplayer.platform.AndroidUiHost
class QrScanActivity : ComponentActivity() { class QrScanActivity : ComponentActivity() {
@@ -22,11 +24,12 @@ class QrScanActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
applyScreenOrientationPolicy()
val options = ScanOptions().apply { val options = ScanOptions().apply {
setDesiredBarcodeFormats(ScanOptions.QR_CODE) setDesiredBarcodeFormats(ScanOptions.QR_CODE)
setPrompt(I18n.t("scan_qr_prompt")) setPrompt(I18n.t("scan_qr_prompt"))
setBeepEnabled(false) setBeepEnabled(false)
setOrientationLocked(false) setOrientationLocked(!isLargeScreenDevice())
setCaptureActivity(com.journeyapps.barcodescanner.CaptureActivity::class.java) setCaptureActivity(com.journeyapps.barcodescanner.CaptureActivity::class.java)
} }
barcodeLauncher.launch(options) barcodeLauncher.launch(options)
@@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import com.zhushenwudi.lovelivemusicplayer.i18n.tr import com.zhushenwudi.lovelivemusicplayer.i18n.tr
import com.zhushenwudi.lovelivemusicplayer.native.util.applyScreenOrientationPolicy
import com.zhushenwudi.lovelivemusicplayer.platform.WebViewAdBlock import com.zhushenwudi.lovelivemusicplayer.platform.WebViewAdBlock
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -56,6 +57,7 @@ class WebViewActivity : ComponentActivity() {
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
applyScreenOrientationPolicy()
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
val url = intent.getStringExtra(EXTRA_URL).orEmpty() val url = intent.getStringExtra(EXTRA_URL).orEmpty()
val title = intent.getStringExtra(EXTRA_TITLE) val title = intent.getStringExtra(EXTRA_TITLE)
@@ -0,0 +1,24 @@
package com.zhushenwudi.lovelivemusicplayer.native.util
import android.app.Activity
import android.content.pm.ActivityInfo
private const val LARGE_SCREEN_MIN_SMALLEST_WIDTH_DP = 600
/** 手机固定竖屏;平板和展开后达到大屏阈值的设备遵循系统旋转设置。 */
fun Activity.applyScreenOrientationPolicy() {
val isLargeScreen = resources.configuration.smallestScreenWidthDp >=
LARGE_SCREEN_MIN_SMALLEST_WIDTH_DP
val targetOrientation = if (isLargeScreen) {
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
if (requestedOrientation != targetOrientation) {
requestedOrientation = targetOrientation
}
}
/** 扫码组件使用同一设备分类,避免手机进入扫码页后重新开放旋转。 */
fun Activity.isLargeScreenDevice(): Boolean =
resources.configuration.smallestScreenWidthDp >= LARGE_SCREEN_MIN_SMALLEST_WIDTH_DP
@@ -276,13 +276,13 @@ private fun ObserveDesktopLyricLifecycle() {
DisposableEffect(owner) { DisposableEffect(owner) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
when (event) { when (event) {
Lifecycle.Event.ON_PAUSE -> AppContainer.setAppInBackground(true) Lifecycle.Event.ON_STOP -> AppContainer.setAppInBackground(true)
Lifecycle.Event.ON_RESUME -> AppContainer.setAppInBackground(false) Lifecycle.Event.ON_START -> AppContainer.setAppInBackground(false)
else -> Unit else -> Unit
} }
} }
owner.lifecycle.addObserver(observer) owner.lifecycle.addObserver(observer)
if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
AppContainer.setAppInBackground(true) AppContainer.setAppInBackground(true)
} }
onDispose { owner.lifecycle.removeObserver(observer) } onDispose { owner.lifecycle.removeObserver(observer) }
@@ -223,12 +223,15 @@ private fun CoverAsyncImage(
// 进程级 Image 底图:LazyList dispose 会丢弃 remember(Painter),导致 // 进程级 Image 底图:LazyList dispose 会丢弃 remember(Painter),导致
// Loading(null) → 空白,即使磁盘/内存仍有封面。 // Loading(null) → 空白,即使磁盘/内存仍有封面。
val baseCacheKey = remember(modelUri, stableCacheKey) { val baseCacheKey = remember(modelUri, stableCacheKey) {
stableCacheKey?.takeIf { it.isNotBlank() } if (
?: if (modelUri.startsWith("http", ignoreCase = true)) { modelUri.startsWith("http://", ignoreCase = true) ||
modelUri.toImageCacheKey() modelUri.startsWith("https://", ignoreCase = true)
} else { ) {
modelUri // 远程封面以 URL 为内容身份:同一专辑在列表、详情和歌曲条目间共享缓存。
} modelUri.toImageCacheKey()
} else {
stableCacheKey?.takeIf { it.isNotBlank() } ?: modelUri
}
} }
val bucketPx = remember(decodeSize, density) { val bucketPx = remember(decodeSize, density) {
decodeSize?.let { dp -> decodeSize?.let { dp ->
@@ -29,6 +29,7 @@ import coil3.memory.MemoryCache
import coil3.network.CacheStrategy import coil3.network.CacheStrategy
import coil3.network.CacheStrategy.ReadResult import coil3.network.CacheStrategy.ReadResult
import coil3.network.CacheStrategy.WriteResult import coil3.network.CacheStrategy.WriteResult
import coil3.network.DeDupeConcurrentRequestStrategy
import coil3.network.NetworkRequest import coil3.network.NetworkRequest
import coil3.network.NetworkResponse import coil3.network.NetworkResponse
import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.network.ktor3.KtorNetworkFetcherFactory
@@ -193,6 +194,9 @@ fun createLlmpImageLoader(context: PlatformContext): ImageLoader {
add( add(
KtorNetworkFetcherFactory( KtorNetworkFetcherFactory(
cacheStrategy = { PermanentImageCacheStrategy }, cacheStrategy = { PermanentImageCacheStrategy },
// Hero/页面切换期间同一封面可能被不同尺寸的组件同时请求。
// 以磁盘缓存键合并并发网络请求,避免相同 URL 重复下载。
concurrentRequestStrategy = { DeDupeConcurrentRequestStrategy() },
), ),
) )
} }
@@ -239,8 +243,10 @@ fun LlmpAsyncImage(
val context = LocalPlatformContext.current val context = LocalPlatformContext.current
val density = LocalDensity.current val density = LocalDensity.current
val httpUrl = (model as? String)?.takeIf { it.isHttpUrl() } val httpUrl = (model as? String)?.takeIf { it.isHttpUrl() }
val baseCacheKey = stableCacheKey?.takeIf { it.isNotBlank() } // 远程图片按 URL 复用原始字节,避免同一专辑封面因 music/album id 不同而重复下载。
?: httpUrl?.toImageCacheKey() // stableCacheKey 仅用于没有远程 URL 的资源;解码尺寸仍只区分内存缓存。
val baseCacheKey = httpUrl?.toImageCacheKey()
?: stableCacheKey?.takeIf { it.isNotBlank() }
val bucketPx = remember(decodeSize, density) { val bucketPx = remember(decodeSize, density) {
decodeSize?.let { dp -> decodeSize?.let { dp ->
decodeSizeBucketPx(with(density) { dp.roundToPx() }) decodeSizeBucketPx(with(density) { dp.roundToPx() })
-2
View File
@@ -167,8 +167,6 @@
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations~ipad</key>
<array> <array>
+8 -15
View File
@@ -30,12 +30,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func sceneWillResignActive(_ scene: UIScene) { func sceneWillResignActive(_ scene: UIScene) {
// 键盘还在时,系统会把键盘场景挂到画中画上;连接一旦被拆掉就会报 scene 不存在。 // 键盘还在时,系统会把键盘场景挂到画中画上;连接一旦被拆掉就会报 scene 不存在。
window?.endEditing(true) 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) { func sceneDidBecomeActive(_ scene: UIScene) {
@@ -44,9 +38,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
let defaults = UserDefaults(suiteName: "group.com.zhushenwudi.lovelivemusicplayer") let defaults = UserDefaults(suiteName: "group.com.zhushenwudi.lovelivemusicplayer")
let playing = defaults?.bool(forKey: "pipIsPlaying") ?? false let playing = defaults?.bool(forKey: "pipIsPlaying") ?? false
let enabled = defaults?.bool(forKey: "desktopLyricEnabled") ?? false let enabled = defaults?.bool(forKey: "desktopLyricEnabled") ?? false
if playing && enabled { pipManager.picInPicAutoOpen(enabled)
pipManager.picInPicAutoOpen(nil) pipManager.setPlaying(playing)
}
} }
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) { func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
@@ -72,7 +65,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
pipManager.addScreenView(on: window) pipManager.addScreenView(on: window)
let enabled = UserDefaults(suiteName: "group.com.zhushenwudi.lovelivemusicplayer")? let enabled = UserDefaults(suiteName: "group.com.zhushenwudi.lovelivemusicplayer")?
.bool(forKey: "desktopLyricEnabled") ?? false .bool(forKey: "desktopLyricEnabled") ?? false
let playing = UserDefaults(suiteName: "group.com.zhushenwudi.lovelivemusicplayer")?
.bool(forKey: "pipIsPlaying") ?? false
pipManager.picInPicAutoOpen(enabled) pipManager.picInPicAutoOpen(enabled)
pipManager.setPlaying(playing)
didSetupPip = true didSetupPip = true
} }
@@ -91,13 +87,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
self.pipManager.picInPicAutoOpen(enabled) self.pipManager.picInPicAutoOpen(enabled)
case "playing": case "playing":
let playing = (notification.userInfo?["playing"] as? String) == "true" let playing = (notification.userInfo?["playing"] as? String) == "true"
if playing { self.pipManager.setPlaying(playing)
self.pipManager.picInPicAutoOpen(nil)
} else {
self.pipManager.manualChangePicInPic(needStart: false)
}
case "show": case "show":
self.pipManager.manualChangePicInPic(needStart: true) // PiP 需要在前台时就准备好,由系统在真正转入后台时自动启动。
self.pipManager.picInPicAutoOpen(nil)
case "hide": case "hide":
self.pipManager.stopPictureInPictureOnly() self.pipManager.stopPictureInPictureOnly()
case "foreground": case "foreground":
+20 -38
View File
@@ -13,8 +13,8 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe
private let sampleHost = SampleBufferHostView() private let sampleHost = SampleBufferHostView()
private var didPrepare = false private var didPrepare = false
private var wantsPip = false private var wantsPip = false
private var isOpenPicInPic = false private var isAutoOpenEnabled = false
private var isStartingPip = false private var isPlaying = false
private var timebase: CMTimebase? private var timebase: CMTimebase?
private var line1 = "" private var line1 = ""
private var line2 = "" private var line2 = ""
@@ -54,8 +54,14 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe
} }
func picInPicAutoOpen(_ isOpen: Bool?) { func picInPicAutoOpen(_ isOpen: Bool?) {
if let isOpen { isOpenPicInPic = isOpen } if let isOpen { isAutoOpenEnabled = isOpen }
if isOpenPicInPic { enqueueCurrentFrame() } refreshAutomaticStartEligibility()
if isAutoOpenEnabled { enqueueCurrentFrame() }
}
func setPlaying(_ playing: Bool) {
isPlaying = playing
refreshAutomaticStartEligibility()
} }
@objc func updatePipScreenView(lyricLine1: String?, lyricLine2: String?, currentLine: Int) { @objc func updatePipScreenView(lyricLine1: String?, lyricLine2: String?, currentLine: Int) {
@@ -65,19 +71,7 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe
enqueueCurrentFrame() enqueueCurrentFrame()
} }
func manualChangePicInPic(needStart: Bool) {
if needStart {
wantsPip = true
isOpenPicInPic = true
pipController?.invalidatePlaybackState()
startPipIfPossible(retries: 8)
} else {
stopPictureInPictureOnly()
}
}
func stopPictureInPictureOnly() { func stopPictureInPictureOnly() {
wantsPip = false
if pipController?.isPictureInPictureActive == true { if pipController?.isPictureInPictureActive == true {
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
} }
@@ -86,7 +80,6 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe
func pictureInPictureControllerDidStartPictureInPicture( func pictureInPictureControllerDidStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController _ pictureInPictureController: AVPictureInPictureController
) { ) {
isStartingPip = false
if !wantsPip { if !wantsPip {
pictureInPictureController.stopPictureInPicture() pictureInPictureController.stopPictureInPicture()
return return
@@ -98,7 +91,6 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe
_ pictureInPictureController: AVPictureInPictureController, _ pictureInPictureController: AVPictureInPictureController,
failedToStartPictureInPictureWithError error: Error failedToStartPictureInPictureWithError error: Error
) { ) {
isStartingPip = false
print("PiP failed to start: \(error)") print("PiP failed to start: \(error)")
} }
@@ -118,7 +110,7 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe
func pictureInPictureControllerIsPlaybackPaused( func pictureInPictureControllerIsPlaybackPaused(
_ pictureInPictureController: AVPictureInPictureController _ pictureInPictureController: AVPictureInPictureController
) -> Bool { ) -> Bool {
!isOpenPicInPic !wantsPip
} }
func pictureInPictureController( func pictureInPictureController(
@@ -153,33 +145,23 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe
controller.requiresLinearPlayback = true controller.requiresLinearPlayback = true
controller.setValue(1, forKey: "controlsStyle") controller.setValue(1, forKey: "controlsStyle")
controller.delegate = self controller.delegate = self
// 自动开启会和 sceneWillResignActive 里的手动开启抢同一次系统连接,快速上滑时连接会被拆掉。 // 系统只会在 App 真正转入后台时自动开启;临时 inactive
controller.canStartPictureInPictureAutomaticallyFromInline = false // (下拉系统界面、未完成的任务切换手势)不会误弹出桌面歌词。
controller.canStartPictureInPictureAutomaticallyFromInline = true
pipController = controller pipController = controller
didPrepare = true didPrepare = true
refreshAutomaticStartEligibility()
} }
/// 必须在还没进后台时同步调用。启动过程中再调一次会拆掉系统连接。 private func refreshAutomaticStartEligibility() {
private func startPipIfPossible(retries: Int) { wantsPip = isAutoOpenEnabled && isPlaying
guard wantsPip, let pip = pipController else { return } pipController?.invalidatePlaybackState()
if pip.isPictureInPictureActive || isStartingPip { return } if !wantsPip {
guard UIApplication.shared.applicationState != .background else { return } stopPictureInPictureOnly()
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() { private func enqueueCurrentFrame() {
if isStartingPip { return }
guard didPrepare || sampleHost.superview != nil else { return } guard didPrepare || sampleHost.superview != nil else { return }
guard let pixelBuffer = makePixelBuffer(size: Self.renderSize) else { return } guard let pixelBuffer = makePixelBuffer(size: Self.renderSize) else { return }
CVPixelBufferLockBaseAddress(pixelBuffer, []) CVPixelBufferLockBaseAddress(pixelBuffer, [])
-2
View File
@@ -85,8 +85,6 @@ targets:
- remote-notification - remote-notification
UISupportedInterfaceOrientations: UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait - UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
"UISupportedInterfaceOrientations~ipad": "UISupportedInterfaceOrientations~ipad":
- UIInterfaceOrientationPortrait - UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeLeft