Compare commits

...
2 Commits
Author SHA1 Message Date
zhushenwudi 027171cf59 fix pip screen bugs 2026-09-26 17:51:25 +08:00
zhushenwudi a046790ab0 fix photo cache and setting config 2026-09-26 16:42:24 +08:00
28 changed files with 643 additions and 526 deletions
@@ -45,6 +45,7 @@ import com.zhushenwudi.lovelivemusicplayer.i18n.tr
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
import kotlin.time.Duration.Companion.seconds
class WebViewActivity : ComponentActivity() { class WebViewActivity : ComponentActivity() {
private var webView: WebView? = null private var webView: WebView? = null
@@ -81,7 +82,7 @@ class WebViewActivity : ComponentActivity() {
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
while (true) { while (true) {
delay(1_000) delay(1.seconds)
webView?.evaluateJavascript(WebViewAdBlock.domCleanupScript(), null) webView?.evaluateJavascript(WebViewAdBlock.domCleanupScript(), null)
} }
} }
@@ -51,6 +51,7 @@ import com.zhushenwudi.lovelivemusicplayer.ui.screens.TachieScreen
import com.zhushenwudi.lovelivemusicplayer.ui.screens.TransferScreen import com.zhushenwudi.lovelivemusicplayer.ui.screens.TransferScreen
import com.zhushenwudi.lovelivemusicplayer.i18n.SyncI18nWithSystemLocale import com.zhushenwudi.lovelivemusicplayer.i18n.SyncI18nWithSystemLocale
import com.zhushenwudi.lovelivemusicplayer.ui.theme.ProvideDesignDensity import com.zhushenwudi.lovelivemusicplayer.ui.theme.ProvideDesignDensity
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun App() { fun App() {
@@ -85,7 +86,7 @@ fun App() {
if (overlayOpen) { if (overlayOpen) {
shellInFront = false shellInFront = false
} else { } else {
delay(LlmpTransitionMs.toLong()) delay(LlmpTransitionMs.toLong().milliseconds)
shellInFront = true shellInFront = true
} }
} }
@@ -182,6 +183,9 @@ fun App() {
settings.followSystemTheme = false settings.followSystemTheme = false
}, },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onReturnHome = {
navController.popBackStack(Routes.MAIN, inclusive = false)
},
onOpenSd = { navController.navigate(Routes.SD) }, onOpenSd = { navController.navigate(Routes.SD) },
onOpenLogs = { navController.navigate(Routes.LOGS) }, onOpenLogs = { navController.navigate(Routes.LOGS) },
onOpenPermissions = { navController.navigate(Routes.PERMISSIONS) }, onOpenPermissions = { navController.navigate(Routes.PERMISSIONS) },
@@ -48,6 +48,7 @@ import com.zhushenwudi.lovelivemusicplayer.ui.weslide.WeSlideController
import com.zhushenwudi.lovelivemusicplayer.update.UpdateChecker import com.zhushenwudi.lovelivemusicplayer.update.UpdateChecker
import com.zhushenwudi.lovelivemusicplayer.util.PathResolver import com.zhushenwudi.lovelivemusicplayer.util.PathResolver
import com.zhushenwudi.lovelivemusicplayer.widget.WidgetCoverCache import com.zhushenwudi.lovelivemusicplayer.widget.WidgetCoverCache
import kotlin.time.Duration.Companion.seconds
object AppContainer { object AppContainer {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -64,6 +65,9 @@ object AppContainer {
private val _usbRevision = MutableStateFlow(0L) private val _usbRevision = MutableStateFlow(0L)
private val _libraryRevision = MutableStateFlow(0L) private val _libraryRevision = MutableStateFlow(0L)
/** 清空用户数据后让首页收起详情和播放页。 */
private val _shellReset = MutableStateFlow(0L)
/** 当前曲目完整歌词,切歌时刷新。 */ /** 当前曲目完整歌词,切歌时刷新。 */
val playingLyric: StateFlow<LoadedLyric> = _playingLyric.asStateFlow() val playingLyric: StateFlow<LoadedLyric> = _playingLyric.asStateFlow()
@@ -78,6 +82,7 @@ object AppContainer {
/** 切存储清库后递增,刷新曲库 UI。 */ /** 切存储清库后递增,刷新曲库 UI。 */
val libraryRevision: StateFlow<Long> = _libraryRevision.asStateFlow() val libraryRevision: StateFlow<Long> = _libraryRevision.asStateFlow()
val shellReset: StateFlow<Long> = _shellReset.asStateFlow()
/** 主页 WeSlide 面板控制器。 */ /** 主页 WeSlide 面板控制器。 */
val weSlide = WeSlideController() val weSlide = WeSlideController()
@@ -146,9 +151,10 @@ object AppContainer {
this.pathResolver = PathResolver(fileSystem, settings) this.pathResolver = PathResolver(fileSystem, settings)
// musicPath 保持相对路径;仅播放引擎解析为 file/http URI。 // musicPath 保持相对路径;仅播放引擎解析为 file/http URI。
player.resolvePlaybackUri = { pathResolver.playbackUri(it) } player.resolvePlaybackUri = { pathResolver.playbackUri(it) }
// Android 通知封面用 file:// / https;iOS Now Playing 优先绝对路径。 // 本地文件存在才用绝对路径。HTTP 曲库没有落盘封面时必须走 https,
// 否则负一屏 / 灵动岛会拿到一个不存在的路径,封面空白。
player.resolveCoverUri = { music -> player.resolveCoverUri = { music ->
pathResolver.absoluteCoverPath(music) pathResolver.absoluteCoverPath(music)?.takeIf(fileSystem::exists)
?: pathResolver.coverDisplayUri(music) ?: pathResolver.coverDisplayUri(music)
} }
@@ -209,7 +215,7 @@ object AppContainer {
bindUsbMountListener() bindUsbMountListener()
// 全表快照放后台,并避开主页出来后的首屏解码。 // 全表快照放后台,并避开主页出来后的首屏解码。
scope.launch { scope.launch {
delay(2_000) delay(2.seconds)
refreshCarPlayCatalog() refreshCarPlayCatalog()
} }
} }
@@ -242,7 +248,7 @@ object AppContainer {
private fun onUsbStorageChanged() { private fun onUsbStorageChanged() {
scope.launch { scope.launch {
delay(1_000) delay(1.seconds)
val pathList = fileSystem.listUsbRoots() val pathList = fileSystem.listUsbRoots()
if (pathList.isEmpty()) return@launch if (pathList.isEmpty()) return@launch
val defPath = settings.sdPath val defPath = settings.sdPath
@@ -274,6 +280,21 @@ object AppContainer {
_libraryRevision.value++ _libraryRevision.value++
} }
/** 清空喜欢、历史、歌单和播放队列,并通知首页刷新。 */
fun clearUserData() {
if (!isInitialized) return
library.clearMenus()
library.clearLove()
library.clearHistory()
settings.dataVersion = ""
clearPlayQueue()
refreshCarPlayCatalog()
_libraryRevision.value++
_loveRevision.value++
_historyRevision.value++
_shellReset.value++
}
fun storageDisplayName(path: String): String { fun storageDisplayName(path: String): String {
val key = path.split("/").getOrNull(2) ?: path.substringAfterLast('/').ifBlank { path } val key = path.split("/").getOrNull(2) ?: path.substringAfterLast('/').ifBlank { path }
return if (key == "emulated") I18n.t("internal_storage") else key return if (key == "emulated") I18n.t("internal_storage") else key
@@ -104,6 +104,8 @@ import com.zhushenwudi.lovelivemusicplayer.ui.theme.asUnscaledInScaledContext
import com.zhushenwudi.lovelivemusicplayer.ui.theme.unscaledNavigationBarsBottom import com.zhushenwudi.lovelivemusicplayer.ui.theme.unscaledNavigationBarsBottom
import com.zhushenwudi.lovelivemusicplayer.ui.theme.unscaledStatusBarsTop import com.zhushenwudi.lovelivemusicplayer.ui.theme.unscaledStatusBarsTop
import com.zhushenwudi.lovelivemusicplayer.ui.weslide.WeSlide import com.zhushenwudi.lovelivemusicplayer.ui.weslide.WeSlide
import kotlinx.coroutines.IO
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
@@ -250,6 +252,12 @@ fun MainShell(
val historyRevision by AppContainer.historyRevision.collectAsState() val historyRevision by AppContainer.historyRevision.collectAsState()
val loveRevision by AppContainer.loveRevision.collectAsState() val loveRevision by AppContainer.loveRevision.collectAsState()
val libraryRevision by AppContainer.libraryRevision.collectAsState() val libraryRevision by AppContainer.libraryRevision.collectAsState()
val shellReset by AppContainer.shellReset.collectAsState()
LaunchedEffect(shellReset) {
if (shellReset == 0L) return@LaunchedEffect
libraryDetail = null
weSlide.hide()
}
val startupHistoryRevision = remember { historyRevision } val startupHistoryRevision = remember { historyRevision }
val startupLoveRevision = remember { loveRevision } val startupLoveRevision = remember { loveRevision }
val libraryLists = remember(refreshToken, currentGroup, enableHttp, libraryRevision) { val libraryLists = remember(refreshToken, currentGroup, enableHttp, libraryRevision) {
@@ -285,7 +293,7 @@ fun MainShell(
} }
// 首屏封面先解码。本地文件核对延后,避免扫盘占满磁盘把列表拖卡。 // 首屏封面先解码。本地文件核对延后,避免扫盘占满磁盘把列表拖卡。
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
delay(1_500) delay(1_500.milliseconds)
homeHazeEnabled = true homeHazeEnabled = true
val groupAtStart = currentGroup.displayName val groupAtStart = currentGroup.displayName
val httpAtStart = enableHttp val httpAtStart = enableHttp
@@ -223,6 +223,7 @@ fun AddMinDialog(
@Composable @Composable
fun ResetDataDialog( fun ResetDataDialog(
onClearSongs: () -> Unit, onClearSongs: () -> Unit,
onClearImageCache: () -> Unit,
onClearUserData: () -> Unit, onClearUserData: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
@@ -241,6 +242,11 @@ fun ResetDataDialog(
onClearSongs() onClearSongs()
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
ResetActionButton(tr("clear_image_cache")) {
onDismiss()
onClearImageCache()
}
Spacer(Modifier.height(24.dp))
ResetActionButton(tr("remove_user_data_kmp")) { ResetActionButton(tr("remove_user_data_kmp")) {
onDismiss() onDismiss()
onClearUserData() onClearUserData()
@@ -254,6 +260,14 @@ fun ResetDataDialog(
modifier = Modifier.padding(horizontal = 12.dp), modifier = Modifier.padding(horizontal = 12.dp),
) )
Spacer(Modifier.height(3.dp)) Spacer(Modifier.height(3.dp))
Text(
tr("explain_image_cache"),
color = if (dark) ColorMs.White else ColorMs.Text333,
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 12.dp),
)
Spacer(Modifier.height(3.dp))
Text( Text(
tr("explain_user_data_kmp"), tr("explain_user_data_kmp"),
color = if (dark) ColorMs.White else ColorMs.Text333, color = if (dark) ColorMs.White else ColorMs.Text333,
@@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.milliseconds
private const val ThumbResizeDurationMs = 1_000 private const val ThumbResizeDurationMs = 1_000
private const val ThumbFadeDelayMs = 650L private const val ThumbFadeDelayMs = 650L
@@ -207,7 +208,7 @@ private fun LlmpFlexibleScrollbarOverlay(
if (snapshot.canScroll && (isScrollInProgress || isDragging)) { if (snapshot.canScroll && (isScrollInProgress || isDragging)) {
showThumb = true showThumb = true
} else { } else {
delay(ThumbFadeDelayMs) delay(ThumbFadeDelayMs.milliseconds)
showThumb = false showThumb = false
} }
} }
@@ -6,7 +6,7 @@ import coil3.compose.setSingletonImageLoaderFactory
import com.zhushenwudi.lovelivemusicplayer.ui.image.createLlmpImageLoader import com.zhushenwudi.lovelivemusicplayer.ui.image.createLlmpImageLoader
/** /**
* 注册 Coil 单例:SVG 解码 + 1 天网络图片缓存(SWR)。 * 注册 Coil 单例:SVG 解码 + 长期网络图片缓存。
* 网络 https(artist_webp 等)需要 classpath 上的 `coil-network-ktor3` — * 网络 https(artist_webp 等)需要 classpath 上的 `coil-network-ktor3` —
* Coil 3 无此模块不会拉取网络图片。 * Coil 3 无此模块不会拉取网络图片。
* 须在应用根节点、任何 AsyncImage 之前调用一次。 * 须在应用根节点、任何 AsyncImage 之前调用一次。
@@ -1,7 +1,10 @@
package com.zhushenwudi.lovelivemusicplayer.ui.image package com.zhushenwudi.lovelivemusicplayer.ui.image
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
@@ -35,23 +38,18 @@ import coil3.request.Options
import coil3.request.crossfade import coil3.request.crossfade
import coil3.size.Size import coil3.size.Size
import coil3.svg.SvgDecoder import coil3.svg.SvgDecoder
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import com.zhushenwudi.lovelivemusicplayer.db.currentTimeMs
import com.zhushenwudi.lovelivemusicplayer.platform.AppLog import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
private const val IMG_TAG = "LLMP-Img" private const val IMG_TAG = "LLMP-Img"
/** 网络写入成功后的磁盘缓存软 TTL。 */
private const val IMAGE_CACHE_TTL_MS = 24L * 60 * 60 * 1000
/** /**
* Coil 网络缓存策略: * Coil 网络缓存策略:
* - `IMAGE_CACHE_TTL_MS` 内:仅使用磁盘缓存。 * - 成功图片一直留在磁盘,直到用户在设置里清除。
* - 过期后:仍立即显示旧图(不空白/不闪烁),后台刷新;新字节写入缓存后再切换 UI。 * - 空正文、HTML/JSON/XML 不写入,读到这类缓存时改为走网络。
* - 磁盘字节无法解码时丢掉该条目并立刻再请求。
*/ */
@OptIn(ExperimentalCoilApi::class) @OptIn(ExperimentalCoilApi::class)
internal object DayTtlSwrCacheStrategy : CacheStrategy { internal object PermanentImageCacheStrategy : CacheStrategy {
override suspend fun read( override suspend fun read(
cacheResponse: NetworkResponse, cacheResponse: NetworkResponse,
@@ -59,13 +57,15 @@ internal object DayTtlSwrCacheStrategy : CacheStrategy {
options: Options, options: Options,
): ReadResult { ): ReadResult {
val url = networkRequest.url val url = networkRequest.url
if (cacheResponse.code !in 200 until 300) { if (cacheResponse.code !in 200 until 300 || isUnusableImageResponse(cacheResponse)) {
AppLog.w(
IMG_TAG,
"diskRead SKIP unusable code=${cacheResponse.code} " +
"type=${cacheResponse.headers["content-type"]} " +
"len=${cacheResponse.headers["content-length"]} url=$url",
)
return ReadResult(networkRequest) return ReadResult(networkRequest)
} }
if (isExpired(cacheResponse)) {
ImageRevalidator.schedule(url, options.context)
}
// 显示时优先磁盘图,避免过期导致 UI 空白。
return ReadResult(cacheResponse) return ReadResult(cacheResponse)
} }
@@ -85,90 +85,51 @@ internal object DayTtlSwrCacheStrategy : CacheStrategy {
), ),
) )
} }
// 仅持久化成功图片;失败请求下次访问重试。 // 仅持久化可解码图片。空正文和 HTML 等不算成功,下次访问重试。
if (networkResponse.code in 200 until 300) { if (networkResponse.code in 200 until 300 && !isUnusableImageResponse(networkResponse)) {
return WriteResult(networkResponse) return WriteResult(networkResponse)
} }
AppLog.w( AppLog.w(
IMG_TAG, IMG_TAG,
"diskWrite SKIP code=${networkResponse.code} url=$url", "diskWrite SKIP code=${networkResponse.code} " +
"type=${networkResponse.headers["content-type"]} " +
"len=${networkResponse.headers["content-length"]} url=$url",
) )
return WriteResult.DISABLED return WriteResult.DISABLED
} }
private fun isExpired(cacheResponse: NetworkResponse): Boolean {
val cachedAt = maxOf(cacheResponse.requestMillis, cacheResponse.responseMillis)
// 无时间戳视为新鲜;标为过期会触发后台
// 重新校验 + UI 重挂载,可能 Error 并闪烁/卡住占位图。
if (cachedAt <= 0L) return false
return currentTimeMs() - cachedAt >= IMAGE_CACHE_TTL_MS
}
} }
internal object ImageRevalidator { internal object ImageRevalidator {
private val mutex = Mutex()
private val inFlight = mutableSetOf<String>()
private var imageLoader: ImageLoader? = null private var imageLoader: ImageLoader? = null
fun bind(loader: ImageLoader) { fun bind(loader: ImageLoader) {
imageLoader = loader imageLoader = loader
} }
suspend fun schedule(url: String, context: PlatformContext) { /** 丢掉无法解码的磁盘/内存条目,下次访问重新请求。 */
if (!url.isHttpUrl()) return fun evictUndecodable(diskKey: String, memoryKey: String?) {
val cacheKey = url.toImageCacheKey() val diskRemoved = imageLoader?.diskCache?.remove(diskKey) == true
mutex.withLock { val memory = imageLoader?.memoryCache
if (!inFlight.add(cacheKey)) { memory?.remove(MemoryCache.Key(diskKey))
return if (memoryKey != null && memoryKey != diskKey) {
} memory?.remove(MemoryCache.Key(memoryKey))
} }
val loader = imageLoader AppLog.w(IMG_TAG, "evict undecodable diskRemoved=$diskRemoved key=$diskKey")
if (loader == null) {
mutex.withLock { inFlight.remove(cacheKey) }
AppLog.w(IMG_TAG, "revalidate skip-noLoader url=$url")
return
}
loader.enqueue(
ImageRequest.Builder(context)
.data(url)
.memoryCacheKey(cacheKey)
.diskCacheKey(cacheKey)
// 跳过读取过期磁盘项;强制网络拉取后重写缓存。
.diskCachePolicy(CachePolicy.WRITE_ONLY)
.memoryCachePolicy(CachePolicy.ENABLED)
.networkCachePolicy(CachePolicy.ENABLED)
.crossfade(false)
.listener(
onSuccess = { _, _ ->
// 只刷新 Coil 磁盘/内存;不写入 CoverImageHolder,
// 避免无尺寸的原图覆盖列表缩略图。
complete(cacheKey)
},
onError = { _, result ->
AppLog.w(
IMG_TAG,
"revalidate ERR ${result.throwable.message} url=$url",
)
complete(cacheKey)
},
onCancel = { _ ->
complete(cacheKey)
},
)
.build(),
)
} }
private fun complete(cacheKey: String) { fun clearAll() {
// 监听器在 CacheStrategy 协程外运行;尽力而为,不挂起。 imageLoader?.memoryCache?.clear()
inFlight.remove(cacheKey) imageLoader?.diskCache?.clear()
// 故意不触发 UI 版本更新:重新校验后重挂载可见封面 CoverImageHolder.clear()
// 曾导致间歇 Error → 占位图锁定。磁盘/内存已更新,供 AppLog.i(IMG_TAG, "image cache cleared")
// 下次组合使用(如滚回)。
} }
} }
/** 清掉内存和磁盘里的网络图片。已显示的封面在下次进入页面时重新加载。 */
fun clearLlmpImageCache() {
ImageRevalidator.clearAll()
}
/** /**
* 封面解码 [androidx.compose.ui.graphics.Image] 的强引用。 * 封面解码 [androidx.compose.ui.graphics.Image] 的强引用。
@@ -192,6 +153,11 @@ internal object CoverImageHolder {
map.remove(old) map.remove(old)
} }
} }
fun clear() {
keys.clear()
map.clear()
}
} }
/** 规范化缓存键,使原始 `'`(如 μ's)与 `%27` 编码一致。 */ /** 规范化缓存键,使原始 `'`(如 μ's)与 `%27` 编码一致。 */
@@ -226,7 +192,7 @@ fun createLlmpImageLoader(context: PlatformContext): ImageLoader {
add(SvgDecoder.Factory()) add(SvgDecoder.Factory())
add( add(
KtorNetworkFetcherFactory( KtorNetworkFetcherFactory(
cacheStrategy = { DayTtlSwrCacheStrategy }, cacheStrategy = { PermanentImageCacheStrategy },
), ),
) )
} }
@@ -252,7 +218,7 @@ internal fun decodeSizeBucketPx(px: Int): Int = when {
internal fun sizedMemoryCacheKey(baseKey: String, bucketPx: Int): String = internal fun sizedMemoryCacheKey(baseKey: String, bucketPx: Int): String =
if (bucketPx > 0) "$baseKey@$bucketPx" else baseKey if (bucketPx > 0) "$baseKey@$bucketPx" else baseKey
/** 共享 AsyncImage:1 天网络缓存 + 过期先用旧图(无闪烁)。 */ /** 共享 AsyncImage:成功图片长期缓存,坏图丢弃后重拉一次。 */
@Composable @Composable
fun LlmpAsyncImage( fun LlmpAsyncImage(
model: Any?, model: Any?,
@@ -287,9 +253,9 @@ fun LlmpAsyncImage(
else -> filterQuality else -> filterQuality
} }
// 后台重新校验时不重挂载。TTL 刷新后重挂载可见封面 // 解码失败只重拉一次。同一条目内不再循环,避免坏响应把占位图卡死。
// 可能 Error 一次并使占位图锁定,行仍在屏幕上。 var refetchWithoutDisk by remember(model, baseCacheKey) { mutableStateOf(false) }
val request = remember(model, httpUrl, baseCacheKey, memoryKey, bucketPx) { val request = remember(model, httpUrl, baseCacheKey, memoryKey, bucketPx, refetchWithoutDisk) {
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(model) .data(model)
.crossfade(false) .crossfade(false)
@@ -297,6 +263,9 @@ fun LlmpAsyncImage(
if (bucketPx > 0) { if (bucketPx > 0) {
size(Size(bucketPx, bucketPx)) size(Size(bucketPx, bucketPx))
} }
if (refetchWithoutDisk) {
diskCachePolicy(CachePolicy.WRITE_ONLY)
}
if (baseCacheKey != null) { if (baseCacheKey != null) {
// 磁盘存原图字节(无尺寸后缀);内存按显示尺寸分桶。 // 磁盘存原图字节(无尺寸后缀);内存按显示尺寸分桶。
diskCacheKey(baseCacheKey) diskCacheKey(baseCacheKey)
@@ -315,6 +284,10 @@ fun LlmpAsyncImage(
IMG_TAG, IMG_TAG,
"req ERR ${result.throwable.message} url=$model", "req ERR ${result.throwable.message} url=$model",
) )
if (!refetchWithoutDisk && result.throwable.isUndecodableImage()) {
ImageRevalidator.evictUndecodable(baseCacheKey, memoryKey)
refetchWithoutDisk = true
}
}, },
) )
} }
@@ -379,3 +352,36 @@ fun LlmpAsyncImage(
private fun String.isHttpUrl(): Boolean = private fun String.isHttpUrl(): Boolean =
startsWith("http://", ignoreCase = true) || startsWith("https://", ignoreCase = true) startsWith("http://", ignoreCase = true) || startsWith("https://", ignoreCase = true)
/** 空正文或明确的非图片类型。这种 2xx 不能占住磁盘 TTL。 */
private fun isUnusableImageResponse(response: NetworkResponse): Boolean {
val length = response.headers["content-length"]?.trim()?.toLongOrNull()
if (length != null && length <= 0L) return true
val type = response.headers["content-type"]
?.substringBefore(';')
?.trim()
?.lowercase()
?: return false
return type == "text/html" ||
type == "application/xhtml+xml" ||
type == "application/json" ||
type == "text/json" ||
type == "application/xml" ||
type == "text/xml"
}
private fun Throwable.isUndecodableImage(): Boolean {
var current: Throwable? = this
while (current != null) {
val message = current.message.orEmpty()
if (
"makeFromEncoded" in message ||
"null bitmap" in message ||
"Unable to create a decoder" in message
) {
return true
}
current = current.cause
}
return false
}
@@ -95,6 +95,7 @@ import com.zhushenwudi.lovelivemusicplayer.ui.components.rememberMusicCoverUri
import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToLong import kotlin.math.roundToLong
import kotlin.time.Duration.Companion.seconds
/** 播放器内容类型:封面 ↔ 歌词(立绘暂未实现)。 */ /** 播放器内容类型:封面 ↔ 歌词(立绘暂未实现)。 */
private enum class PlayerContent { private enum class PlayerContent {
@@ -652,7 +653,7 @@ private fun PlayerLyricScroll(
LaunchedEffect(isDragged, listState.isScrollInProgress, selecting) { LaunchedEffect(isDragged, listState.isScrollInProgress, selecting) {
if (!selecting) return@LaunchedEffect if (!selecting) return@LaunchedEffect
if (isDragged || listState.isScrollInProgress) return@LaunchedEffect if (isDragged || listState.isScrollInProgress) return@LaunchedEffect
delay(3_000) delay(3.seconds)
if (!isDragged && !listState.isScrollInProgress) { if (!isDragged && !listState.isScrollInProgress) {
selecting = false selecting = false
selectedIndex = -1 selectedIndex = -1
@@ -24,7 +24,6 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -55,19 +54,23 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -83,6 +86,7 @@ import com.zhushenwudi.lovelivemusicplayer.platform.DetailBackHandler
import com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi import com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi
import com.zhushenwudi.lovelivemusicplayer.player.LoopMode import com.zhushenwudi.lovelivemusicplayer.player.LoopMode
import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage
import com.zhushenwudi.lovelivemusicplayer.ui.image.clearLlmpImageCache
import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs
import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme
import com.zhushenwudi.lovelivemusicplayer.ui.components.AddMinDialog import com.zhushenwudi.lovelivemusicplayer.ui.components.AddMinDialog
@@ -106,7 +110,9 @@ import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset
import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgIcon import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgIcon
import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalDesignScale import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalDesignScale
import com.zhushenwudi.lovelivemusicplayer.ui.theme.hSp import com.zhushenwudi.lovelivemusicplayer.ui.theme.hSp
import kotlinx.coroutines.IO
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
private val dailyJson = Json { ignoreUnknownKeys = true; isLenient = true } private val dailyJson = Json { ignoreUnknownKeys = true; isLenient = true }
@@ -147,6 +153,7 @@ fun SettingsScreen(
onFollowSystemChange: (Boolean) -> Unit, onFollowSystemChange: (Boolean) -> Unit,
onDarkThemeChange: (Boolean) -> Unit, onDarkThemeChange: (Boolean) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onReturnHome: () -> Unit = {},
onOpenSd: () -> Unit = {}, onOpenSd: () -> Unit = {},
onOpenLogs: () -> Unit = {}, onOpenLogs: () -> Unit = {},
onOpenPermissions: () -> Unit = {}, onOpenPermissions: () -> Unit = {},
@@ -333,17 +340,21 @@ fun SettingsScreen(
PlatformUi.showToast(I18n.t("clean_success")) PlatformUi.showToast(I18n.t("clean_success"))
} }
}, },
onClearImageCache = {
scope.launch(Dispatchers.IO) {
clearLlmpImageCache()
withContext(Dispatchers.Main) {
PlatformUi.showToast(I18n.t("clean_success"))
}
}
},
onClearUserData = { onClearUserData = {
scope.launch { scope.launch {
loadingMsg = I18n.t("exiting") loadingMsg = I18n.t("resetting")
AppContainer.library.clearMenus() AppContainer.clearUserData()
AppContainer.library.clearLove()
AppContainer.library.clearHistory()
AppContainer.library.clearPlayList()
settings.dataVersion = ""
loadingMsg = null loadingMsg = null
PlatformUi.showToast(I18n.t("user_data_cleared")) PlatformUi.showToast(I18n.t("user_data_cleared"))
PlatformUi.exitApp() onReturnHome()
} }
}, },
onDismiss = { showClearDialog = false }, onDismiss = { showClearDialog = false },
@@ -462,7 +473,7 @@ fun TransferScreen(onBack: () -> Unit, onOpenScan: () -> Unit) {
LaunchedEffect(state.completed) { LaunchedEffect(state.completed) {
if (state.completed && !wasCompleted) { if (state.completed && !wasCompleted) {
// 等本帧最后一次封面更新进入动画队列,再等轮播完整播放完毕。 // 等本帧最后一次封面更新进入动画队列,再等轮播完整播放完毕。
delay(100) delay(100.milliseconds)
snapshotFlow { coverAnimationsIdle }.first { it } snapshotFlow { coverAnimationsIdle }.first { it }
onBack() onBack()
} }
@@ -1258,14 +1269,20 @@ private fun DailySection(
} }
} }
private const val PrivacyLinkTag = "privacy_link"
private const val PrivacyUmengUrl = "https://www.umeng.com/page/policy" private const val PrivacyUmengUrl = "https://www.umeng.com/page/policy"
private const val PrivacyShareUrl = "https://policy.zztfly.com/sdk/share/privacy" private const val PrivacyShareUrl = "https://policy.zztfly.com/sdk/share/privacy"
private const val Privacy360Url = "https://jiagu.360.cn/#/global/help/322" private const val Privacy360Url = "https://jiagu.360.cn/#/global/help/322"
private const val PrivacyGithubUrl = private const val PrivacyGithubUrl =
"https://github.com/zhushenwudi/LoveLiveMusicPlayerMobile" "https://github.com/zhushenwudi/LoveLiveMusicPlayerMobile"
private fun privacyLink(
url: String,
styles: TextLinkStyles,
title: String? = null,
): LinkAnnotation = LinkAnnotation.Clickable(tag = url, styles = styles) {
PlatformUi.openWebView(url, title ?: I18n.t("privacy_policy"))
}
@Composable @Composable
fun PermissionsScreen(onBack: () -> Unit) { fun PermissionsScreen(onBack: () -> Unit) {
val dark = LocalDarkTheme.current val dark = LocalDarkTheme.current
@@ -1274,30 +1291,24 @@ fun PermissionsScreen(onBack: () -> Unit) {
val linkColor = ColorMs.Blue28B3F7 val linkColor = ColorMs.Blue28B3F7
val bodySize = hSp(12f) val bodySize = hSp(12f)
val bodyStyle = TextStyle(color = bodyColor, fontSize = bodySize, lineHeight = bodySize * 1.4f) val bodyStyle = TextStyle(color = bodyColor, fontSize = bodySize, lineHeight = bodySize * 1.4f)
val linkStyle = SpanStyle(color = linkColor, fontSize = bodySize) val linkStyles = TextLinkStyles(style = SpanStyle(color = linkColor, fontSize = bodySize))
val lang by I18n.lang.collectAsState() val lang by I18n.lang.collectAsState()
val annotated = remember(bodyColor, bodySize, lang) { val annotated = remember(bodySize, lang) {
buildAnnotatedString { buildAnnotatedString {
append(I18n.t("privacy_detail1")) append(I18n.t("privacy_detail1"))
pushStringAnnotation(PrivacyLinkTag, PrivacyUmengUrl) withLink(privacyLink(PrivacyUmengUrl, linkStyles)) { append(I18n.t("privacy_umeng")) }
withStyle(linkStyle) { append(I18n.t("privacy_umeng")) }
pop()
append(I18n.t("privacy_detail2")) append(I18n.t("privacy_detail2"))
pushStringAnnotation(PrivacyLinkTag, PrivacyShareUrl) withLink(privacyLink(PrivacyShareUrl, linkStyles)) { append(I18n.t("privacy_share")) }
withStyle(linkStyle) { append(I18n.t("privacy_share")) }
pop()
append(I18n.t("privacy_detail3")) append(I18n.t("privacy_detail3"))
pushStringAnnotation(PrivacyLinkTag, Privacy360Url) withLink(privacyLink(Privacy360Url, linkStyles)) { append(I18n.t("privacy_360")) }
withStyle(linkStyle) { append(I18n.t("privacy_360")) }
pop()
append(I18n.t("privacy_detail4")) append(I18n.t("privacy_detail4"))
} }
} }
val githubLink = remember(bodySize, lang) { val githubLink = remember(bodySize, lang) {
buildAnnotatedString { buildAnnotatedString {
pushStringAnnotation(PrivacyLinkTag, PrivacyGithubUrl) withLink(privacyLink(PrivacyGithubUrl, linkStyles, title = "GitHub")) {
withStyle(linkStyle) { append(I18n.t("github_url")) } append(I18n.t("github_url"))
pop() }
} }
} }
Scaffold( Scaffold(
@@ -1316,25 +1327,12 @@ fun PermissionsScreen(onBack: () -> Unit) {
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
.navigationBarsPadding(), .navigationBarsPadding(),
) { ) {
ClickableText( Text(text = annotated, style = bodyStyle)
text = annotated,
style = bodyStyle,
onClick = { offset ->
annotated.getStringAnnotations(PrivacyLinkTag, offset, offset)
.firstOrNull()
?.let { PlatformUi.openWebView(it.item, I18n.t("privacy_policy")) }
},
)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
ClickableText( Text(
text = githubLink, text = githubLink,
style = bodyStyle.copy(textAlign = TextAlign.Center), style = bodyStyle.copy(textAlign = TextAlign.Center),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onClick = { offset ->
githubLink.getStringAnnotations(PrivacyLinkTag, offset, offset)
.firstOrNull()
?.let { PlatformUi.openWebView(it.item, "GitHub") }
},
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
@@ -1350,7 +1348,7 @@ fun SdScreen(onBack: () -> Unit) {
devices = AppContainer.listStorageDevices() devices = AppContainer.listStorageDevices()
return@LaunchedEffect return@LaunchedEffect
} }
delay(1_000) delay(1.seconds)
devices = AppContainer.listStorageDevices() devices = AppContainer.listStorageDevices()
} }
val dark = LocalDarkTheme.current val dark = LocalDarkTheme.current
@@ -29,6 +29,7 @@ import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res
import com.zhushenwudi.lovelivemusicplayer.i18n.tr import com.zhushenwudi.lovelivemusicplayer.i18n.tr
import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs
import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage
import kotlin.time.Duration.Companion.seconds
/** /**
* 启动页流程: * 启动页流程:
@@ -53,7 +54,7 @@ fun SplashScreen(onFinished: () -> Unit) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// 先显示 1 秒 Logo // 先显示 1 秒 Logo
delay(1_000) delay(1.seconds)
count = 2 count = 2
photoPath = runCatching { photoPath = runCatching {
AppContainer.splashPhoto.pickCachedDisplayPath() AppContainer.splashPhoto.pickCachedDisplayPath()
@@ -61,7 +62,7 @@ fun SplashScreen(onFinished: () -> Unit) {
// 剩余倒计时(2 秒 → 0)后进入主页 // 剩余倒计时(2 秒 → 0)后进入主页
while (count > 0) { while (count > 0) {
delay(1_000) delay(1.seconds)
count -= 1 count -= 1
} }
goHome() goHome()
+1 -1
View File
@@ -1,5 +1,5 @@
[versions] [versions]
agp = "9.3.1" agp = "9.4.1"
android-compileSdk = "37" android-compileSdk = "37"
android-minSdk = "24" android-minSdk = "24"
android-targetSdk = "37" android-targetSdk = "37"
-12
View File
@@ -7,10 +7,8 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* 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 */; }; 22EA1CB33A17DCC6FC82406D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F543015C899423D4B01CEB2E /* Assets.xcassets */; };
2D0291E964949C7C8D4DF324 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 88A292AA7607F14E8C66291E /* 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 */; }; 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */; };
5A902F589276392B4CBC0015 /* HomeWidgetExampleBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30942AA1A2E3A1AB832DD959 /* HomeWidgetExampleBundle.swift */; }; 5A902F589276392B4CBC0015 /* HomeWidgetExampleBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30942AA1A2E3A1AB832DD959 /* HomeWidgetExampleBundle.swift */; };
76ED3117C0B97CD5572EB808 /* HomeWidgetExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B1CCBD46108D407DF0C9A3 /* HomeWidgetExample.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 */; }; 93FF51B22016695CB74F0AD6 /* HostBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B6B3C131D45A3E52B69B531 /* HostBridge.swift */; };
A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2016716493FA6F4B6E30EB17 /* PipScreenManager.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, ); }; }; 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 */; }; F9A23E2F5F7514E5D6A3D9C0 /* BackgroundIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */; };
FA6C12E55F9D41237B4BDAF1 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310E847101B97B7A36776025 /* CarPlaySceneDelegate.swift */; }; FA6C12E55F9D41237B4BDAF1 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310E847101B97B7A36776025 /* CarPlaySceneDelegate.swift */; };
FDE52B8D57A097A60A4CF366 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6C1F485E0CF8BB70C9DDD2 /* AppDelegate.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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; D1E2F3A4B5C60718293A4B01 /* QqShareHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QqShareHost.swift; sourceTree = "<group>"; };
@@ -137,10 +131,7 @@
5B4BD3733F8124940306FE19 /* pip */ = { 5B4BD3733F8124940306FE19 /* pip */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E2D6A135D7E87F67C8285D07 /* black.mp4 */,
4B8F846972E53BC33C1001D3 /* GradientView.swift */,
2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */, 2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */,
0480DE3601E8C37A2D4745EA /* PipScreenView.swift */,
); );
path = pip; path = pip;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -265,7 +256,6 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
22EA1CB33A17DCC6FC82406D /* Assets.xcassets in Resources */, 22EA1CB33A17DCC6FC82406D /* Assets.xcassets in Resources */,
EAC4398962E17AAE02F2DB68 /* black.mp4 in Resources */,
D1E2F3A4B5C60718293A4B06 /* TencentOpenApi_IOS_Bundle.bundle in Resources */, D1E2F3A4B5C60718293A4B06 /* TencentOpenApi_IOS_Bundle.bundle in Resources */,
I18N0PLIST0BUILD0FILE001 /* InfoPlist.strings in Resources */, I18N0PLIST0BUILD0FILE001 /* InfoPlist.strings in Resources */,
); );
@@ -344,10 +334,8 @@
FDE52B8D57A097A60A4CF366 /* AppDelegate.swift in Sources */, FDE52B8D57A097A60A4CF366 /* AppDelegate.swift in Sources */,
FA6C12E55F9D41237B4BDAF1 /* CarPlaySceneDelegate.swift in Sources */, FA6C12E55F9D41237B4BDAF1 /* CarPlaySceneDelegate.swift in Sources */,
56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */, 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */,
4701F6E8155C8E093C8CF81B /* GradientView.swift in Sources */,
93FF51B22016695CB74F0AD6 /* HostBridge.swift in Sources */, 93FF51B22016695CB74F0AD6 /* HostBridge.swift in Sources */,
A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */, A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */,
02E790E4779E846963D3DC78 /* PipScreenView.swift in Sources */,
88ABACE0259DE83261737B99 /* SceneDelegate.swift in Sources */, 88ABACE0259DE83261737B99 /* SceneDelegate.swift in Sources */,
8070A4B4DD1D3DD823A8F923 /* WidgetAppUtils.swift in Sources */, 8070A4B4DD1D3DD823A8F923 /* WidgetAppUtils.swift in Sources */,
B7C8D9E0F1A234567890ABCD /* InAppWebViewController.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"
}
]
}
+31
View File
@@ -1,3 +1,4 @@
import AVFoundation
import UIKit import UIKit
import WidgetKit import WidgetKit
@@ -93,9 +94,39 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
guard let message = notification.userInfo?["message"] as? String else { return } guard let message = notification.userInfo?["message"] as? String else { return }
HostBridge.shared.showToast(message) HostBridge.shared.showToast(message)
} }
NotificationCenter.default.addObserver(
forName: Notification.Name("llmpPrepareAudioSession"),
object: nil,
queue: .main
) { [weak self] _ in
self?.activatePlaybackSession()
}
QqShareHost.shared.prepare() 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( func application(
_ app: UIApplication, _ app: UIApplication,
open url: URL, open url: URL,
+1 -1
View File
@@ -173,6 +173,6 @@
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<false/> <true/>
</dict> </dict>
</plist> </plist>
+12 -2
View File
@@ -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) { func sceneDidBecomeActive(_ scene: UIScene) {
// 前台不显示桌面歌词:关掉 PiP,但播放中仍保持 dummy,方便下次进后台自动弹出。 // 前台不显示桌面歌词。开关仍开着时,下次进后台再弹出。
pipManager.stopPictureInPictureOnly() pipManager.stopPictureInPictureOnly()
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
@@ -86,7 +97,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
self.pipManager.manualChangePicInPic(needStart: false) self.pipManager.manualChangePicInPic(needStart: false)
} }
case "show": case "show":
self.pipManager.picInPicAutoOpen(nil)
self.pipManager.manualChangePicInPic(needStart: true) self.pipManager.manualChangePicInPic(needStart: true)
case "hide": case "hide":
self.pipManager.stopPictureInPictureOnly() self.pipManager.stopPictureInPictureOnly()
-55
View File
@@ -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()
}
}
+290 -188
View File
@@ -1,211 +1,83 @@
import AVFoundation
import AVKit import AVKit
import CoreMedia
import CoreVideo
import UIKit import UIKit
@MainActor @MainActor
class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDelegate { class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDelegate, AVPictureInPictureSampleBufferPlaybackDelegate {
// 4:1 能保持最大宽度,同时比 5:1 的歌词条更接近原来的高度。 private static let renderSize = CGSize(width: 800, height: 200)
private static let pipAspectRatio: CGFloat = 2000.0 / 500.0
private var firstWindow: UIWindow?
private var pipController: AVPictureInPictureController? private var pipController: AVPictureInPictureController?
private let sourceView = UIView() private let sourceView = UIView()
private var sourceHeightConstraint: NSLayoutConstraint? private let sampleHost = SampleBufferHostView()
private var picInPicView = PipScreenView()
private var isOpenPicInPic = false
private var suspectedWindows: [UIWindow] = []
private var didPrepare = false private var didPrepare = false
private var wantsPip = false
override init() { private var isOpenPicInPic = false
super.init() private var isStartingPip = false
isOpenPicInPic = false private var timebase: CMTimebase?
} private var line1 = ""
private var line2 = ""
deinit { private var currentLine = -1
NotificationCenter.default.removeObserver(self)
}
func addScreenView(on view: UIView) { func addScreenView(on view: UIView) {
if sourceView.superview !== view { guard sourceView.superview !== view else {
sourceView.removeFromSuperview() if !didPrepare { preparePicInPic() }
sourceView.backgroundColor = .clear return
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,
])
} }
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() view.layoutIfNeeded()
if !didPrepare { if !didPrepare { preparePicInPic() }
preparePicInPic(on: sourceView)
} else {
pipController?.playerLayer.frame = sourceView.bounds
}
} }
func removeScreenView() { func removeScreenView() {
wantsPip = false
sourceView.removeFromSuperview() 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?) { func picInPicAutoOpen(_ isOpen: Bool?) {
if let isOpen { isOpenPicInPic = isOpen } if let isOpen { isOpenPicInPic = isOpen }
let isPlayingNow = pipController?.playerLayer.player?.timeControlStatus == .playing if isOpenPicInPic { enqueueCurrentFrame() }
if isOpenPicInPic {
if !isPlayingNow { pipController?.playerLayer.player?.play() }
} else if isPlayingNow {
pipController?.playerLayer.player?.pause()
}
} }
@objc func updatePipScreenView(lyricLine1: String?, lyricLine2: String?, currentLine: Int) { @objc func updatePipScreenView(lyricLine1: String?, lyricLine2: String?, currentLine: Int) {
picInPicView.updateContent(lyricLine1: lyricLine1, lyricLine2: lyricLine2, currentLine: currentLine) if let lyricLine1 { line1 = lyricLine1 }
} if let lyricLine2 { line2 = lyricLine2 }
self.currentLine = currentLine
@objc private func windowDidBecomeVisible(notification: Notification) { enqueueCurrentFrame()
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()
} }
func manualChangePicInPic(needStart: Bool) { func manualChangePicInPic(needStart: Bool) {
if needStart { if needStart {
pipController?.playerLayer.player?.play() wantsPip = true
if pipController?.isPictureInPictureActive == false { isOpenPicInPic = true
pipController?.startPictureInPicture() pipController?.invalidatePlaybackState()
} startPipIfPossible(retries: 8)
} else { } else {
stopPictureInPictureOnly() stopPictureInPictureOnly()
pipController?.playerLayer.player?.pause()
} }
} }
/// 只关 PiP,dummy player 继续播,方便下次进后台自动弹出。
func stopPictureInPictureOnly() { func stopPictureInPictureOnly() {
wantsPip = false
if pipController?.isPictureInPictureActive == true { if pipController?.isPictureInPictureActive == true {
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
} }
@@ -214,20 +86,250 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe
func pictureInPictureControllerDidStartPictureInPicture( func pictureInPictureControllerDidStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController _ pictureInPictureController: AVPictureInPictureController
) { ) {
if firstWindow == nil { isStartingPip = false
firstWindow = filterTargetWindow() if !wantsPip {
suspectedWindows.removeAll() pictureInPictureController.stopPictureInPicture()
return
} }
if let firstWindow { enqueueCurrentFrame()
firstWindow.addSubview(picInPicView) }
picInPicView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([ func pictureInPictureController(
picInPicView.topAnchor.constraint(equalTo: firstWindow.topAnchor), _ pictureInPictureController: AVPictureInPictureController,
picInPicView.leadingAnchor.constraint(equalTo: firstWindow.leadingAnchor), failedToStartPictureInPictureWithError error: Error
picInPicView.trailingAnchor.constraint(equalTo: firstWindow.trailingAnchor), ) {
picInPicView.bottomAnchor.constraint(equalTo: firstWindow.bottomAnchor), 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
} }
} }
-110
View File
@@ -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.
@@ -23,6 +23,7 @@ import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
import com.zhushenwudi.lovelivemusicplayer.platform.PlatformServices import com.zhushenwudi.lovelivemusicplayer.platform.PlatformServices
import java.io.File import java.io.File
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
actual class PlayerController(context: Context) { actual class PlayerController(context: Context) {
private val appContext = context.applicationContext private val appContext = context.applicationContext
@@ -114,7 +115,7 @@ actual class PlayerController(context: Context) {
durationMs = player.duration.coerceAtLeast(0), durationMs = player.duration.coerceAtLeast(0),
) )
} }
delay(200) delay(200.milliseconds)
} }
} }
} }
@@ -284,8 +284,10 @@ internal val DeStrings: Map<String, String> = mapOf(
"now_playing_song" to "Läuft gerade: %s", "now_playing_song" to "Läuft gerade: %s",
"fetch_songs_fail_or_net" to "Datenfehler oder Netzwerkfehler", "fetch_songs_fail_or_net" to "Datenfehler oder Netzwerkfehler",
"explain_songs_data_kmp" to "Songdaten: Bibliotheksmetadaten, Version", "explain_songs_data_kmp" to "Songdaten: Bibliotheksmetadaten, Version",
"explain_user_data_kmp" to "Benutzerdaten: Likes/Verlauf/Playlists/Cache, dann beenden", "explain_image_cache" to "Bildcache: heruntergeladene Cover, danach neu laden",
"explain_user_data_kmp" to "Benutzerdaten: Likes/Verlauf/Playlists/Cache",
"remove_songs_data_kmp" to "Songdaten löschen", "remove_songs_data_kmp" to "Songdaten löschen",
"clear_image_cache" to "Bildcache löschen",
"remove_user_data_kmp" to "Benutzerdaten löschen", "remove_user_data_kmp" to "Benutzerdaten löschen",
"confirm_delete_from_menu_short" to "Aus Playlist löschen?", "confirm_delete_from_menu_short" to "Aus Playlist löschen?",
"play_text" to "Spielen,Pausierte", "play_text" to "Spielen,Pausierte",
@@ -284,8 +284,10 @@ internal val EnStrings: Map<String, String> = mapOf(
"now_playing_song" to "Now Playing: %s", "now_playing_song" to "Now Playing: %s",
"fetch_songs_fail_or_net" to "Data error or network failure", "fetch_songs_fail_or_net" to "Data error or network failure",
"explain_songs_data_kmp" to "Song data: library metadata, version", "explain_songs_data_kmp" to "Song data: library metadata, version",
"explain_user_data_kmp" to "User data: likes/history/playlists/cache, then quit", "explain_image_cache" to "Image cache: downloaded covers, reloaded after clearing",
"explain_user_data_kmp" to "User data: likes/history/playlists/cache",
"remove_songs_data_kmp" to "Clear song data", "remove_songs_data_kmp" to "Clear song data",
"clear_image_cache" to "Clear image cache",
"remove_user_data_kmp" to "Clear user data", "remove_user_data_kmp" to "Clear user data",
"confirm_delete_from_menu_short" to "Delete from playlist?", "confirm_delete_from_menu_short" to "Delete from playlist?",
"play_text" to "Playing,Paused", "play_text" to "Playing,Paused",
@@ -284,8 +284,10 @@ internal val JaStrings: Map<String, String> = mapOf(
"now_playing_song" to "再生中:%s", "now_playing_song" to "再生中:%s",
"fetch_songs_fail_or_net" to "データ異常またはネットワーク失敗", "fetch_songs_fail_or_net" to "データ異常またはネットワーク失敗",
"explain_songs_data_kmp" to "曲データ:ライブラリメタデータ、バージョン", "explain_songs_data_kmp" to "曲データ:ライブラリメタデータ、バージョン",
"explain_user_data_kmp" to "ユーザーデータ:好き/履歴/プレイリスト/キャッシュを削除して終了", "explain_image_cache" to "画像キャッシュ:ダウンロード済みカバー。削除後に再読み込み",
"explain_user_data_kmp" to "ユーザーデータ:好き/履歴/プレイリスト/キャッシュ",
"remove_songs_data_kmp" to "曲データを削除", "remove_songs_data_kmp" to "曲データを削除",
"clear_image_cache" to "画像キャッシュを削除",
"remove_user_data_kmp" to "ユーザーデータを削除", "remove_user_data_kmp" to "ユーザーデータを削除",
"confirm_delete_from_menu_short" to "プレイリストから削除しますか?", "confirm_delete_from_menu_short" to "プレイリストから削除しますか?",
"play_text" to "放送中,停止しました", "play_text" to "放送中,停止しました",
@@ -284,8 +284,10 @@ internal val ZhStrings: Map<String, String> = mapOf(
"now_playing_song" to "正在播放:%s", "now_playing_song" to "正在播放:%s",
"fetch_songs_fail_or_net" to "数据异常或网络失败", "fetch_songs_fail_or_net" to "数据异常或网络失败",
"explain_songs_data_kmp" to "歌曲数据:曲库元数据、版本号", "explain_songs_data_kmp" to "歌曲数据:曲库元数据、版本号",
"explain_user_data_kmp" to "用户数据:喜欢/历史/歌单/缓存,并退出应用", "explain_image_cache" to "图片缓存:已下载的封面,清除后重新加载",
"explain_user_data_kmp" to "用户数据:喜欢/历史/歌单/缓存",
"remove_songs_data_kmp" to "清除歌曲数据", "remove_songs_data_kmp" to "清除歌曲数据",
"clear_image_cache" to "清除图片缓存",
"remove_user_data_kmp" to "清除用户数据", "remove_user_data_kmp" to "清除用户数据",
"confirm_delete_from_menu_short" to "确认从歌单删除?", "confirm_delete_from_menu_short" to "确认从歌单删除?",
"play_text" to "播放中,已暂停", "play_text" to "播放中,已暂停",
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
class SleepTimer( class SleepTimer(
private val scope: CoroutineScope, private val scope: CoroutineScope,
@@ -23,7 +24,7 @@ class SleepTimer(
_remainingMs.value = left _remainingMs.value = left
job = scope.launch { job = scope.launch {
while (left > 0) { while (left > 0) {
delay(1_000) delay(1.seconds)
left -= 1_000 left -= 1_000
_remainingMs.value = left.coerceAtLeast(0) _remainingMs.value = left.coerceAtLeast(0)
} }
@@ -1,6 +1,8 @@
package com.zhushenwudi.lovelivemusicplayer.player package com.zhushenwudi.lovelivemusicplayer.player
import kotlinx.cinterop.CValue
import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.useContents
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -12,7 +14,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import platform.AVFAudio.AVAudioSession import platform.AVFAudio.AVAudioSession
import platform.AVFAudio.AVAudioSessionCategoryPlayback
import platform.AVFAudio.AVAudioSessionInterruptionNotification import platform.AVFAudio.AVAudioSessionInterruptionNotification
import platform.AVFAudio.AVAudioSessionInterruptionOptionKey import platform.AVFAudio.AVAudioSessionInterruptionOptionKey
import platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume import platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume
@@ -22,7 +23,6 @@ import platform.AVFAudio.AVAudioSessionInterruptionTypeKey
import platform.AVFAudio.AVAudioSessionRouteChangeNotification import platform.AVFAudio.AVAudioSessionRouteChangeNotification
import platform.AVFAudio.AVAudioSessionRouteChangeReasonKey import platform.AVFAudio.AVAudioSessionRouteChangeReasonKey
import platform.AVFAudio.AVAudioSessionRouteChangeReasonOldDeviceUnavailable import platform.AVFAudio.AVAudioSessionRouteChangeReasonOldDeviceUnavailable
import platform.AVFAudio.setActive
import platform.AVFoundation.AVPlayer import platform.AVFoundation.AVPlayer
import platform.AVFoundation.AVPlayerItem import platform.AVFoundation.AVPlayerItem
import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification
@@ -40,9 +40,13 @@ import platform.AVFoundation.rate
import platform.AVFoundation.replaceCurrentItemWithPlayerItem import platform.AVFoundation.replaceCurrentItemWithPlayerItem
import platform.AVFoundation.resourceLoader import platform.AVFoundation.resourceLoader
import platform.AVFoundation.seekToTime import platform.AVFoundation.seekToTime
import platform.CoreGraphics.CGRectMake
import platform.CoreGraphics.CGSize
import platform.CoreGraphics.CGSizeMake
import platform.CoreMedia.CMTimeGetSeconds import platform.CoreMedia.CMTimeGetSeconds
import platform.CoreMedia.CMTimeMake import platform.CoreMedia.CMTimeMake
import platform.Foundation.NSBundle import platform.Foundation.NSBundle
import platform.Foundation.NSThread
import platform.Foundation.NSError import platform.Foundation.NSError
import platform.Foundation.NSMutableURLRequest import platform.Foundation.NSMutableURLRequest
import platform.Foundation.NSNotification import platform.Foundation.NSNotification
@@ -69,6 +73,9 @@ import platform.MediaPlayer.MPRemoteCommandHandlerStatus
import platform.MediaPlayer.MPRemoteCommandHandlerStatusCommandFailed import platform.MediaPlayer.MPRemoteCommandHandlerStatusCommandFailed
import platform.MediaPlayer.MPRemoteCommandHandlerStatusSuccess import platform.MediaPlayer.MPRemoteCommandHandlerStatusSuccess
import platform.MediaPlayer.MPSeekCommandEvent import platform.MediaPlayer.MPSeekCommandEvent
import platform.UIKit.UIGraphicsBeginImageContextWithOptions
import platform.UIKit.UIGraphicsEndImageContext
import platform.UIKit.UIGraphicsGetImageFromCurrentImageContext
import platform.UIKit.UIImage import platform.UIKit.UIImage
import platform.darwin.dispatch_async import platform.darwin.dispatch_async
import platform.darwin.dispatch_get_main_queue import platform.darwin.dispatch_get_main_queue
@@ -122,6 +129,9 @@ actual class PlayerController {
private var lastEndMark: TimeSource.Monotonic.ValueTimeMark? = null private var lastEndMark: TimeSource.Monotonic.ValueTimeMark? = null
private var audioReady = false private var audioReady = false
private var audioActive = false
private var playWhenReady = false
private var audioSessionReadyObserver: Any? = null
/** CarPlay / 锁屏 Now Playing 标题用的当前歌词;null 表示显示歌名。 */ /** CarPlay / 锁屏 Now Playing 标题用的当前歌词;null 表示显示歌名。 */
private var nowPlayingLyric: String? = null private var nowPlayingLyric: String? = null
@@ -134,18 +144,50 @@ actual class PlayerController {
observePlaybackFailed() observePlaybackFailed()
observeAudioRouteChanges() observeAudioRouteChanges()
observeAudioInterruptions() observeAudioInterruptions()
observeAudioSessionReady()
} }
private fun ensureAudioReady() { private fun ensureAudioReady() {
if (audioReady) return if (audioActive || audioReady) return
audioReady = true 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() } runCatching { setupRemoteCommands() }
.onFailure { AppLog.e(TAG, "ensureAudioReady remoteCommands fail", it) } .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() { private fun setupRemoteCommands() {
@@ -261,7 +303,7 @@ actual class PlayerController {
queue = null, queue = null,
) { notification: NSNotification? -> ) { notification: NSNotification? ->
val ended = notification?.`object` as? AVPlayerItem val ended = notification?.`object` as? AVPlayerItem
// 桌面歌词 dummy black.mp4 也会发 DidPlayToEnd;只处理本控制器的音乐 item。 // 只处理本控制器的音乐 item。
// Kotlin/Native 的 === 对 ObjC 对象包装器不可靠,须用 isEqual 比较指针。 // Kotlin/Native 的 === 对 ObjC 对象包装器不可靠,须用 isEqual 比较指针。
if (ended == null || !isMusicItem(ended)) { if (ended == null || !isMusicItem(ended)) {
return@addObserverForName return@addObserverForName
@@ -455,6 +497,10 @@ actual class PlayerController {
* 封面 + 专辑信息启用灵动岛媒体胶囊。 * 封面 + 专辑信息启用灵动岛媒体胶囊。
*/ */
private fun updateNowPlayingInfo() { private fun updateNowPlayingInfo() {
if (!NSThread.isMainThread) {
dispatch_async(dispatch_get_main_queue()) { updateNowPlayingInfo() }
return
}
val s = _state.value val s = _state.value
val current = s.current ?: return val current = s.current ?: return
val songName = current.musicName ?: return val songName = current.musicName ?: return
@@ -502,12 +548,32 @@ actual class PlayerController {
else -> raw else -> raw
} }
val image = UIImage.imageWithContentsOfFile(path) ?: return null val image = UIImage.imageWithContentsOfFile(path) ?: return null
val artwork = MPMediaItemArtwork(boundsSize = image.size) { _ -> image } val artwork = coverArtwork(image)
cachedArtworkMusicId = id cachedArtworkMusicId = id
cachedArtwork = artwork cachedArtwork = artwork
return artwork return artwork
} }
/**
* 系统会按锁屏 / 控制中心要的尺寸回调。直接交原图时,负一屏封面会空白。
*/
private fun coverArtwork(image: UIImage): MPMediaItemArtwork {
return MPMediaItemArtwork(boundsSize = image.size) { requested ->
scaledCover(image, requested)
}
}
private fun scaledCover(image: UIImage, requested: CValue<CGSize>): UIImage {
val width = requested.useContents { this.width }
val height = requested.useContents { this.height }
if (width < 1.0 || height < 1.0) return image
UIGraphicsBeginImageContextWithOptions(CGSizeMake(width, height), false, 0.0)
image.drawInRect(CGRectMake(0.0, 0.0, width, height))
val scaled = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return scaled ?: image
}
private fun prefetchHttpArtwork(musicId: String, url: String) { private fun prefetchHttpArtwork(musicId: String, url: String) {
if (musicId == coverFetchingMusicId) return if (musicId == coverFetchingMusicId) return
if (musicId == cachedArtworkMusicId && cachedArtwork != null) return if (musicId == cachedArtworkMusicId && cachedArtwork != null) return
@@ -537,7 +603,7 @@ actual class PlayerController {
return@dispatch_async return@dispatch_async
} }
if (_state.value.current?.musicId != musicId) return@dispatch_async if (_state.value.current?.musicId != musicId) return@dispatch_async
val artwork = MPMediaItemArtwork(boundsSize = image.size) { _ -> image } val artwork = coverArtwork(image)
cachedArtworkMusicId = musicId cachedArtworkMusicId = musicId
cachedArtwork = artwork cachedArtwork = artwork
updateNowPlayingInfo() updateNowPlayingInfo()
@@ -581,17 +647,24 @@ actual class PlayerController {
ensureAudioReady() ensureAudioReady()
val current = _state.value.current val current = _state.value.current
if (loadedUri.isNullOrBlank() && current != null) { if (loadedUri.isNullOrBlank() && current != null) {
playWhenReady = false
AppLog.e(TAG, "play() abort reason=noLoadedUri id=${current.musicId}") AppLog.e(TAG, "play() abort reason=noLoadedUri id=${current.musicId}")
_state.update { it.copy(isPlaying = false, error = "No playable URI") } _state.update { it.copy(isPlaying = false, error = "No playable URI") }
return return
} }
player.play()
_state.update { it.copy(isPlaying = true, error = null) } _state.update { it.copy(isPlaying = true, error = null) }
startPositionUpdates() if (audioActive) {
updateNowPlayingInfo() player.play()
startPositionUpdates()
updateNowPlayingInfo()
} else {
playWhenReady = true
updateNowPlayingInfo()
}
} }
actual fun pause() { actual fun pause() {
playWhenReady = false
resumeAfterInterruption = false resumeAfterInterruption = false
endScrub() endScrub()
player.pause() player.pause()
@@ -875,8 +948,12 @@ actual class PlayerController {
watchItemStatus(item, path, music) watchItemStatus(item, path, music)
if (autoPlay) { if (autoPlay) {
ensureAudioReady() ensureAudioReady()
player.play() if (audioActive) {
startPositionUpdates() player.play()
startPositionUpdates()
} else {
playWhenReady = true
}
} else { } else {
player.pause() player.pause()
positionJob?.cancel() positionJob?.cancel()