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