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 java.io.ByteArrayInputStream
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
class WebViewActivity : ComponentActivity() {
private var webView: WebView? = null
@@ -81,7 +82,7 @@ class WebViewActivity : ComponentActivity() {
}
LaunchedEffect(Unit) {
while (true) {
delay(1_000)
delay(1.seconds)
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.i18n.SyncI18nWithSystemLocale
import com.zhushenwudi.lovelivemusicplayer.ui.theme.ProvideDesignDensity
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun App() {
@@ -85,7 +86,7 @@ fun App() {
if (overlayOpen) {
shellInFront = false
} else {
delay(LlmpTransitionMs.toLong())
delay(LlmpTransitionMs.toLong().milliseconds)
shellInFront = true
}
}
@@ -182,6 +183,9 @@ fun App() {
settings.followSystemTheme = false
},
onBack = { navController.popBackStack() },
onReturnHome = {
navController.popBackStack(Routes.MAIN, inclusive = false)
},
onOpenSd = { navController.navigate(Routes.SD) },
onOpenLogs = { navController.navigate(Routes.LOGS) },
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.util.PathResolver
import com.zhushenwudi.lovelivemusicplayer.widget.WidgetCoverCache
import kotlin.time.Duration.Companion.seconds
object AppContainer {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -64,6 +65,9 @@ object AppContainer {
private val _usbRevision = MutableStateFlow(0L)
private val _libraryRevision = MutableStateFlow(0L)
/** 清空用户数据后让首页收起详情和播放页。 */
private val _shellReset = MutableStateFlow(0L)
/** 当前曲目完整歌词,切歌时刷新。 */
val playingLyric: StateFlow<LoadedLyric> = _playingLyric.asStateFlow()
@@ -78,6 +82,7 @@ object AppContainer {
/** 切存储清库后递增,刷新曲库 UI。 */
val libraryRevision: StateFlow<Long> = _libraryRevision.asStateFlow()
val shellReset: StateFlow<Long> = _shellReset.asStateFlow()
/** 主页 WeSlide 面板控制器。 */
val weSlide = WeSlideController()
@@ -146,9 +151,10 @@ object AppContainer {
this.pathResolver = PathResolver(fileSystem, settings)
// musicPath 保持相对路径;仅播放引擎解析为 file/http URI。
player.resolvePlaybackUri = { pathResolver.playbackUri(it) }
// Android 通知封面用 file:// / https;iOS Now Playing 优先绝对路径。
// 本地文件存在才用绝对路径。HTTP 曲库没有落盘封面时必须走 https,
// 否则负一屏 / 灵动岛会拿到一个不存在的路径,封面空白。
player.resolveCoverUri = { music ->
pathResolver.absoluteCoverPath(music)
pathResolver.absoluteCoverPath(music)?.takeIf(fileSystem::exists)
?: pathResolver.coverDisplayUri(music)
}
@@ -209,7 +215,7 @@ object AppContainer {
bindUsbMountListener()
// 全表快照放后台,并避开主页出来后的首屏解码。
scope.launch {
delay(2_000)
delay(2.seconds)
refreshCarPlayCatalog()
}
}
@@ -242,7 +248,7 @@ object AppContainer {
private fun onUsbStorageChanged() {
scope.launch {
delay(1_000)
delay(1.seconds)
val pathList = fileSystem.listUsbRoots()
if (pathList.isEmpty()) return@launch
val defPath = settings.sdPath
@@ -274,6 +280,21 @@ object AppContainer {
_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 {
val key = path.split("/").getOrNull(2) ?: path.substringAfterLast('/').ifBlank { path }
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.unscaledStatusBarsTop
import com.zhushenwudi.lovelivemusicplayer.ui.weslide.WeSlide
import kotlinx.coroutines.IO
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
@@ -250,6 +252,12 @@ fun MainShell(
val historyRevision by AppContainer.historyRevision.collectAsState()
val loveRevision by AppContainer.loveRevision.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 startupLoveRevision = remember { loveRevision }
val libraryLists = remember(refreshToken, currentGroup, enableHttp, libraryRevision) {
@@ -285,7 +293,7 @@ fun MainShell(
}
// 首屏封面先解码。本地文件核对延后,避免扫盘占满磁盘把列表拖卡。
LaunchedEffect(Unit) {
delay(1_500)
delay(1_500.milliseconds)
homeHazeEnabled = true
val groupAtStart = currentGroup.displayName
val httpAtStart = enableHttp
@@ -223,6 +223,7 @@ fun AddMinDialog(
@Composable
fun ResetDataDialog(
onClearSongs: () -> Unit,
onClearImageCache: () -> Unit,
onClearUserData: () -> Unit,
onDismiss: () -> Unit,
) {
@@ -241,6 +242,11 @@ fun ResetDataDialog(
onClearSongs()
}
Spacer(Modifier.height(24.dp))
ResetActionButton(tr("clear_image_cache")) {
onDismiss()
onClearImageCache()
}
Spacer(Modifier.height(24.dp))
ResetActionButton(tr("remove_user_data_kmp")) {
onDismiss()
onClearUserData()
@@ -254,6 +260,14 @@ fun ResetDataDialog(
modifier = Modifier.padding(horizontal = 12.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(
tr("explain_user_data_kmp"),
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.delay
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.milliseconds
private const val ThumbResizeDurationMs = 1_000
private const val ThumbFadeDelayMs = 650L
@@ -207,7 +208,7 @@ private fun LlmpFlexibleScrollbarOverlay(
if (snapshot.canScroll && (isScrollInProgress || isDragging)) {
showThumb = true
} else {
delay(ThumbFadeDelayMs)
delay(ThumbFadeDelayMs.milliseconds)
showThumb = false
}
}
@@ -6,7 +6,7 @@ import coil3.compose.setSingletonImageLoaderFactory
import com.zhushenwudi.lovelivemusicplayer.ui.image.createLlmpImageLoader
/**
* 注册 Coil 单例:SVG 解码 + 1 天网络图片缓存(SWR)。
* 注册 Coil 单例:SVG 解码 + 长期网络图片缓存。
* 网络 https(artist_webp 等)需要 classpath 上的 `coil-network-ktor3` —
* Coil 3 无此模块不会拉取网络图片。
* 须在应用根节点、任何 AsyncImage 之前调用一次。
@@ -1,7 +1,10 @@
package com.zhushenwudi.lovelivemusicplayer.ui.image
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
@@ -35,23 +38,18 @@ import coil3.request.Options
import coil3.request.crossfade
import coil3.size.Size
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
private const val IMG_TAG = "LLMP-Img"
/** 网络写入成功后的磁盘缓存软 TTL。 */
private const val IMAGE_CACHE_TTL_MS = 24L * 60 * 60 * 1000
/**
* Coil 网络缓存策略:
* - `IMAGE_CACHE_TTL_MS` 内:仅使用磁盘缓存。
* - 过期后:仍立即显示旧图(不空白/不闪烁),后台刷新;新字节写入缓存后再切换 UI。
* - 成功图片一直留在磁盘,直到用户在设置里清除。
* - 空正文、HTML/JSON/XML 不写入,读到这类缓存时改为走网络。
* - 磁盘字节无法解码时丢掉该条目并立刻再请求。
*/
@OptIn(ExperimentalCoilApi::class)
internal object DayTtlSwrCacheStrategy : CacheStrategy {
internal object PermanentImageCacheStrategy : CacheStrategy {
override suspend fun read(
cacheResponse: NetworkResponse,
@@ -59,13 +57,15 @@ internal object DayTtlSwrCacheStrategy : CacheStrategy {
options: Options,
): ReadResult {
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)
}
if (isExpired(cacheResponse)) {
ImageRevalidator.schedule(url, options.context)
}
// 显示时优先磁盘图,避免过期导致 UI 空白。
return ReadResult(cacheResponse)
}
@@ -85,90 +85,51 @@ internal object DayTtlSwrCacheStrategy : CacheStrategy {
),
)
}
// 仅持久化成功图片;失败请求下次访问重试。
if (networkResponse.code in 200 until 300) {
// 仅持久化可解码图片。空正文和 HTML 等不算成功,下次访问重试。
if (networkResponse.code in 200 until 300 && !isUnusableImageResponse(networkResponse)) {
return WriteResult(networkResponse)
}
AppLog.w(
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
}
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 {
private val mutex = Mutex()
private val inFlight = mutableSetOf<String>()
private var imageLoader: ImageLoader? = null
fun bind(loader: ImageLoader) {
imageLoader = loader
}
suspend fun schedule(url: String, context: PlatformContext) {
if (!url.isHttpUrl()) return
val cacheKey = url.toImageCacheKey()
mutex.withLock {
if (!inFlight.add(cacheKey)) {
return
/** 丢掉无法解码的磁盘/内存条目,下次访问重新请求。 */
fun evictUndecodable(diskKey: String, memoryKey: String?) {
val diskRemoved = imageLoader?.diskCache?.remove(diskKey) == true
val memory = imageLoader?.memoryCache
memory?.remove(MemoryCache.Key(diskKey))
if (memoryKey != null && memoryKey != diskKey) {
memory?.remove(MemoryCache.Key(memoryKey))
}
}
val loader = imageLoader
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(),
)
AppLog.w(IMG_TAG, "evict undecodable diskRemoved=$diskRemoved key=$diskKey")
}
private fun complete(cacheKey: String) {
// 监听器在 CacheStrategy 协程外运行;尽力而为,不挂起。
inFlight.remove(cacheKey)
// 故意不触发 UI 版本更新:重新校验后重挂载可见封面
// 曾导致间歇 Error → 占位图锁定。磁盘/内存已更新,供
// 下次组合使用(如滚回)。
fun clearAll() {
imageLoader?.memoryCache?.clear()
imageLoader?.diskCache?.clear()
CoverImageHolder.clear()
AppLog.i(IMG_TAG, "image cache cleared")
}
}
/** 清掉内存和磁盘里的网络图片。已显示的封面在下次进入页面时重新加载。 */
fun clearLlmpImageCache() {
ImageRevalidator.clearAll()
}
/**
* 封面解码 [androidx.compose.ui.graphics.Image] 的强引用。
@@ -192,6 +153,11 @@ internal object CoverImageHolder {
map.remove(old)
}
}
fun clear() {
keys.clear()
map.clear()
}
}
/** 规范化缓存键,使原始 `'`(如 μ's)与 `%27` 编码一致。 */
@@ -226,7 +192,7 @@ fun createLlmpImageLoader(context: PlatformContext): ImageLoader {
add(SvgDecoder.Factory())
add(
KtorNetworkFetcherFactory(
cacheStrategy = { DayTtlSwrCacheStrategy },
cacheStrategy = { PermanentImageCacheStrategy },
),
)
}
@@ -252,7 +218,7 @@ internal fun decodeSizeBucketPx(px: Int): Int = when {
internal fun sizedMemoryCacheKey(baseKey: String, bucketPx: Int): String =
if (bucketPx > 0) "$baseKey@$bucketPx" else baseKey
/** 共享 AsyncImage:1 天网络缓存 + 过期先用旧图(无闪烁)。 */
/** 共享 AsyncImage:成功图片长期缓存,坏图丢弃后重拉一次。 */
@Composable
fun LlmpAsyncImage(
model: Any?,
@@ -287,9 +253,9 @@ fun LlmpAsyncImage(
else -> filterQuality
}
// 后台重新校验时不重挂载。TTL 刷新后重挂载可见封面
// 可能 Error 一次并使占位图锁定,行仍在屏幕上。
val request = remember(model, httpUrl, baseCacheKey, memoryKey, bucketPx) {
// 解码失败只重拉一次。同一条目内不再循环,避免坏响应把占位图卡死。
var refetchWithoutDisk by remember(model, baseCacheKey) { mutableStateOf(false) }
val request = remember(model, httpUrl, baseCacheKey, memoryKey, bucketPx, refetchWithoutDisk) {
ImageRequest.Builder(context)
.data(model)
.crossfade(false)
@@ -297,6 +263,9 @@ fun LlmpAsyncImage(
if (bucketPx > 0) {
size(Size(bucketPx, bucketPx))
}
if (refetchWithoutDisk) {
diskCachePolicy(CachePolicy.WRITE_ONLY)
}
if (baseCacheKey != null) {
// 磁盘存原图字节(无尺寸后缀);内存按显示尺寸分桶。
diskCacheKey(baseCacheKey)
@@ -315,6 +284,10 @@ fun LlmpAsyncImage(
IMG_TAG,
"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 =
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 kotlin.math.abs
import kotlin.math.roundToLong
import kotlin.time.Duration.Companion.seconds
/** 播放器内容类型:封面 ↔ 歌词(立绘暂未实现)。 */
private enum class PlayerContent {
@@ -652,7 +653,7 @@ private fun PlayerLyricScroll(
LaunchedEffect(isDragged, listState.isScrollInProgress, selecting) {
if (!selecting) return@LaunchedEffect
if (isDragged || listState.isScrollInProgress) return@LaunchedEffect
delay(3_000)
delay(3.seconds)
if (!isDragged && !listState.isScrollInProgress) {
selecting = false
selectedIndex = -1
@@ -24,7 +24,6 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.OutlinedTextField
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.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
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.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.Serializable
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.player.LoopMode
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.LocalDarkTheme
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.theme.LocalDesignScale
import com.zhushenwudi.lovelivemusicplayer.ui.theme.hSp
import kotlinx.coroutines.IO
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
private val dailyJson = Json { ignoreUnknownKeys = true; isLenient = true }
@@ -147,6 +153,7 @@ fun SettingsScreen(
onFollowSystemChange: (Boolean) -> Unit,
onDarkThemeChange: (Boolean) -> Unit,
onBack: () -> Unit,
onReturnHome: () -> Unit = {},
onOpenSd: () -> Unit = {},
onOpenLogs: () -> Unit = {},
onOpenPermissions: () -> Unit = {},
@@ -333,17 +340,21 @@ fun SettingsScreen(
PlatformUi.showToast(I18n.t("clean_success"))
}
},
onClearImageCache = {
scope.launch(Dispatchers.IO) {
clearLlmpImageCache()
withContext(Dispatchers.Main) {
PlatformUi.showToast(I18n.t("clean_success"))
}
}
},
onClearUserData = {
scope.launch {
loadingMsg = I18n.t("exiting")
AppContainer.library.clearMenus()
AppContainer.library.clearLove()
AppContainer.library.clearHistory()
AppContainer.library.clearPlayList()
settings.dataVersion = ""
loadingMsg = I18n.t("resetting")
AppContainer.clearUserData()
loadingMsg = null
PlatformUi.showToast(I18n.t("user_data_cleared"))
PlatformUi.exitApp()
onReturnHome()
}
},
onDismiss = { showClearDialog = false },
@@ -462,7 +473,7 @@ fun TransferScreen(onBack: () -> Unit, onOpenScan: () -> Unit) {
LaunchedEffect(state.completed) {
if (state.completed && !wasCompleted) {
// 等本帧最后一次封面更新进入动画队列,再等轮播完整播放完毕。
delay(100)
delay(100.milliseconds)
snapshotFlow { coverAnimationsIdle }.first { it }
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 PrivacyShareUrl = "https://policy.zztfly.com/sdk/share/privacy"
private const val Privacy360Url = "https://jiagu.360.cn/#/global/help/322"
private const val PrivacyGithubUrl =
"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
fun PermissionsScreen(onBack: () -> Unit) {
val dark = LocalDarkTheme.current
@@ -1274,30 +1291,24 @@ fun PermissionsScreen(onBack: () -> Unit) {
val linkColor = ColorMs.Blue28B3F7
val bodySize = hSp(12f)
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 annotated = remember(bodyColor, bodySize, lang) {
val annotated = remember(bodySize, lang) {
buildAnnotatedString {
append(I18n.t("privacy_detail1"))
pushStringAnnotation(PrivacyLinkTag, PrivacyUmengUrl)
withStyle(linkStyle) { append(I18n.t("privacy_umeng")) }
pop()
withLink(privacyLink(PrivacyUmengUrl, linkStyles)) { append(I18n.t("privacy_umeng")) }
append(I18n.t("privacy_detail2"))
pushStringAnnotation(PrivacyLinkTag, PrivacyShareUrl)
withStyle(linkStyle) { append(I18n.t("privacy_share")) }
pop()
withLink(privacyLink(PrivacyShareUrl, linkStyles)) { append(I18n.t("privacy_share")) }
append(I18n.t("privacy_detail3"))
pushStringAnnotation(PrivacyLinkTag, Privacy360Url)
withStyle(linkStyle) { append(I18n.t("privacy_360")) }
pop()
withLink(privacyLink(Privacy360Url, linkStyles)) { append(I18n.t("privacy_360")) }
append(I18n.t("privacy_detail4"))
}
}
val githubLink = remember(bodySize, lang) {
buildAnnotatedString {
pushStringAnnotation(PrivacyLinkTag, PrivacyGithubUrl)
withStyle(linkStyle) { append(I18n.t("github_url")) }
pop()
withLink(privacyLink(PrivacyGithubUrl, linkStyles, title = "GitHub")) {
append(I18n.t("github_url"))
}
}
}
Scaffold(
@@ -1316,25 +1327,12 @@ fun PermissionsScreen(onBack: () -> Unit) {
.padding(horizontal = 12.dp)
.navigationBarsPadding(),
) {
ClickableText(
text = annotated,
style = bodyStyle,
onClick = { offset ->
annotated.getStringAnnotations(PrivacyLinkTag, offset, offset)
.firstOrNull()
?.let { PlatformUi.openWebView(it.item, I18n.t("privacy_policy")) }
},
)
Text(text = annotated, style = bodyStyle)
Spacer(Modifier.height(12.dp))
ClickableText(
Text(
text = githubLink,
style = bodyStyle.copy(textAlign = TextAlign.Center),
modifier = Modifier.fillMaxWidth(),
onClick = { offset ->
githubLink.getStringAnnotations(PrivacyLinkTag, offset, offset)
.firstOrNull()
?.let { PlatformUi.openWebView(it.item, "GitHub") }
},
)
Spacer(Modifier.height(12.dp))
}
@@ -1350,7 +1348,7 @@ fun SdScreen(onBack: () -> Unit) {
devices = AppContainer.listStorageDevices()
return@LaunchedEffect
}
delay(1_000)
delay(1.seconds)
devices = AppContainer.listStorageDevices()
}
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.theme.ColorMs
import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage
import kotlin.time.Duration.Companion.seconds
/**
* 启动页流程:
@@ -53,7 +54,7 @@ fun SplashScreen(onFinished: () -> Unit) {
LaunchedEffect(Unit) {
// 先显示 1 秒 Logo
delay(1_000)
delay(1.seconds)
count = 2
photoPath = runCatching {
AppContainer.splashPhoto.pickCachedDisplayPath()
@@ -61,7 +62,7 @@ fun SplashScreen(onFinished: () -> Unit) {
// 剩余倒计时(2 秒 → 0)后进入主页
while (count > 0) {
delay(1_000)
delay(1.seconds)
count -= 1
}
goHome()
+1 -1
View File
@@ -1,5 +1,5 @@
[versions]
agp = "9.3.1"
agp = "9.4.1"
android-compileSdk = "37"
android-minSdk = "24"
android-targetSdk = "37"
-12
View File
@@ -7,10 +7,8 @@
objects = {
/* Begin PBXBuildFile section */
02E790E4779E846963D3DC78 /* PipScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0480DE3601E8C37A2D4745EA /* PipScreenView.swift */; };
22EA1CB33A17DCC6FC82406D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F543015C899423D4B01CEB2E /* Assets.xcassets */; };
2D0291E964949C7C8D4DF324 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 88A292AA7607F14E8C66291E /* Assets.xcassets */; };
4701F6E8155C8E093C8CF81B /* GradientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8F846972E53BC33C1001D3 /* GradientView.swift */; };
56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */; };
5A902F589276392B4CBC0015 /* HomeWidgetExampleBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30942AA1A2E3A1AB832DD959 /* HomeWidgetExampleBundle.swift */; };
76ED3117C0B97CD5572EB808 /* HomeWidgetExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B1CCBD46108D407DF0C9A3 /* HomeWidgetExample.swift */; };
@@ -22,7 +20,6 @@
93FF51B22016695CB74F0AD6 /* HostBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B6B3C131D45A3E52B69B531 /* HostBridge.swift */; };
A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */; };
C8353E6C8BE98C27FD768A59 /* WidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E6AF7C77765E074EE1C7E597 /* WidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
EAC4398962E17AAE02F2DB68 /* black.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = E2D6A135D7E87F67C8285D07 /* black.mp4 */; };
F9A23E2F5F7514E5D6A3D9C0 /* BackgroundIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */; };
FA6C12E55F9D41237B4BDAF1 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310E847101B97B7A36776025 /* CarPlaySceneDelegate.swift */; };
FDE52B8D57A097A60A4CF366 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6C1F485E0CF8BB70C9DDD2 /* AppDelegate.swift */; };
@@ -60,12 +57,10 @@
0142951DE661E55DC3207327 /* WidgetAppUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetAppUtils.swift; sourceTree = "<group>"; };
A1B2C3D4E5F6789012345678 /* InAppWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewController.swift; sourceTree = "<group>"; };
B2C3D4E5F67890123456789 /* WebViewContentBlocker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewContentBlocker.swift; sourceTree = "<group>"; };
0480DE3601E8C37A2D4745EA /* PipScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PipScreenView.swift; sourceTree = "<group>"; };
1285E8A7B7E7359EA11B9591 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PipScreenManager.swift; sourceTree = "<group>"; };
30942AA1A2E3A1AB832DD959 /* HomeWidgetExampleBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetExampleBundle.swift; sourceTree = "<group>"; };
310E847101B97B7A36776025 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = "<group>"; };
4B8F846972E53BC33C1001D3 /* GradientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientView.swift; sourceTree = "<group>"; };
59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundIntent.swift; sourceTree = "<group>"; };
7B6B3C131D45A3E52B69B531 /* HostBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostBridge.swift; sourceTree = "<group>"; };
86038AE1032E284E79B32AD6 /* iosApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -73,7 +68,6 @@
8B6C1F485E0CF8BB70C9DDD2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
B8B1CCBD46108D407DF0C9A3 /* HomeWidgetExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetExample.swift; sourceTree = "<group>"; };
CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
E2D6A135D7E87F67C8285D07 /* black.mp4 */ = {isa = PBXFileReference; path = black.mp4; sourceTree = "<group>"; };
E6AF7C77765E074EE1C7E597 /* WidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
F543015C899423D4B01CEB2E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
D1E2F3A4B5C60718293A4B01 /* QqShareHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QqShareHost.swift; sourceTree = "<group>"; };
@@ -137,10 +131,7 @@
5B4BD3733F8124940306FE19 /* pip */ = {
isa = PBXGroup;
children = (
E2D6A135D7E87F67C8285D07 /* black.mp4 */,
4B8F846972E53BC33C1001D3 /* GradientView.swift */,
2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */,
0480DE3601E8C37A2D4745EA /* PipScreenView.swift */,
);
path = pip;
sourceTree = "<group>";
@@ -265,7 +256,6 @@
buildActionMask = 2147483647;
files = (
22EA1CB33A17DCC6FC82406D /* Assets.xcassets in Resources */,
EAC4398962E17AAE02F2DB68 /* black.mp4 in Resources */,
D1E2F3A4B5C60718293A4B06 /* TencentOpenApi_IOS_Bundle.bundle in Resources */,
I18N0PLIST0BUILD0FILE001 /* InfoPlist.strings in Resources */,
);
@@ -344,10 +334,8 @@
FDE52B8D57A097A60A4CF366 /* AppDelegate.swift in Sources */,
FA6C12E55F9D41237B4BDAF1 /* CarPlaySceneDelegate.swift in Sources */,
56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */,
4701F6E8155C8E093C8CF81B /* GradientView.swift in Sources */,
93FF51B22016695CB74F0AD6 /* HostBridge.swift in Sources */,
A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */,
02E790E4779E846963D3DC78 /* PipScreenView.swift in Sources */,
88ABACE0259DE83261737B99 /* SceneDelegate.swift in Sources */,
8070A4B4DD1D3DD823A8F923 /* WidgetAppUtils.swift in Sources */,
B7C8D9E0F1A234567890ABCD /* InAppWebViewController.swift in Sources */,
@@ -0,0 +1,9 @@
{
"id" : "97ba7140-7c52-4fdd-9ed8-e5a5fd628d17",
"targets" : [
{
"id" : "DD6B14F5-41C7-48A4-AA91-5E72470E1514",
"name" : "LLMP"
}
]
}
+31
View File
@@ -1,3 +1,4 @@
import AVFoundation
import UIKit
import WidgetKit
@@ -93,9 +94,39 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
guard let message = notification.userInfo?["message"] as? String else { return }
HostBridge.shared.showToast(message)
}
NotificationCenter.default.addObserver(
forName: Notification.Name("llmpPrepareAudioSession"),
object: nil,
queue: .main
) { [weak self] _ in
self?.activatePlaybackSession()
}
QqShareHost.shared.prepare()
}
/// iOS 27 起主线程同步 setActive 会报 Hang Risk,改走异步激活。
private func activatePlaybackSession() {
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playback)
if #available(iOS 27.0, *) {
session.activate(options: []) { activated, error in
DispatchQueue.main.async {
NotificationCenter.default.post(
name: Notification.Name("llmpAudioSessionReady"),
object: nil,
userInfo: ["ok": activated && error == nil]
)
}
}
} else {
try? session.setActive(true)
NotificationCenter.default.post(
name: Notification.Name("llmpAudioSessionReady"),
object: nil
)
}
}
func application(
_ app: UIApplication,
open url: URL,
+1 -1
View File
@@ -173,6 +173,6 @@
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<true/>
</dict>
</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) {
// 前台不显示桌面歌词:关掉 PiP,但播放中仍保持 dummy,方便下次进后台自动弹出。
// 前台不显示桌面歌词。开关仍开着时,下次进后台再弹出。
pipManager.stopPictureInPictureOnly()
let defaults = UserDefaults(suiteName: "group.com.zhushenwudi.lovelivemusicplayer")
let playing = defaults?.bool(forKey: "pipIsPlaying") ?? false
@@ -86,7 +97,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
self.pipManager.manualChangePicInPic(needStart: false)
}
case "show":
self.pipManager.picInPicAutoOpen(nil)
self.pipManager.manualChangePicInPic(needStart: true)
case "hide":
self.pipManager.stopPictureInPictureOnly()
-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()
}
}
+281 -179
View File
@@ -1,211 +1,83 @@
import AVFoundation
import AVKit
import CoreMedia
import CoreVideo
import UIKit
@MainActor
class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDelegate {
// 4:1 能保持最大宽度,同时比 5:1 的歌词条更接近原来的高度。
private static let pipAspectRatio: CGFloat = 2000.0 / 500.0
class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDelegate, AVPictureInPictureSampleBufferPlaybackDelegate {
private static let renderSize = CGSize(width: 800, height: 200)
private var firstWindow: UIWindow?
private var pipController: AVPictureInPictureController?
private let sourceView = UIView()
private var sourceHeightConstraint: NSLayoutConstraint?
private var picInPicView = PipScreenView()
private var isOpenPicInPic = false
private var suspectedWindows: [UIWindow] = []
private let sampleHost = SampleBufferHostView()
private var didPrepare = false
override init() {
super.init()
isOpenPicInPic = false
}
deinit {
NotificationCenter.default.removeObserver(self)
}
private var wantsPip = false
private var isOpenPicInPic = false
private var isStartingPip = false
private var timebase: CMTimebase?
private var line1 = ""
private var line2 = ""
private var currentLine = -1
func addScreenView(on view: UIView) {
if sourceView.superview !== view {
guard sourceView.superview !== view else {
if !didPrepare { preparePicInPic() }
return
}
sourceView.removeFromSuperview()
sourceView.backgroundColor = .clear
sourceView.isUserInteractionEnabled = false
sourceView.clipsToBounds = true
sourceView.translatesAutoresizingMaskIntoConstraints = false
// 放在 UIWindow 最底层:仍处于可视区域,可触发自动 PiP,但不会盖住 App。
view.insertSubview(sourceView, at: 0)
let height = sourceView.heightAnchor.constraint(
equalTo: sourceView.widthAnchor,
multiplier: 1.0 / Self.pipAspectRatio
)
sourceHeightConstraint = height
view.addSubview(sourceView)
sampleHost.translatesAutoresizingMaskIntoConstraints = false
sampleHost.isUserInteractionEnabled = false
sourceView.addSubview(sampleHost)
NSLayoutConstraint.activate([
sourceView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
sourceView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
sourceView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
height,
sourceView.heightAnchor.constraint(equalToConstant: 1),
sampleHost.leadingAnchor.constraint(equalTo: sourceView.leadingAnchor),
sampleHost.trailingAnchor.constraint(equalTo: sourceView.trailingAnchor),
sampleHost.topAnchor.constraint(equalTo: sourceView.topAnchor),
sampleHost.bottomAnchor.constraint(equalTo: sourceView.bottomAnchor),
])
}
view.layoutIfNeeded()
if !didPrepare {
preparePicInPic(on: sourceView)
} else {
pipController?.playerLayer.frame = sourceView.bounds
}
if !didPrepare { preparePicInPic() }
}
func removeScreenView() {
wantsPip = false
sourceView.removeFromSuperview()
sourceHeightConstraint = nil
NotificationCenter.default.removeObserver(self)
suspectedWindows.removeAll()
}
private func preparePicInPic(on view: UIView) {
do {
try AVAudioSession.sharedInstance().setCategory(.playback)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("Error setting up audio session: \(error)")
}
guard let url = Bundle.main.url(forResource: "black", withExtension: "mp4") else {
print("Missing black.mp4 in app bundle — add from Flutter ios/Runner or create a 1-frame black video.")
return
}
let item = AVPlayerItem(asset: AVAsset(url: url))
let player = AVPlayer(playerItem: item)
let playerLayer = AVPlayerLayer(player: player)
view.layoutIfNeeded()
playerLayer.frame = view.bounds
playerLayer.backgroundColor = UIColor.black.cgColor
playerLayer.videoGravity = .resizeAspectFill
view.layer.insertSublayer(playerLayer, at: 0)
pipController = AVPictureInPictureController(playerLayer: playerLayer)
if #available(iOS 14.0, *) {
pipController?.requiresLinearPlayback = true
}
pipController?.setValue(1, forKey: "controlsStyle")
pipController?.delegate = self
if #available(iOS 14.2, *) {
pipController?.canStartPictureInPictureAutomaticallyFromInline = true
}
didPrepare = true
NotificationCenter.default.addObserver(
self,
selector: #selector(playerItemDidReachEnd),
name: .AVPlayerItemDidPlayToEndTime,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidBecomeVisible),
name: UIWindow.didBecomeVisibleNotification,
object: nil
)
for name in [
UIApplication.didEnterBackgroundNotification,
UIApplication.willEnterForegroundNotification,
UIApplication.didBecomeActiveNotification,
UIApplication.protectedDataDidBecomeAvailableNotification,
UIApplication.significantTimeChangeNotification,
] {
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationLifecycleDidChange),
name: name,
object: nil
)
}
}
func picInPicAutoOpen(_ isOpen: Bool?) {
if let isOpen { isOpenPicInPic = isOpen }
let isPlayingNow = pipController?.playerLayer.player?.timeControlStatus == .playing
if isOpenPicInPic {
if !isPlayingNow { pipController?.playerLayer.player?.play() }
} else if isPlayingNow {
pipController?.playerLayer.player?.pause()
}
if isOpenPicInPic { enqueueCurrentFrame() }
}
@objc func updatePipScreenView(lyricLine1: String?, lyricLine2: String?, currentLine: Int) {
picInPicView.updateContent(lyricLine1: lyricLine1, lyricLine2: lyricLine2, currentLine: currentLine)
}
@objc private func windowDidBecomeVisible(notification: Notification) {
guard let object = notification.object else { return }
if String(describing: type(of: object)) == "PGHostedWindow" {
firstWindow = notification.object as? UIWindow
NotificationCenter.default.removeObserver(self, name: UIWindow.didBecomeVisibleNotification, object: nil)
restartPipRendering()
} else if let targetWindow = object as? UIWindow {
suspectedWindows.append(targetWindow)
}
}
@objc private func applicationLifecycleDidChange(_ notification: Notification) {
restartPipRendering()
}
private func restartPipRendering() {
if isOpenPicInPic,
pipController?.playerLayer.player?.timeControlStatus != .playing
{
pipController?.playerLayer.player?.play()
}
picInPicView.setNeedsLayout()
picInPicView.layoutIfNeeded()
picInPicView.gradientLayer.restartAnimation()
// 解锁后 PGHostedWindow 会异步恢复,再补一次以避开系统重建窗口的时序。
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 300_000_000)
guard let self else { return }
self.picInPicView.setNeedsLayout()
self.picInPicView.layoutIfNeeded()
self.picInPicView.gradientLayer.restartAnimation()
}
}
private func filterTargetWindow() -> UIWindow? {
for window in suspectedWindows where String(describing: type(of: window)) == "PGHostedWindow" {
return window
}
for window in suspectedWindows where window.windowLevel == UIWindow.Level(rawValue: -10_000_000) {
return window
}
for window in suspectedWindows where window.frame.size.height < 300 {
return window
}
return suspectedWindows.first
}
@objc private func playerItemDidReachEnd(notification: Notification) {
guard let item = notification.object as? AVPlayerItem,
item === pipController?.playerLayer.player?.currentItem
else { return }
pipController?.playerLayer.player?.seek(to: .zero)
pipController?.playerLayer.player?.play()
if let lyricLine1 { line1 = lyricLine1 }
if let lyricLine2 { line2 = lyricLine2 }
self.currentLine = currentLine
enqueueCurrentFrame()
}
func manualChangePicInPic(needStart: Bool) {
if needStart {
pipController?.playerLayer.player?.play()
if pipController?.isPictureInPictureActive == false {
pipController?.startPictureInPicture()
}
wantsPip = true
isOpenPicInPic = true
pipController?.invalidatePlaybackState()
startPipIfPossible(retries: 8)
} else {
stopPictureInPictureOnly()
pipController?.playerLayer.player?.pause()
}
}
/// 只关 PiP,dummy player 继续播,方便下次进后台自动弹出。
func stopPictureInPictureOnly() {
wantsPip = false
if pipController?.isPictureInPictureActive == true {
pipController?.stopPictureInPicture()
}
@@ -214,20 +86,250 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe
func pictureInPictureControllerDidStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
if firstWindow == nil {
firstWindow = filterTargetWindow()
suspectedWindows.removeAll()
isStartingPip = false
if !wantsPip {
pictureInPictureController.stopPictureInPicture()
return
}
if let firstWindow {
firstWindow.addSubview(picInPicView)
picInPicView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
picInPicView.topAnchor.constraint(equalTo: firstWindow.topAnchor),
picInPicView.leadingAnchor.constraint(equalTo: firstWindow.leadingAnchor),
picInPicView.trailingAnchor.constraint(equalTo: firstWindow.trailingAnchor),
picInPicView.bottomAnchor.constraint(equalTo: firstWindow.bottomAnchor),
])
enqueueCurrentFrame()
}
restartPipRendering()
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
failedToStartPictureInPictureWithError error: Error
) {
isStartingPip = false
print("PiP failed to start: \(error)")
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
setPlaying playing: Bool
) {
if playing { enqueueCurrentFrame() }
}
func pictureInPictureControllerTimeRangeForPlayback(
_ pictureInPictureController: AVPictureInPictureController
) -> CMTimeRange {
CMTimeRange(start: .zero, duration: .positiveInfinity)
}
func pictureInPictureControllerIsPlaybackPaused(
_ pictureInPictureController: AVPictureInPictureController
) -> Bool {
!isOpenPicInPic
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
didTransitionToRenderSize newRenderSize: CMVideoDimensions
) {
enqueueCurrentFrame()
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
skipByInterval skipInterval: CMTime,
completion completionHandler: @escaping () -> Void
) {
completionHandler()
}
private func preparePicInPic() {
let layer = sampleHost.sampleLayer
layer.videoGravity = .resizeAspect
layer.backgroundColor = UIColor.black.cgColor
if let timebase = makeHostTimebase() {
self.timebase = timebase
layer.controlTimebase = timebase
}
enqueueCurrentFrame()
let controller = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(
sampleBufferDisplayLayer: layer,
playbackDelegate: self
))
controller.requiresLinearPlayback = true
controller.setValue(1, forKey: "controlsStyle")
controller.delegate = self
// 自动开启会和 sceneWillResignActive 里的手动开启抢同一次系统连接,快速上滑时连接会被拆掉。
controller.canStartPictureInPictureAutomaticallyFromInline = false
pipController = controller
didPrepare = true
}
/// 必须在还没进后台时同步调用。启动过程中再调一次会拆掉系统连接。
private func startPipIfPossible(retries: Int) {
guard wantsPip, let pip = pipController else { return }
if pip.isPictureInPictureActive || isStartingPip { return }
guard UIApplication.shared.applicationState != .background else { return }
guard pip.isPictureInPicturePossible else {
if retries > 0 {
DispatchQueue.main.async { [weak self] in
self?.startPipIfPossible(retries: retries - 1)
}
} else {
print("PiP not possible after retries")
}
return
}
isStartingPip = true
pip.startPictureInPicture()
}
private func enqueueCurrentFrame() {
if isStartingPip { return }
guard didPrepare || sampleHost.superview != nil else { return }
guard let pixelBuffer = makePixelBuffer(size: Self.renderSize) else { return }
CVPixelBufferLockBaseAddress(pixelBuffer, [])
if let context = CGContext(
data: CVPixelBufferGetBaseAddress(pixelBuffer),
width: CVPixelBufferGetWidth(pixelBuffer),
height: CVPixelBufferGetHeight(pixelBuffer),
bitsPerComponent: 8,
bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer),
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue
) {
let height = CGFloat(CVPixelBufferGetHeight(pixelBuffer))
let width = CGFloat(CVPixelBufferGetWidth(pixelBuffer))
context.translateBy(x: 0, y: height)
context.scaleBy(x: 1, y: -1)
context.scaleBy(x: width / Self.renderSize.width, y: height / Self.renderSize.height)
drawLyrics(in: context, size: Self.renderSize)
}
CVPixelBufferUnlockBaseAddress(pixelBuffer, [])
let pts = timebase.map { CMTimebaseGetTime($0) } ?? .zero
guard let sample = makeSampleBuffer(from: pixelBuffer, pts: pts) else { return }
markDisplayImmediately(sample)
let layer = sampleHost.sampleLayer
if layer.status == .failed {
layer.flush()
}
guard layer.isReadyForMoreMediaData else { return }
layer.enqueue(sample)
}
private func drawLyrics(in context: CGContext, size: CGSize) {
UIGraphicsPushContext(context)
UIColor.black.setFill()
UIRectFill(CGRect(origin: .zero, size: size))
let font = UIFont.boldSystemFont(ofSize: 34)
let style = NSMutableParagraphStyle()
style.lineBreakMode = .byTruncatingTail
let primary = UIColor.white
let dim = UIColor.white.withAlphaComponent(0.45)
let textWidth = size.width - 48
if currentLine == -1 {
let shown = line1.isEmpty ? " " : line1
(shown as NSString).draw(
in: CGRect(x: 24, y: (size.height - 48) / 2, width: textWidth, height: 48),
withAttributes: [.font: font, .foregroundColor: primary, .paragraphStyle: style]
)
} else {
(line1 as NSString).draw(
in: CGRect(x: 24, y: 36, width: textWidth, height: 56),
withAttributes: [
.font: font,
.foregroundColor: currentLine == 2 ? dim : primary,
.paragraphStyle: style,
]
)
(line2 as NSString).draw(
in: CGRect(x: 24, y: 108, width: textWidth, height: 56),
withAttributes: [
.font: font,
.foregroundColor: currentLine == 1 ? dim : primary,
.paragraphStyle: style,
]
)
}
UIGraphicsPopContext()
}
private func markDisplayImmediately(_ sample: CMSampleBuffer) {
guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: true),
CFArrayGetCount(attachments) > 0 else { return }
let dict = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self)
CFDictionarySetValue(
dict,
Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately as NSString).toOpaque(),
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque()
)
}
private func makeHostTimebase() -> CMTimebase? {
var timebase: CMTimebase?
let status = CMTimebaseCreateWithSourceClock(
allocator: kCFAllocatorDefault,
sourceClock: CMClockGetHostTimeClock(),
timebaseOut: &timebase
)
guard status == noErr, let timebase else { return nil }
CMTimebaseSetTime(timebase, time: .zero)
CMTimebaseSetRate(timebase, rate: 1)
return timebase
}
private func makePixelBuffer(size: CGSize) -> CVPixelBuffer? {
let width = evenPixel(size.width)
let height = evenPixel(size.height)
guard width > 0, height > 0 else { return nil }
let attrs: [CFString: Any] = [
kCVPixelBufferCGImageCompatibilityKey: true,
kCVPixelBufferCGBitmapContextCompatibilityKey: true,
kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
]
var pixelBuffer: CVPixelBuffer?
let status = CVPixelBufferCreate(
kCFAllocatorDefault,
width,
height,
kCVPixelFormatType_32BGRA,
attrs as CFDictionary,
&pixelBuffer
)
guard status == kCVReturnSuccess else { return nil }
return pixelBuffer
}
private func makeSampleBuffer(from pixelBuffer: CVPixelBuffer, pts: CMTime) -> CMSampleBuffer? {
var format: CMVideoFormatDescription?
let formatStatus = CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescriptionOut: &format
)
guard formatStatus == noErr, let format else { return nil }
var timing = CMSampleTimingInfo(
duration: CMTime(value: 1, timescale: 30),
presentationTimeStamp: pts,
decodeTimeStamp: .invalid
)
var sample: CMSampleBuffer?
let status = CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescription: format,
sampleTiming: &timing,
sampleBufferOut: &sample
)
guard status == noErr else { return nil }
return sample
}
private func evenPixel(_ value: CGFloat) -> Int {
let rounded = max(Int(value.rounded()), 2)
return rounded.isMultiple(of: 2) ? rounded : rounded - 1
}
}
private final class SampleBufferHostView: UIView {
override class var layerClass: AnyClass { AVSampleBufferDisplayLayer.self }
var sampleLayer: AVSampleBufferDisplayLayer {
layer as! AVSampleBufferDisplayLayer
}
}
-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 java.io.File
import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
actual class PlayerController(context: Context) {
private val appContext = context.applicationContext
@@ -114,7 +115,7 @@ actual class PlayerController(context: Context) {
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",
"fetch_songs_fail_or_net" to "Datenfehler oder Netzwerkfehler",
"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",
"clear_image_cache" to "Bildcache löschen",
"remove_user_data_kmp" to "Benutzerdaten löschen",
"confirm_delete_from_menu_short" to "Aus Playlist löschen?",
"play_text" to "Spielen,Pausierte",
@@ -284,8 +284,10 @@ internal val EnStrings: Map<String, String> = mapOf(
"now_playing_song" to "Now Playing: %s",
"fetch_songs_fail_or_net" to "Data error or network failure",
"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",
"clear_image_cache" to "Clear image cache",
"remove_user_data_kmp" to "Clear user data",
"confirm_delete_from_menu_short" to "Delete from playlist?",
"play_text" to "Playing,Paused",
@@ -284,8 +284,10 @@ internal val JaStrings: Map<String, String> = mapOf(
"now_playing_song" to "再生中:%s",
"fetch_songs_fail_or_net" 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 "曲データを削除",
"clear_image_cache" to "画像キャッシュを削除",
"remove_user_data_kmp" to "ユーザーデータを削除",
"confirm_delete_from_menu_short" to "プレイリストから削除しますか?",
"play_text" to "放送中,停止しました",
@@ -284,8 +284,10 @@ internal val ZhStrings: Map<String, String> = mapOf(
"now_playing_song" to "正在播放:%s",
"fetch_songs_fail_or_net" 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 "清除歌曲数据",
"clear_image_cache" to "清除图片缓存",
"remove_user_data_kmp" to "清除用户数据",
"confirm_delete_from_menu_short" to "确认从歌单删除?",
"play_text" to "播放中,已暂停",
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
class SleepTimer(
private val scope: CoroutineScope,
@@ -23,7 +24,7 @@ class SleepTimer(
_remainingMs.value = left
job = scope.launch {
while (left > 0) {
delay(1_000)
delay(1.seconds)
left -= 1_000
_remainingMs.value = left.coerceAtLeast(0)
}
@@ -1,6 +1,8 @@
package com.zhushenwudi.lovelivemusicplayer.player
import kotlinx.cinterop.CValue
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.useContents
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -12,7 +14,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import platform.AVFAudio.AVAudioSession
import platform.AVFAudio.AVAudioSessionCategoryPlayback
import platform.AVFAudio.AVAudioSessionInterruptionNotification
import platform.AVFAudio.AVAudioSessionInterruptionOptionKey
import platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume
@@ -22,7 +23,6 @@ import platform.AVFAudio.AVAudioSessionInterruptionTypeKey
import platform.AVFAudio.AVAudioSessionRouteChangeNotification
import platform.AVFAudio.AVAudioSessionRouteChangeReasonKey
import platform.AVFAudio.AVAudioSessionRouteChangeReasonOldDeviceUnavailable
import platform.AVFAudio.setActive
import platform.AVFoundation.AVPlayer
import platform.AVFoundation.AVPlayerItem
import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification
@@ -40,9 +40,13 @@ import platform.AVFoundation.rate
import platform.AVFoundation.replaceCurrentItemWithPlayerItem
import platform.AVFoundation.resourceLoader
import platform.AVFoundation.seekToTime
import platform.CoreGraphics.CGRectMake
import platform.CoreGraphics.CGSize
import platform.CoreGraphics.CGSizeMake
import platform.CoreMedia.CMTimeGetSeconds
import platform.CoreMedia.CMTimeMake
import platform.Foundation.NSBundle
import platform.Foundation.NSThread
import platform.Foundation.NSError
import platform.Foundation.NSMutableURLRequest
import platform.Foundation.NSNotification
@@ -69,6 +73,9 @@ import platform.MediaPlayer.MPRemoteCommandHandlerStatus
import platform.MediaPlayer.MPRemoteCommandHandlerStatusCommandFailed
import platform.MediaPlayer.MPRemoteCommandHandlerStatusSuccess
import platform.MediaPlayer.MPSeekCommandEvent
import platform.UIKit.UIGraphicsBeginImageContextWithOptions
import platform.UIKit.UIGraphicsEndImageContext
import platform.UIKit.UIGraphicsGetImageFromCurrentImageContext
import platform.UIKit.UIImage
import platform.darwin.dispatch_async
import platform.darwin.dispatch_get_main_queue
@@ -122,6 +129,9 @@ actual class PlayerController {
private var lastEndMark: TimeSource.Monotonic.ValueTimeMark? = null
private var audioReady = false
private var audioActive = false
private var playWhenReady = false
private var audioSessionReadyObserver: Any? = null
/** CarPlay / 锁屏 Now Playing 标题用的当前歌词;null 表示显示歌名。 */
private var nowPlayingLyric: String? = null
@@ -134,18 +144,50 @@ actual class PlayerController {
observePlaybackFailed()
observeAudioRouteChanges()
observeAudioInterruptions()
observeAudioSessionReady()
}
private fun ensureAudioReady() {
if (audioReady) return
if (audioActive || audioReady) return
audioReady = true
runCatching {
val session = AVAudioSession.sharedInstance()
session.setCategory(AVAudioSessionCategoryPlayback, error = null)
session.setActive(true, error = null)
}.onFailure { AppLog.e(TAG, "ensureAudioReady session fail", it) }
runCatching { setupRemoteCommands() }
.onFailure { AppLog.e(TAG, "ensureAudioReady remoteCommands fail", it) }
NSNotificationCenter.defaultCenter.postNotificationName(
aName = "llmpPrepareAudioSession",
`object` = null,
userInfo = null,
)
}
private fun observeAudioSessionReady() {
audioSessionReadyObserver = NSNotificationCenter.defaultCenter.addObserverForName(
name = "llmpAudioSessionReady",
`object` = null,
queue = null,
) { notification: NSNotification? ->
val ok = (notification?.userInfo?.get("ok") as? NSNumber)?.boolValue ?: true
dispatch_async(dispatch_get_main_queue()) {
if (!ok) {
audioReady = false
playWhenReady = false
AppLog.e(TAG, "audio session activate failed")
if (_state.value.isPlaying) {
player.pause()
_state.update { it.copy(isPlaying = false) }
}
return@dispatch_async
}
audioActive = true
if (playWhenReady && _state.value.isPlaying) {
playWhenReady = false
player.play()
startPositionUpdates()
updateNowPlayingInfo()
} else {
playWhenReady = false
}
}
}
}
private fun setupRemoteCommands() {
@@ -261,7 +303,7 @@ actual class PlayerController {
queue = null,
) { notification: NSNotification? ->
val ended = notification?.`object` as? AVPlayerItem
// 桌面歌词 dummy black.mp4 也会发 DidPlayToEnd;只处理本控制器的音乐 item。
// 只处理本控制器的音乐 item。
// Kotlin/Native 的 === 对 ObjC 对象包装器不可靠,须用 isEqual 比较指针。
if (ended == null || !isMusicItem(ended)) {
return@addObserverForName
@@ -455,6 +497,10 @@ actual class PlayerController {
* 封面 + 专辑信息启用灵动岛媒体胶囊。
*/
private fun updateNowPlayingInfo() {
if (!NSThread.isMainThread) {
dispatch_async(dispatch_get_main_queue()) { updateNowPlayingInfo() }
return
}
val s = _state.value
val current = s.current ?: return
val songName = current.musicName ?: return
@@ -502,12 +548,32 @@ actual class PlayerController {
else -> raw
}
val image = UIImage.imageWithContentsOfFile(path) ?: return null
val artwork = MPMediaItemArtwork(boundsSize = image.size) { _ -> image }
val artwork = coverArtwork(image)
cachedArtworkMusicId = id
cachedArtwork = 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) {
if (musicId == coverFetchingMusicId) return
if (musicId == cachedArtworkMusicId && cachedArtwork != null) return
@@ -537,7 +603,7 @@ actual class PlayerController {
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
cachedArtwork = artwork
updateNowPlayingInfo()
@@ -581,17 +647,24 @@ actual class PlayerController {
ensureAudioReady()
val current = _state.value.current
if (loadedUri.isNullOrBlank() && current != null) {
playWhenReady = false
AppLog.e(TAG, "play() abort reason=noLoadedUri id=${current.musicId}")
_state.update { it.copy(isPlaying = false, error = "No playable URI") }
return
}
player.play()
_state.update { it.copy(isPlaying = true, error = null) }
if (audioActive) {
player.play()
startPositionUpdates()
updateNowPlayingInfo()
} else {
playWhenReady = true
updateNowPlayingInfo()
}
}
actual fun pause() {
playWhenReady = false
resumeAfterInterruption = false
endScrub()
player.pause()
@@ -875,8 +948,12 @@ actual class PlayerController {
watchItemStatus(item, path, music)
if (autoPlay) {
ensureAudioReady()
if (audioActive) {
player.play()
startPositionUpdates()
} else {
playWhenReady = true
}
} else {
player.pause()
positionJob?.cancel()