From 43e9e1b916641e54abc07fdc5bec23eb1a5e137a Mon Sep 17 00:00:00 2001 From: zhushenwudi <55681140@163.com> Date: Sat, 26 Sep 2026 09:49:56 +0800 Subject: [PATCH] fix moegirl ad blocker and optimize func --- .../lovelivemusicplayer/MainActivity.kt | 27 ++ .../native/WebViewActivity.kt | 34 ++- .../ui/theme/StartupWindowMetrics.android.kt | 52 ++++ .../lovelivemusicplayer/AppContainer.kt | 33 ++- .../lovelivemusicplayer/ui/MainShell.kt | 115 ++++++--- .../ui/player/MiniPlayerBar.kt | 102 +++++++- .../ui/screens/FeatureScreens.kt | 12 +- .../ui/theme/DesignDensity.kt | 98 +++++-- .../ui/theme/StartupWindowMetrics.ios.kt | 6 + iosApp/iosApp/InAppWebViewController.swift | 13 +- iosApp/iosApp/WebViewContentBlocker.swift | 12 +- .../player/AndroidPlayerHolder.kt | 5 +- .../db/LibraryRepository.kt | 35 ++- .../platform/WebViewAdBlock.kt | 240 ++++-------------- .../lovelivemusicplayer/player/SleepTimer.kt | 2 +- .../lovelivemusicplayer/util/PathResolver.kt | 11 +- .../player/PlayerController.ios.kt | 55 +++- 17 files changed, 550 insertions(+), 302 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/theme/StartupWindowMetrics.android.kt create mode 100644 composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/theme/StartupWindowMetrics.ios.kt diff --git a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/MainActivity.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/MainActivity.kt index 2673c69..200d434 100644 --- a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/MainActivity.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/MainActivity.kt @@ -6,12 +6,16 @@ import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.provider.Settings +import android.view.View +import android.view.ViewTreeObserver import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.edit +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import com.zhushenwudi.lovelivemusicplayer.db.DriverFactory import com.zhushenwudi.lovelivemusicplayer.db.createDatabase import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidShareBridge @@ -66,9 +70,32 @@ class MainActivity : ComponentActivity() { requestNotifyPermission() startPlaybackService() + // 系统栏 inset 和窗口高度到位之前不画出第一帧,避免顶栏先铺进状态栏再缩回。 + holdDrawUntilSystemBarsReady() setContent { App() } } + private fun holdDrawUntilSystemBarsReady() { + val content = findViewById(android.R.id.content) ?: return + content.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { + private var skips = 0 + + override fun onPreDraw(): Boolean { + val top = ViewCompat.getRootWindowInsets(content) + ?.getInsets(WindowInsetsCompat.Type.statusBars()) + ?.top ?: 0 + val ready = skips >= 3 || (content.height > 0 && top > 0) + if (ready) { + content.viewTreeObserver.removeOnPreDrawListener(this) + return true + } + skips++ + content.postOnAnimation { content.invalidate() } + return false + } + }) + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) diff --git a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/WebViewActivity.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/WebViewActivity.kt index 36dfa65..c6ba29a 100644 --- a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/WebViewActivity.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/WebViewActivity.kt @@ -11,6 +11,7 @@ import android.webkit.WebViewClient import androidx.activity.ComponentActivity import androidx.activity.addCallback import androidx.activity.compose.setContent +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward @@ -28,16 +30,21 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.WindowCompat import com.zhushenwudi.lovelivemusicplayer.i18n.tr import com.zhushenwudi.lovelivemusicplayer.platform.WebViewAdBlock import java.io.ByteArrayInputStream +import kotlinx.coroutines.delay class WebViewActivity : ComponentActivity() { private var webView: WebView? = null @@ -48,6 +55,7 @@ class WebViewActivity : ComponentActivity() { @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) val url = intent.getStringExtra(EXTRA_URL).orEmpty() val title = intent.getStringExtra(EXTRA_TITLE) title?.let { setTitle(it) } @@ -67,7 +75,22 @@ class WebViewActivity : ComponentActivity() { val progress by pageProgress MaterialTheme { - Column(Modifier.fillMaxSize()) { + SideEffect { + WindowCompat.getInsetsController(window, window.decorView) + .isAppearanceLightStatusBars = true + } + LaunchedEffect(Unit) { + while (true) { + delay(1_000) + webView?.evaluateJavascript(WebViewAdBlock.domCleanupScript(), null) + } + } + Column( + Modifier + .fillMaxSize() + .background(Color.White) + .statusBarsPadding(), + ) { Box( modifier = Modifier .weight(1f) @@ -162,8 +185,8 @@ class WebViewActivity : ComponentActivity() { view: WebView?, request: WebResourceRequest?, ): WebResourceResponse? { - val host = request?.url?.host - if (!host.isNullOrBlank() && WebViewAdBlock.isBlockedHost(host)) { + val target = request?.url?.toString().orEmpty() + if (WebViewAdBlock.isBlockedUrl(target)) { return WebResourceResponse( "text/plain", "utf-8", @@ -180,8 +203,9 @@ class WebViewActivity : ComponentActivity() { override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) - if (view != null && !url.isNullOrBlank()) { - view.evaluateJavascript(WebViewAdBlock.injectionScript(url), null) + if (view != null) { + view.evaluateJavascript(WebViewAdBlock.cosmeticStyleScript(), null) + view.evaluateJavascript(WebViewAdBlock.domCleanupScript(), null) } onNavigationChanged() } diff --git a/composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/theme/StartupWindowMetrics.android.kt b/composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/theme/StartupWindowMetrics.android.kt new file mode 100644 index 0000000..5e71aa2 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/theme/StartupWindowMetrics.android.kt @@ -0,0 +1,52 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.theme + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.os.Build +import android.view.WindowInsets +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat + +@Composable +internal actual fun rememberStartupWindowMetrics(): StartupWindowMetrics { + val view = LocalView.current + return remember(view) { view.context.readStartupWindowMetrics() } +} + +private fun Context.readStartupWindowMetrics(): StartupWindowMetrics { + val activity = findActivity() ?: return StartupWindowMetrics() + if (Build.VERSION.SDK_INT >= 30) { + val metrics = activity.windowManager.currentWindowMetrics + val mask = WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars() + val bars = metrics.windowInsets.getInsets(mask) + return StartupWindowMetrics( + heightPx = metrics.bounds.height(), + statusTopPx = bars.top, + navBottomPx = bars.bottom, + ) + } + val root = ViewCompat.getRootWindowInsets(activity.window.decorView) + val statusTop = root?.getInsets(WindowInsetsCompat.Type.statusBars())?.top + ?: activity.statusBarFallbackPx() + val navBottom = root?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0 + return StartupWindowMetrics( + heightPx = activity.resources.displayMetrics.heightPixels, + statusTopPx = statusTop, + navBottomPx = navBottom, + ) +} + +private fun Activity.statusBarFallbackPx(): Int { + val id = resources.getIdentifier("status_bar_height", "dimen", "android") + return if (id > 0) resources.getDimensionPixelSize(id) else 0 +} + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt index 6ab13c2..2b588c6 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import com.zhushenwudi.lovelivemusicplayer.AppContainer.scope import com.zhushenwudi.lovelivemusicplayer.data.DataImportService import com.zhushenwudi.lovelivemusicplayer.db.LibraryRepository @@ -148,7 +149,6 @@ object AppContainer { // Android 通知封面用 file:// / https;iOS Now Playing 优先绝对路径。 player.resolveCoverUri = { music -> pathResolver.absoluteCoverPath(music) - ?.takeIf { fileSystem.exists(it) } ?: pathResolver.coverDisplayUri(music) } @@ -181,7 +181,9 @@ object AppContainer { this.dataSync = DataSyncService(WsClient(http), library, scope) this.updateChecker = UpdateChecker(ossApi) this.sleepTimer = SleepTimer(scope) { - player.pause() + withContext(Dispatchers.Main) { + runCatching { player.pause() } + } } this.splashPhoto = SplashPhotoService(fileSystem, ossApi, scope) @@ -195,7 +197,6 @@ object AppContainer { persistPlayQueue() } } - refreshCarPlayCatalog() PlatformServices.desktopLyric.pipAutoOpen(settings.enableDesktopLyric) PlatformServices.carPlay.bind(PlatformServices.carPlayCatalog) bindCarPlayNowPlaying() @@ -206,6 +207,11 @@ object AppContainer { bindHistoryOnTrackChange() initSdStorage() bindUsbMountListener() + // 全表快照放后台,并避开主页出来后的首屏解码。 + scope.launch { + delay(2_000) + refreshCarPlayCatalog() + } } /** @@ -467,6 +473,8 @@ object AppContainer { private fun bindCarPlayNowPlaying() { carPlayNowPlayingJob?.cancel() carPlayNowPlayingJob = scope.launch { + var recentKey: Pair? = null + var skipStartupRecent = true player.state .map { listOf( @@ -489,11 +497,20 @@ object AppContainer { favorite = current?.isLove == true, loopMode = player.state.value.loopMode, ) - val recent = library.findAllListByGroup( - group = "all", - enableHttp = settings.enableHttp, - ).recent.withSortOrder(settings.recentSortOrder) - PlatformServices.carPlayCatalog.updateRecent(recent, cover) + val key = current?.musicId to (current?.isLove == true) + if (key != recentKey) { + recentKey = key + // 冷启动这一次交给延后的曲库快照,避免和首屏抢数据库。 + if (skipStartupRecent) { + skipStartupRecent = false + } else { + val recent = library.recentVisible( + group = "all", + enableHttp = settings.enableHttp, + ).withSortOrder(settings.recentSortOrder) + PlatformServices.carPlayCatalog.updateRecent(recent, cover) + } + } PlatformServices.carPlay.refreshNowPlaying() } } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/MainShell.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/MainShell.kt index 2ed8117..cc41ae5 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/MainShell.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/MainShell.kt @@ -48,9 +48,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.min import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import com.zhushenwudi.lovelivemusicplayer.AppContainer import com.zhushenwudi.lovelivemusicplayer.Const import com.zhushenwudi.lovelivemusicplayer.domain.Album @@ -145,8 +148,9 @@ fun MainShell( val section = if (topTabIndex == 0) TopSection.LIBRARY else TopSection.MINE val weSlide = AppContainer.weSlide val weSlideFooter = AppContainer.weSlideFooter - // 迷你播放器毛玻璃的 Haze 背景源 + // 迷你播放器毛玻璃的 Haze 背景源。首屏先不挂,避免整页离屏层和封面解码叠在一起。 val miniPlayerHaze = rememberHazeState() + var homeHazeEnabled by remember { mutableStateOf(false) } // 选择模式 > 0 时锁定歌库/我的标签与 WeSlide 手势 val batchLock = selectMode == LibrarySelectMode.Batch val detailOpen = libraryDetail != null @@ -240,20 +244,14 @@ fun MainShell( } } - // 存储根/SD 路径变更后修复过期的 existFile(轻量 exists 扫描)。 - LaunchedEffect(Unit) { - runCatching { - AppContainer.library.rescanExistFiles(AppContainer.pathResolver) - } - refreshToken++ - } - // HTTP 关闭时按本地 existFile 过滤曲库。 // 正倒序按页独立计算,不写进查询 key。 val enableHttp = AppContainer.settings.enableHttp val historyRevision by AppContainer.historyRevision.collectAsState() val loveRevision by AppContainer.loveRevision.collectAsState() val libraryRevision by AppContainer.libraryRevision.collectAsState() + val startupHistoryRevision = remember { historyRevision } + val startupLoveRevision = remember { loveRevision } val libraryLists = remember(refreshToken, currentGroup, enableHttp, libraryRevision) { runCatching { AppContainer.library.findAllListByGroup( @@ -271,12 +269,12 @@ fun MainShell( ) } } - var music by remember { mutableStateOf(emptyList()) } - var albums by remember { mutableStateOf(emptyList()) } + var music by remember { mutableStateOf(libraryLists.music) } + var albums by remember { mutableStateOf(libraryLists.albums) } var artists by remember { mutableStateOf(libraryLists.artists) } var menus by remember { mutableStateOf(libraryLists.menus) } - var loved by remember { mutableStateOf(emptyList()) } - var recent by remember { mutableStateOf(emptyList()) } + var loved by remember { mutableStateOf(libraryLists.loved) } + var recent by remember { mutableStateOf(libraryLists.recent) } LaunchedEffect(libraryLists) { music = libraryLists.music albums = libraryLists.albums @@ -285,35 +283,64 @@ fun MainShell( loved = libraryLists.loved recent = libraryLists.recent } - // 播放历史更新时仅刷新最近播放列表。 + // 首屏封面先解码。本地文件核对延后,避免扫盘占满磁盘把列表拖卡。 + LaunchedEffect(Unit) { + delay(1_500) + homeHazeEnabled = true + val groupAtStart = currentGroup.displayName + val httpAtStart = enableHttp + val changed = withContext(Dispatchers.IO) { + runCatching { + AppContainer.library.rescanExistFiles(AppContainer.pathResolver) + }.getOrDefault(0) + } + if (changed > 0 && + currentGroup.displayName == groupAtStart && + enableHttp == httpAtStart + ) { + val lists = withContext(Dispatchers.Default) { + runCatching { + AppContainer.library.findAllListByGroup(groupAtStart, httpAtStart) + }.getOrNull() + } + if (lists != null && + currentGroup.displayName == groupAtStart && + enableHttp == httpAtStart + ) { + music = lists.music + albums = lists.albums + artists = lists.artists + menus = lists.menus + loved = lists.loved + recent = lists.recent + } + } + } + // 播放历史更新时仅刷新最近播放列表。启动时已经记过的版本不再查库。 LaunchedEffect(historyRevision, currentGroup, enableHttp) { - if (historyRevision == 0L) return@LaunchedEffect - runCatching { - AppContainer.library.findAllListByGroup( - group = currentGroup.displayName, - enableHttp = enableHttp, - ).recent - }.getOrNull()?.let { recent = it } + if (historyRevision == startupHistoryRevision) return@LaunchedEffect + val groupName = currentGroup.displayName + val http = enableHttp + val next = withContext(Dispatchers.Default) { + runCatching { + AppContainer.library.recentVisible(groupName, http) + }.getOrNull() + } + if (next != null) recent = next } // 收藏切换时刷新收藏列表。 LaunchedEffect(loveRevision, currentGroup, enableHttp) { - if (loveRevision == 0L) return@LaunchedEffect - runCatching { - AppContainer.library.findAllListByGroup( - group = currentGroup.displayName, - enableHttp = enableHttp, - ) - }.getOrNull()?.let { lists -> - loved = lists.loved - // 同步歌曲/最近播放行的 isLove 与 Music.isLove 列。 - fun patch(list: List) = list.map { m -> - val id = m.musicId ?: return@map m - val love = AppContainer.library.getMusicById(id)?.isLove == true - if (m.isLove == love) m else m.copy(isLove = love) - } - music = patch(music) - recent = patch(recent) - } + if (loveRevision == startupLoveRevision) return@LaunchedEffect + val groupName = currentGroup.displayName + val http = enableHttp + val lists = withContext(Dispatchers.Default) { + runCatching { + AppContainer.library.findAllListByGroup(groupName, http) + }.getOrNull() + } ?: return@LaunchedEffect + loved = lists.loved + music = lists.music + recent = lists.recent } // 搜索模式:在歌曲/收藏/最近播放中按歌名过滤。 val displayMusic = remember(music, songsSort, searchQuery, selectMode) { @@ -582,7 +609,7 @@ fun MainShell( ) { CompositionLocalProvider( LocalLayoutDirection provides LayoutDirection.Ltr, - LocalMiniPlayerHaze provides miniPlayerHaze, + LocalMiniPlayerHaze provides if (homeHazeEnabled) miniPlayerHaze else null, LocalHomeListBottomInset provides HomeListChrome.ListBottomInset, ) { WeSlide( @@ -617,7 +644,13 @@ fun MainShell( Box( Modifier .fillMaxSize() - .hazeSource(state = miniPlayerHaze), + .then( + if (homeHazeEnabled) { + Modifier.hazeSource(state = miniPlayerHaze) + } else { + Modifier + }, + ), ) { Column( Modifier @@ -712,7 +745,7 @@ fun MainShell( state = contentPagerState, modifier = Modifier.weight(1f).fillMaxWidth(), verticalAlignment = Alignment.Top, - beyondViewportPageCount = 1, + beyondViewportPageCount = 0, userScrollEnabled = !weSlide.isOpened && !detailOpen && selectMode == LibrarySelectMode.Normal, diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/MiniPlayerBar.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/MiniPlayerBar.kt index de830ed..c341b60 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/MiniPlayerBar.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/MiniPlayerBar.kt @@ -1,22 +1,29 @@ package com.zhushenwudi.lovelivemusicplayer.ui.player +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue @@ -27,13 +34,20 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import kotlin.math.roundToInt import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeTint import dev.chrisbanes.haze.hazeEffect @@ -48,6 +62,7 @@ import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset import com.zhushenwudi.lovelivemusicplayer.ui.icons.TintIcon import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import kotlin.time.Duration.Companion.milliseconds /** 迷你栏背后的主页列表内容,由 [com.zhushenwudi.lovelivemusicplayer.ui.MainShell] 提供。 */ val LocalMiniPlayerHaze = compositionLocalOf { null } @@ -132,15 +147,9 @@ fun MiniPlayerBar( ) } Spacer(Modifier.width(8.dp)) - // 居中跑马灯歌名 - Text( + MiniPlayerTitle( text = title, color = titleColor, - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, modifier = Modifier .weight(1f) .then( @@ -205,6 +214,85 @@ fun MiniPlayerBar( } } +private val MiniTitleStyle = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, +) + +/** 略慢于常见 30dp/s 走马灯。 */ +private val MiniTitleMarqueeVelocity = 20.dp + +private const val MiniTitleMarqueeHoldMs = 2_000 + +/** + * 歌名宽于显示区域时走马灯:左侧对齐停 2 秒,匀速滚到右侧对齐再停 2 秒, + * 然后跳回起点,再等 2 秒继续。未超出时居中静止。 + */ +@Composable +private fun MiniPlayerTitle( + text: String, + color: Color, + modifier: Modifier = Modifier, +) { + val textMeasurer = rememberTextMeasurer() + val style = MiniTitleStyle.copy(color = color) + BoxWithConstraints( + modifier = modifier.clipToBounds(), + contentAlignment = Alignment.CenterStart, + ) { + val maxWidthPx = constraints.maxWidth + val density = LocalDensity.current + val textWidthPx = remember(text, style, textMeasurer, maxWidthPx) { + textMeasurer.measure( + text = text, + style = style, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Visible, + ).size.width + } + val overflowPx = (textWidthPx - maxWidthPx).coerceAtLeast(0) + if (overflowPx <= 0) { + Text( + text = text, + style = style, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } else { + val offsetX = remember(text) { Animatable(0f) } + LaunchedEffect(text, overflowPx) { + val velocityPxPerSec = with(density) { MiniTitleMarqueeVelocity.toPx() } + val durationMs = (overflowPx / velocityPxPerSec * 1_000f) + .toInt() + .coerceAtLeast(1) + offsetX.snapTo(0f) + while (true) { + delay(MiniTitleMarqueeHoldMs.toLong().milliseconds) + offsetX.animateTo( + targetValue = -overflowPx.toFloat(), + animationSpec = tween(durationMs, easing = LinearEasing), + ) + delay(MiniTitleMarqueeHoldMs.toLong().milliseconds) + offsetX.snapTo(0f) + } + } + Text( + text = text, + style = style, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Visible, + modifier = Modifier + .wrapContentWidth(unbounded = true, align = Alignment.Start) + .offset { IntOffset(offsetX.value.roundToInt(), 0) }, + ) + } + } +} + @Composable private fun MiniBarIconButton( asset: SvgAsset, diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt index 0202486..1e1af89 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt @@ -196,7 +196,11 @@ fun SongList( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = listBottomInset), ) { - items(music) { item -> + items( + items = music, + key = { it.musicId ?: "${it.musicName}-${it.musicPath}-${it.albumId}" }, + contentType = { "song" }, + ) { item -> val id = item.musicId.orEmpty() SongListItem( music = item, @@ -267,7 +271,11 @@ private fun AlbumList( ), horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - items(albums) { album -> + items( + items = albums, + key = { it.albumId ?: "${it.albumName}-${it.coverPath}" }, + contentType = { "album" }, + ) { album -> AlbumGridItem( album = album, onClick = { album.albumId?.let(onOpen) }, diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/theme/DesignDensity.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/theme/DesignDensity.kt index 87874f7..cb70d28 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/theme/DesignDensity.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/theme/DesignDensity.kt @@ -1,12 +1,19 @@ package com.zhushenwudi.lovelivemusicplayer.ui.theme +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.statusBars import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.Density @@ -24,31 +31,52 @@ val LocalDesignScale = staticCompositionLocalOf { 1f } /** [com.zhushenwudi.lovelivemusicplayer.ui.theme.ProvideDesignDensity] 缩放前的设备密度(供底部栏使用)。 */ val LocalUnscaledDensity = staticCompositionLocalOf { null } +/** + * 首帧 [androidx.compose.ui.platform.LocalWindowInfo] 仍是 0×0、系统栏 inset 也还没派发时, + * 用平台窗口度量顶上,避免先按 scale=1 铺满状态栏再缩回。 + */ +data class StartupWindowMetrics( + val heightPx: Int = 0, + val statusTopPx: Int = 0, + val navBottomPx: Int = 0, +) + +val LocalStartupWindowMetrics = staticCompositionLocalOf { StartupWindowMetrics() } + +@Composable +internal expect fun rememberStartupWindowMetrics(): StartupWindowMetrics + /** * 按设计稿比例缩放 [androidx.compose.ui.platform.LocalDensity],使 dp/sp 与设计尺寸一致。 * 在应用根节点调用一次;底部栏需保持设备密度时用 [com.zhushenwudi.lovelivemusicplayer.ui.theme.ProvideUnscaledDensity] 包裹。 */ @Composable fun ProvideDesignDensity(content: @Composable () -> Unit) { - val base = LocalDensity.current - val containerPx = LocalWindowInfo.current.containerSize - val heightDp = containerPx.height / base.density - // 按高度比例缩放;不低于设计尺寸;平板上限 1.35。忽略系统字体缩放(对齐 Flutter textScaler = 1.0)。 - val scale = remember(heightDp) { - (heightDp / Const.UI_HEIGHT).coerceIn(1f, 1.35f) - } - val scaled = remember(base, scale) { - Density( - density = base.density * scale, - fontScale = 1f, + val startup = rememberStartupWindowMetrics() + CompositionLocalProvider(LocalStartupWindowMetrics provides startup) { + val base = LocalDensity.current + val containerPx = LocalWindowInfo.current.containerSize + val heightPx = if (containerPx.height > 0) containerPx.height else startup.heightPx + if (heightPx <= 0) { + Box(Modifier.fillMaxSize()) + return@CompositionLocalProvider + } + val heightDp = heightPx / base.density + // 按高度比例缩放;不低于设计尺寸;平板上限 1.35。忽略系统字体缩放(对齐 Flutter textScaler = 1.0)。 + val scale = (heightDp / Const.UI_HEIGHT).coerceIn(1f, 1.35f) + val scaled = remember(base.density, scale) { + Density( + density = base.density * scale, + fontScale = 1f, + ) + } + CompositionLocalProvider( + LocalDensity provides scaled, + LocalUnscaledDensity provides base, + LocalDesignScale provides scale, + content = content, ) } - CompositionLocalProvider( - LocalDensity provides scaled, - LocalUnscaledDensity provides base, - LocalDesignScale provides scale, - content = content, - ) } /** 恢复设备密度,保持底部栏物理尺寸不变。 */ @@ -71,16 +99,38 @@ fun Dp.asUnscaledInScaledContext(): Dp { /** 状态栏顶部内边距(dp,使用真实设备密度,忽略设计缩放)。 */ @Composable -fun unscaledStatusBarsTop(): Dp { - val density = LocalUnscaledDensity.current ?: LocalDensity.current - return with(density) { WindowInsets.statusBars.getTop(this).toDp() } -} +fun unscaledStatusBarsTop(): Dp = unscaledSystemBarPx( + composePx = WindowInsets.statusBars.getTop(systemBarDensity()), + startupPx = LocalStartupWindowMetrics.current.statusTopPx, +) /** 导航栏底部内边距(dp,使用真实设备密度,忽略设计缩放)。 */ @Composable -fun unscaledNavigationBarsBottom(): Dp { - val density = LocalUnscaledDensity.current ?: LocalDensity.current - return with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } +fun unscaledNavigationBarsBottom(): Dp = unscaledSystemBarPx( + composePx = WindowInsets.navigationBars.getBottom(systemBarDensity()), + startupPx = LocalStartupWindowMetrics.current.navBottomPx, +) + +@Composable +private fun systemBarDensity(): Density = + LocalUnscaledDensity.current ?: LocalDensity.current + +/** + * Compose 首帧系统栏 inset 常为 0。在它真正派发之前用启动时的窗口度量, + * 派发之后以 Compose 为准(含沉浸式隐藏)。 + */ +@Composable +private fun unscaledSystemBarPx(composePx: Int, startupPx: Int): Dp { + var dispatched by remember { mutableStateOf(false) } + SideEffect { + if (composePx > 0) dispatched = true + } + val px = when { + composePx > 0 -> composePx + dispatched -> 0 + else -> startupPx + } + return with(systemBarDensity()) { px.toDp() } } /** diff --git a/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/theme/StartupWindowMetrics.ios.kt b/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/theme/StartupWindowMetrics.ios.kt new file mode 100644 index 0000000..93f8a1f --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/theme/StartupWindowMetrics.ios.kt @@ -0,0 +1,6 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.theme + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun rememberStartupWindowMetrics(): StartupWindowMetrics = StartupWindowMetrics() diff --git a/iosApp/iosApp/InAppWebViewController.swift b/iosApp/iosApp/InAppWebViewController.swift index 737607d..56a5847 100644 --- a/iosApp/iosApp/InAppWebViewController.swift +++ b/iosApp/iosApp/InAppWebViewController.swift @@ -13,6 +13,7 @@ final class InAppWebViewController: UIViewController { private var backButton: UIBarButtonItem! private var forwardButton: UIBarButtonItem! private var refreshButton: UIBarButtonItem! + private var adCleanupTimer: Timer? init(url: String, title: String?) { urlString = url @@ -59,15 +60,24 @@ final class InAppWebViewController: UIViewController { self.view.bringSubviewToFront(self.progressView) self.observeProgress(webView) self.updateNavigationButtons() + self.startAdCleanupTimer() guard let url = Self.resolveURL(self.urlString) else { return } webView.load(URLRequest(url: url)) } } deinit { + adCleanupTimer?.invalidate() progressObservation?.invalidate() } + private func startAdCleanupTimer() { + adCleanupTimer?.invalidate() + adCleanupTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + self?.webView?.evaluateJavaScript(WebViewAdBlock.shared.domCleanupScript(), completionHandler: nil) + } + } + private func setupToolbar() { toolbar.translatesAutoresizingMaskIntoConstraints = false view.addSubview(toolbar) @@ -171,8 +181,7 @@ extension InAppWebViewController: WKNavigationDelegate { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { progressView.isHidden = true updateNavigationButtons() - let url = webView.url?.absoluteString ?? urlString - webView.evaluateJavaScript(WebViewAdBlock.shared.injectionScript(url: url), completionHandler: nil) + webView.evaluateJavaScript(WebViewAdBlock.shared.domCleanupScript(), completionHandler: nil) } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { diff --git a/iosApp/iosApp/WebViewContentBlocker.swift b/iosApp/iosApp/WebViewContentBlocker.swift index f28e248..8a9e059 100644 --- a/iosApp/iosApp/WebViewContentBlocker.swift +++ b/iosApp/iosApp/WebViewContentBlocker.swift @@ -1,23 +1,15 @@ import ComposeApp import WebKit -/// 将 Kotlin [WebViewAdBlock] 规则编译为 WKContentRuleList,并预装页面脚本。 +/// 将 Kotlin [WebViewAdBlock] 规则编译为 WKContentRuleList。页面脚本由控制器定时执行,不预装进文档。 enum WebViewContentBlocker { - private static let identifier = "llmpAdBlock.v4" + private static let identifier = "llmpAdBlock.v5" static func configure( _ configuration: WKWebViewConfiguration, pageUrl: String, completion: @escaping () -> Void ) { - configuration.userContentController.addUserScript( - WKUserScript( - source: WebViewAdBlock.shared.injectionScript(url: pageUrl), - injectionTime: .atDocumentEnd, - forMainFrameOnly: true - ) - ) - let json = WebViewAdBlock.shared.buildContentBlockerJson() guard let store = WKContentRuleListStore.default() else { DispatchQueue.main.async { completion() } diff --git a/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/AndroidPlayerHolder.kt b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/AndroidPlayerHolder.kt index a39771b..a757715 100644 --- a/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/AndroidPlayerHolder.kt +++ b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/AndroidPlayerHolder.kt @@ -22,9 +22,12 @@ object AndroidPlayerHolder { .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .setUsage(C.USAGE_MEDIA) .build(), + // 其他媒体永久抢焦点:暂停且不恢复。 + // 电话/瞬时失去:暂停,焦点回来后恢复。 + // 导航、通知等可闪避:音量降到约 0.2,结束后恢复原音量。 /* handleAudioFocus = */ true, ) - // 有线/蓝牙耳机拔出或断开时暂停。 + // 有线/蓝牙耳机拔出或断开时暂停,不自动续播。媒体按键走 MediaSession。 .setHandleAudioBecomingNoisy(true) .build() player = created diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/LibraryRepository.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/LibraryRepository.kt index 597d6b7..037db4b 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/LibraryRepository.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/LibraryRepository.kt @@ -46,7 +46,8 @@ class LibraryRepository(private val db: LlmpDatabase) { val albumComparator = compareBy({ it.date.orEmpty() }, { it.albumId.orEmpty() }) // 各页正倒序由 UI 独立计算;此处只返回规范顺序。 - val music = getAllMusic().filter { it.matches() }.sortedWith(musicComparator) + val allMusic = getAllMusic() + val music = allMusic.filter { it.matches() }.sortedWith(musicComparator) // 可见歌曲已满足:导出筛选 +(HTTP 或本地 existFile)。 val musicIds = music.mapNotNull { it.musicId }.toSet() val visibleAlbumIds = music.mapNotNull { it.albumId }.toSet() @@ -66,7 +67,7 @@ class LibraryRepository(private val db: LlmpDatabase) { val loved = getLovedMusic().filter { it.matches() }.asReversed() // 最近播放与已筛选歌曲列表取交集。 // selectAllHistory 已按 timestamp DESC(最新在队首),规范顺序保持最新在前。 - val recent = getHistoryMusic().filter { m -> + val recent = historyFrom(allMusic).filter { m -> m.musicId in musicIds && (allGroup || m.group == group) } return LibraryLists( @@ -192,10 +193,25 @@ class LibraryRepository(private val db: LlmpDatabase) { fun getLovedMusic(): List = q.selectLovedMusic().executeAsList().map { it.toDomain() } - fun getHistoryMusic(): List = - q.selectAllHistory().executeAsList().mapNotNull { h -> - getMusicById(h.musicId)?.copy(timestamp = h.timestamp) + fun getHistoryMusic(): List = historyFrom(getAllMusic()) + + /** + * 最近播放里、当前团组且(HTTP 或本地)可见的曲目。 + * 只扫歌曲表和历史表,不加载专辑/艺人/歌单。 + */ + fun recentVisible(group: String, enableHttp: Boolean): List { + val allGroup = group == "all" + return historyFrom(getAllMusic()).filter { music -> + (allGroup || music.group == group) && (enableHttp || music.existFile == true) } + } + + private fun historyFrom(allMusic: List): List { + val byId = allMusic.associateBy { it.musicId } + return q.selectAllHistory().executeAsList().mapNotNull { h -> + byId[h.musicId]?.copy(timestamp = h.timestamp) + } + } fun upsertMusic(music: Music) { val id = music.musicId ?: return @@ -374,11 +390,14 @@ class LibraryRepository(private val db: LlmpDatabase) { * 按 `ids` 顺序获取曲目; * HTTP 关闭时跳过缺失或非本地文件。 */ - fun getMusicByIdsPreservingOrder(ids: List, enableHttp: Boolean): List = - ids.mapNotNull { id -> - val music = getMusicById(id) ?: return@mapNotNull null + fun getMusicByIdsPreservingOrder(ids: List, enableHttp: Boolean): List { + if (ids.isEmpty()) return emptyList() + val byId = getAllMusic().associateBy { it.musicId } + return ids.mapNotNull { id -> + val music = byId[id] ?: return@mapNotNull null if (!enableHttp && music.existFile != true) null else music } + } fun savePlayList(items: List, playingId: String?) { q.deletePlayList() diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/WebViewAdBlock.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/WebViewAdBlock.kt index 57db617..73b7ea3 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/WebViewAdBlock.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/WebViewAdBlock.kt @@ -5,215 +5,87 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +private val contentBlockerJson = Json { explicitNulls = false } + /** - * 应用内 WebView 广告过滤。 - * Android:拦截请求 + 页面脚本;iOS:WKContentRuleList + UserScript。 + * 对齐 Flutter 萌娘百科页:少量 URL 拦截 + 选择器隐藏,DOM 清理由原生每秒执行一次。 + * 不在页面里安装具名函数或 setInterval,避免被站点当成广告拦截脚本。 */ object WebViewAdBlock { - val blockedHostSuffixes: List = listOf( - "doubleclick.net", - "googlesyndication.com", - "googleadservices.com", - "google-analytics.com", - "googletagmanager.com", - "googletagservices.com", - "adservice.google.com", - "pagead2.googlesyndication.com", - "ads.youtube.com", - "cpro.baidu.com", - "pos.baidu.com", - "hm.baidu.com", - "tanx.com", - "alimama.com", - "mmstat.com", - "cnzz.com", - "irs01.com", - "irs01.net", - "admaster.com.cn", - "gdt.qq.com", - "e.qq.com", - "ad.qq.com", - "adsrvr.org", - "adnxs.com", - "criteo.com", - "criteo.net", - "taboola.com", - "outbrain.com", - "media.net", - "a-mo.net", - "moatads.com", - "scorecardresearch.com", - "quantserve.com", - "adform.net", - "adform.com", - "rubiconproject.com", - "pubmatic.com", - "openx.net", - "casalemedia.com", - "contextweb.com", - "lijit.com", - "amazon-adsystem.com", - "fundingchoicesmessages.google.com", - "fondrea.com", - "fundeary.com", - "grfz.laogu.wang", - "laogu.wang", - "intellitxt.com", - "exoclick.com", - "juicyads.com", - "propellerads.com", - "popads.net", - "mgid.com", - "revcontent.com", - "zergnet.com", - "yieldmo.com", - "sharethrough.com", - "spotxchange.com", - "teads.tv", - "smartadserver.com", - "serving-sys.com", - "2mdn.net", - "inmobi.com", - "chartbeat.com", - "hotjar.com", - "clarity.ms", - ) + private val adUrlFilters: List = listOf( + ".*.doubleclick.net/.*", + ".*.ads.pubmatic.com/.*", + ".*.googlesyndication.com/.*", + ".*.google-analytics.com/.*", + ".*.adservice.google.*/.*", + ".*.adbrite.com/.*", + ".*.exponential.com/.*", + ".*.quantserve.com/.*", + ".*.scorecardresearch.com/.*", + ".*.zedo.com/.*", + ".*.adsafeprotected.com/.*", + ".*.teads.tv/.*", + ".*.outbrain.com/.*", + ).map { Regex(it, RegexOption.IGNORE_CASE) } - private val cssHideSelectors: List = listOf( - "ins.adsbygoogle", - ".adsbygoogle", - ".google-auto-placed", - "[id*='google_ads']", - "[class*='google-ad']", - "[data-ad-slot]", - "[data-ad-client]", - "iframe[src*='doubleclick']", - "iframe[src*='googlesyndication']", - "iframe[src*='adservice']", - "iframe[src*='fondrea']", - "iframe[src*='fundeary']", - ".fc-ab-root", - ) + const val cosmeticSelector: String = ".banner, .banners, .ads, .ad, .advert" - fun isMoegirlUrl(url: String): Boolean = - url.contains("moegirl.org", ignoreCase = true) - - fun isBlockedHost(host: String): Boolean { - val normalized = host.trim().lowercase().removePrefix("www.") - if (normalized.isEmpty()) return false - return blockedHostSuffixes.any { suffix -> - normalized == suffix || normalized.endsWith(".$suffix") - } + fun isBlockedUrl(url: String): Boolean { + if (url.isBlank()) return false + return adUrlFilters.any { it.containsMatchIn(url) } } - fun injectionScript(url: String): String = - if (isMoegirlUrl(url)) moegirlCleanupScript() else cssHideScript() - - /** - * 萌百广告清理:装一次后每秒自跑。 - * 不要在 Kotlin raw string 里写 `\\.`,会变成 JS 里的字面反斜杠。 - */ - fun moegirlCleanupScript(): String = """ + /** 只跑一遍,由原生定时器重复调用。 */ + fun domCleanupScript(): String = """ (function() { - if ((location.host || '').indexOf('moegirl.org') === -1) return; - if (window.__llmpAdblock) { - window.__llmpAdblock(); - return; - } - - var KEYWORDS = ['广告','推广','推廣','廣告','谷歌广告','百度推广','百度推廣','加载中','ads','promotions']; - - function compact(text) { - return (text || '').replace(/\s+/g, '').replace(/[×x✕✖X]/g, '').toLowerCase(); - } - - function isBadge(el) { - var text = (el.innerText || el.textContent || '').trim(); - return text.length <= 16 && KEYWORDS.indexOf(compact(text)) >= 0; - } - - function isStop(node) { - if (!node || node === document.body || node === document.documentElement) return true; - var id = node.id || ''; - return id === 'moe-article' || id === 'moe-main-container' || id === 'app' || id === 'content'; - } - - function pickRoot(badge) { - var last = null; - for (var node = badge; node && !isStop(node); node = node.parentElement) { - if (node.classList && node.classList.contains('mw-parser-output')) break; - if (node.classList && ( - node.classList.contains('moe-card') || - node.classList.contains('fc-ab-root') || - node.classList.contains('adsbygoogle') - )) return node; - var style = getComputedStyle(node); - var box = node.getBoundingClientRect(); - if ((style.position === 'fixed' || style.position === 'sticky') && - box.height >= 36 && box.height < innerHeight * 0.6 && - box.width >= innerWidth * 0.35) return node; - if (node.querySelector && node.querySelector('img,iframe,video,canvas') && - box.height >= 64 && box.height <= innerHeight * 0.55 && box.width >= 180) { - last = node; + var divElements = document.querySelectorAll('*'); + var keywords = ['广告', '推广', '谷歌广告', '加载中']; + for (var parent of divElements) { + if (parent.className === 'adsbygoogle') { + parent.remove(); + } + var childNodes = parent.childNodes; + if (childNodes.length <= 3) { + for (var node of childNodes) { + var elementText = node.textContent; + if (keywords.includes(elementText)) { + parent.remove(); + break; + } } } - return last; } - - function run() { - try { - document.querySelectorAll('.adsbygoogle,ins.adsbygoogle,.fc-ab-root,#mw-data-after-content') - .forEach(function(node) { node.remove(); }); - document.querySelectorAll('a,span,div,small,label,em,i,button').forEach(function(el) { - if (!isBadge(el)) return; - var root = pickRoot(el); - if (root && !root.closest('#footer-moegirl,footer.mw-footer')) root.remove(); - }); - } catch (e) {} - } - - window.__llmpAdblock = run; - run(); - setInterval(run, 1000); })(); """.trimIndent() - fun cssHideScript(): String { - val selectors = cssHideSelectors.joinToString(",") { it.replace("'", "\\'") } - return """ - (function() { - if (window.__llmpCssAdblock) return; - window.__llmpCssAdblock = true; - var selectors = '$selectors'; - function hide() { - try { - selectors.split(',').forEach(function(sel) { - document.querySelectorAll(sel).forEach(function(node) { - node.style.setProperty('display', 'none', 'important'); - }); - }); - } catch (e) {} - } - hide(); - setInterval(hide, 1000); - })(); - """.trimIndent() - } + /** Android 没有内容规则引擎,用一次性样式等价 CSS display none。 */ + fun cosmeticStyleScript(): String = """ + (function() { + var css = '$cosmeticSelector { display: none !important; }'; + var nodes = document.getElementsByTagName('style'); + for (var i = 0; i < nodes.length; i++) { + if (nodes[i].textContent === css) return; + } + var style = document.createElement('style'); + style.appendChild(document.createTextNode(css)); + (document.head || document.documentElement).appendChild(style); + })(); + """.trimIndent() fun buildContentBlockerJson(): String { - val rules = blockedHostSuffixes.map { host -> + val rules = adUrlFilters.map { pattern -> ContentBlockerRule( - trigger = ContentBlockerTrigger(urlFilter = ".*$host"), + trigger = ContentBlockerTrigger(urlFilter = pattern.pattern), action = ContentBlockerAction(type = "block"), ) } + ContentBlockerRule( trigger = ContentBlockerTrigger(urlFilter = ".*"), action = ContentBlockerAction( type = "css-display-none", - selector = cssHideSelectors.joinToString(", "), + selector = cosmeticSelector, ), ) - return Json.encodeToString(rules) + return contentBlockerJson.encodeToString(rules) } } diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/SleepTimer.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/SleepTimer.kt index 237723e..787c225 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/SleepTimer.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/SleepTimer.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.launch class SleepTimer( private val scope: CoroutineScope, - private val onFire: () -> Unit, + private val onFire: suspend () -> Unit, ) { private val _remainingMs = MutableStateFlow(0L) val remainingMs: StateFlow = _remainingMs.asStateFlow() diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/PathResolver.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/PathResolver.kt index 90edddd..e1ce28d 100644 --- a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/PathResolver.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/PathResolver.kt @@ -98,11 +98,9 @@ class PathResolver( */ fun coverDisplayUri(music: Music): String? { if (music.musicId == null) return null + // 不在 UI 线程 stat。文件缺失时由图片加载失败落到 Logo。 if (music.existFile == true) { - val path = absoluteCoverPath(music) - if (path != null && fs.exists(path)) { - return "file://$path" - } + absoluteCoverPath(music)?.let { return "file://$it" } } if (settings.enableHttp) { val relative = joinMediaRelativePath(music.baseUrl, music.coverPath) ?: return null @@ -115,10 +113,7 @@ class PathResolver( fun coverDisplayUri(album: Album): String? { val rel = album.coverPath ?: return null if (album.existFile == true) { - val path = absoluteAlbumCoverPath(album) - if (path != null && fs.exists(path)) { - return "file://$path" - } + absoluteAlbumCoverPath(album)?.let { return "file://$it" } } if (settings.enableHttp) { return joinHttpUrl(rel) diff --git a/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerController.ios.kt b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerController.ios.kt index 5c8eecb..8cbb7e6 100644 --- a/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerController.ios.kt +++ b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerController.ios.kt @@ -13,6 +13,12 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import platform.AVFAudio.AVAudioSession import platform.AVFAudio.AVAudioSessionCategoryPlayback +import platform.AVFAudio.AVAudioSessionInterruptionNotification +import platform.AVFAudio.AVAudioSessionInterruptionOptionKey +import platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume +import platform.AVFAudio.AVAudioSessionInterruptionTypeBegan +import platform.AVFAudio.AVAudioSessionInterruptionTypeEnded +import platform.AVFAudio.AVAudioSessionInterruptionTypeKey import platform.AVFAudio.AVAudioSessionRouteChangeNotification import platform.AVFAudio.AVAudioSessionRouteChangeReasonKey import platform.AVFAudio.AVAudioSessionRouteChangeReasonOldDeviceUnavailable @@ -82,6 +88,13 @@ actual class PlayerController { private var endObserver: Any? = null private var failObserver: Any? = null private var routeObserver: Any? = null + private var interruptionObserver: Any? = null + + /** + * 电话、闹钟、其他音频会话打断前正在播放。 + * 用户在打断期间手动暂停则清掉,结束后不再自动恢复。 + */ + private var resumeAfterInterruption = false private val _state = MutableStateFlow(PlayerState()) actual val state: StateFlow = _state.asStateFlow() @@ -120,6 +133,7 @@ actual class PlayerController { observePlaybackEnd() observePlaybackFailed() observeAudioRouteChanges() + observeAudioInterruptions() } private fun ensureAudioReady() { @@ -284,7 +298,45 @@ actual class PlayerController { } } - /** 耳机/蓝牙音频路由不可用时暂停(拔出/断开)。 */ + /** + * 电话 / 闹钟 / 其他 App 占用音频会话时暂停。 + * 结束且系统给出 ShouldResume 才恢复;导航等短提示由系统把本会话音量闪避,不必自己改 volume。 + */ + private fun observeAudioInterruptions() { + interruptionObserver = NSNotificationCenter.defaultCenter.addObserverForName( + name = AVAudioSessionInterruptionNotification, + `object` = AVAudioSession.sharedInstance(), + queue = null, + ) { notification: NSNotification? -> + val info = notification?.userInfo + val type = (info?.get(AVAudioSessionInterruptionTypeKey) as? NSNumber)?.unsignedLongValue + val options = + (info?.get(AVAudioSessionInterruptionOptionKey) as? NSNumber)?.unsignedLongValue ?: 0uL + dispatch_async(dispatch_get_main_queue()) { + when (type) { + AVAudioSessionInterruptionTypeBegan -> { + val wasPlaying = _state.value.isPlaying + if (wasPlaying) { + pause() + resumeAfterInterruption = true + } else { + resumeAfterInterruption = false + } + } + + AVAudioSessionInterruptionTypeEnded -> { + val shouldResume = + (options and AVAudioSessionInterruptionOptionShouldResume) != 0uL + val resume = shouldResume && resumeAfterInterruption + resumeAfterInterruption = false + if (resume) play() + } + } + } + } + } + + /** 耳机/蓝牙音频路由不可用时暂停(拔出/断开),不自动续播。 */ private fun observeAudioRouteChanges() { routeObserver = NSNotificationCenter.defaultCenter.addObserverForName( name = AVAudioSessionRouteChangeNotification, @@ -540,6 +592,7 @@ actual class PlayerController { } actual fun pause() { + resumeAfterInterruption = false endScrub() player.pause() positionJob?.cancel()