fix photo cache and setting config

This commit is contained in:
2026-09-26 16:42:24 +08:00
parent 284b9cc028
commit a046790ab0
20 changed files with 243 additions and 145 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"
+1 -1
View File
@@ -173,6 +173,6 @@
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<true/>
</dict>
</plist>
@@ -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
@@ -40,9 +42,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 +75,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
@@ -455,6 +464,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 +515,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 +570,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()