fix moegirl ad blocker and optimize func
This commit is contained in:
@@ -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<View>(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)
|
||||
|
||||
+29
-5
@@ -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()
|
||||
}
|
||||
|
||||
+52
@@ -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
|
||||
}
|
||||
+25
-8
@@ -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<String?, Boolean>? = 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()
|
||||
}
|
||||
}
|
||||
|
||||
+74
-41
@@ -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<Music>()) }
|
||||
var albums by remember { mutableStateOf(emptyList<Album>()) }
|
||||
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<Music>()) }
|
||||
var recent by remember { mutableStateOf(emptyList<Music>()) }
|
||||
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<Music>) = 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,
|
||||
|
||||
+95
-7
@@ -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<HazeState?> { 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,
|
||||
|
||||
+10
-2
@@ -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) },
|
||||
|
||||
+74
-24
@@ -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<Density?> { 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() }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.zhushenwudi.lovelivemusicplayer.ui.theme
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
internal actual fun rememberStartupWindowMetrics(): StartupWindowMetrics = StartupWindowMetrics()
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() }
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
+27
-8
@@ -46,7 +46,8 @@ class LibraryRepository(private val db: LlmpDatabase) {
|
||||
val albumComparator = compareBy<Album>({ 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<Music> =
|
||||
q.selectLovedMusic().executeAsList().map { it.toDomain() }
|
||||
|
||||
fun getHistoryMusic(): List<Music> =
|
||||
q.selectAllHistory().executeAsList().mapNotNull { h ->
|
||||
getMusicById(h.musicId)?.copy(timestamp = h.timestamp)
|
||||
fun getHistoryMusic(): List<Music> = historyFrom(getAllMusic())
|
||||
|
||||
/**
|
||||
* 最近播放里、当前团组且(HTTP 或本地)可见的曲目。
|
||||
* 只扫歌曲表和历史表,不加载专辑/艺人/歌单。
|
||||
*/
|
||||
fun recentVisible(group: String, enableHttp: Boolean): List<Music> {
|
||||
val allGroup = group == "all"
|
||||
return historyFrom(getAllMusic()).filter { music ->
|
||||
(allGroup || music.group == group) && (enableHttp || music.existFile == true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun historyFrom(allMusic: List<Music>): List<Music> {
|
||||
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<String>, enableHttp: Boolean): List<Music> =
|
||||
ids.mapNotNull { id ->
|
||||
val music = getMusicById(id) ?: return@mapNotNull null
|
||||
fun getMusicByIdsPreservingOrder(ids: List<String>, enableHttp: Boolean): List<Music> {
|
||||
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<Music>, playingId: String?) {
|
||||
q.deletePlayList()
|
||||
|
||||
+56
-184
@@ -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<String> = 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<Regex> = 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<String> = 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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<Long> = _remainingMs.asStateFlow()
|
||||
|
||||
+3
-8
@@ -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)
|
||||
|
||||
+54
-1
@@ -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<PlayerState> = _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()
|
||||
|
||||
Reference in New Issue
Block a user