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.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewTreeObserver
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.edit
|
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.DriverFactory
|
||||||
import com.zhushenwudi.lovelivemusicplayer.db.createDatabase
|
import com.zhushenwudi.lovelivemusicplayer.db.createDatabase
|
||||||
import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidShareBridge
|
import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidShareBridge
|
||||||
@@ -66,9 +70,32 @@ class MainActivity : ComponentActivity() {
|
|||||||
requestNotifyPermission()
|
requestNotifyPermission()
|
||||||
startPlaybackService()
|
startPlaybackService()
|
||||||
|
|
||||||
|
// 系统栏 inset 和窗口高度到位之前不画出第一帧,避免顶栏先铺进状态栏再缩回。
|
||||||
|
holdDrawUntilSystemBarsReady()
|
||||||
setContent { App() }
|
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) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
|
|||||||
+29
-5
@@ -11,6 +11,7 @@ import android.webkit.WebViewClient
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.addCallback
|
import androidx.activity.addCallback
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
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.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
import com.zhushenwudi.lovelivemusicplayer.i18n.tr
|
import com.zhushenwudi.lovelivemusicplayer.i18n.tr
|
||||||
import com.zhushenwudi.lovelivemusicplayer.platform.WebViewAdBlock
|
import com.zhushenwudi.lovelivemusicplayer.platform.WebViewAdBlock
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
class WebViewActivity : ComponentActivity() {
|
class WebViewActivity : ComponentActivity() {
|
||||||
private var webView: WebView? = null
|
private var webView: WebView? = null
|
||||||
@@ -48,6 +55,7 @@ class WebViewActivity : ComponentActivity() {
|
|||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
val url = intent.getStringExtra(EXTRA_URL).orEmpty()
|
val url = intent.getStringExtra(EXTRA_URL).orEmpty()
|
||||||
val title = intent.getStringExtra(EXTRA_TITLE)
|
val title = intent.getStringExtra(EXTRA_TITLE)
|
||||||
title?.let { setTitle(it) }
|
title?.let { setTitle(it) }
|
||||||
@@ -67,7 +75,22 @@ class WebViewActivity : ComponentActivity() {
|
|||||||
val progress by pageProgress
|
val progress by pageProgress
|
||||||
|
|
||||||
MaterialTheme {
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -162,8 +185,8 @@ class WebViewActivity : ComponentActivity() {
|
|||||||
view: WebView?,
|
view: WebView?,
|
||||||
request: WebResourceRequest?,
|
request: WebResourceRequest?,
|
||||||
): WebResourceResponse? {
|
): WebResourceResponse? {
|
||||||
val host = request?.url?.host
|
val target = request?.url?.toString().orEmpty()
|
||||||
if (!host.isNullOrBlank() && WebViewAdBlock.isBlockedHost(host)) {
|
if (WebViewAdBlock.isBlockedUrl(target)) {
|
||||||
return WebResourceResponse(
|
return WebResourceResponse(
|
||||||
"text/plain",
|
"text/plain",
|
||||||
"utf-8",
|
"utf-8",
|
||||||
@@ -180,8 +203,9 @@ class WebViewActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onPageFinished(view: WebView?, url: String?) {
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
super.onPageFinished(view, url)
|
super.onPageFinished(view, url)
|
||||||
if (view != null && !url.isNullOrBlank()) {
|
if (view != null) {
|
||||||
view.evaluateJavascript(WebViewAdBlock.injectionScript(url), null)
|
view.evaluateJavascript(WebViewAdBlock.cosmeticStyleScript(), null)
|
||||||
|
view.evaluateJavascript(WebViewAdBlock.domCleanupScript(), null)
|
||||||
}
|
}
|
||||||
onNavigationChanged()
|
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.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import com.zhushenwudi.lovelivemusicplayer.AppContainer.scope
|
import com.zhushenwudi.lovelivemusicplayer.AppContainer.scope
|
||||||
import com.zhushenwudi.lovelivemusicplayer.data.DataImportService
|
import com.zhushenwudi.lovelivemusicplayer.data.DataImportService
|
||||||
import com.zhushenwudi.lovelivemusicplayer.db.LibraryRepository
|
import com.zhushenwudi.lovelivemusicplayer.db.LibraryRepository
|
||||||
@@ -148,7 +149,6 @@ object AppContainer {
|
|||||||
// Android 通知封面用 file:// / https;iOS Now Playing 优先绝对路径。
|
// Android 通知封面用 file:// / https;iOS Now Playing 优先绝对路径。
|
||||||
player.resolveCoverUri = { music ->
|
player.resolveCoverUri = { music ->
|
||||||
pathResolver.absoluteCoverPath(music)
|
pathResolver.absoluteCoverPath(music)
|
||||||
?.takeIf { fileSystem.exists(it) }
|
|
||||||
?: pathResolver.coverDisplayUri(music)
|
?: pathResolver.coverDisplayUri(music)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +181,9 @@ object AppContainer {
|
|||||||
this.dataSync = DataSyncService(WsClient(http), library, scope)
|
this.dataSync = DataSyncService(WsClient(http), library, scope)
|
||||||
this.updateChecker = UpdateChecker(ossApi)
|
this.updateChecker = UpdateChecker(ossApi)
|
||||||
this.sleepTimer = SleepTimer(scope) {
|
this.sleepTimer = SleepTimer(scope) {
|
||||||
player.pause()
|
withContext(Dispatchers.Main) {
|
||||||
|
runCatching { player.pause() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.splashPhoto = SplashPhotoService(fileSystem, ossApi, scope)
|
this.splashPhoto = SplashPhotoService(fileSystem, ossApi, scope)
|
||||||
|
|
||||||
@@ -195,7 +197,6 @@ object AppContainer {
|
|||||||
persistPlayQueue()
|
persistPlayQueue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refreshCarPlayCatalog()
|
|
||||||
PlatformServices.desktopLyric.pipAutoOpen(settings.enableDesktopLyric)
|
PlatformServices.desktopLyric.pipAutoOpen(settings.enableDesktopLyric)
|
||||||
PlatformServices.carPlay.bind(PlatformServices.carPlayCatalog)
|
PlatformServices.carPlay.bind(PlatformServices.carPlayCatalog)
|
||||||
bindCarPlayNowPlaying()
|
bindCarPlayNowPlaying()
|
||||||
@@ -206,6 +207,11 @@ object AppContainer {
|
|||||||
bindHistoryOnTrackChange()
|
bindHistoryOnTrackChange()
|
||||||
initSdStorage()
|
initSdStorage()
|
||||||
bindUsbMountListener()
|
bindUsbMountListener()
|
||||||
|
// 全表快照放后台,并避开主页出来后的首屏解码。
|
||||||
|
scope.launch {
|
||||||
|
delay(2_000)
|
||||||
|
refreshCarPlayCatalog()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -467,6 +473,8 @@ object AppContainer {
|
|||||||
private fun bindCarPlayNowPlaying() {
|
private fun bindCarPlayNowPlaying() {
|
||||||
carPlayNowPlayingJob?.cancel()
|
carPlayNowPlayingJob?.cancel()
|
||||||
carPlayNowPlayingJob = scope.launch {
|
carPlayNowPlayingJob = scope.launch {
|
||||||
|
var recentKey: Pair<String?, Boolean>? = null
|
||||||
|
var skipStartupRecent = true
|
||||||
player.state
|
player.state
|
||||||
.map {
|
.map {
|
||||||
listOf(
|
listOf(
|
||||||
@@ -489,11 +497,20 @@ object AppContainer {
|
|||||||
favorite = current?.isLove == true,
|
favorite = current?.isLove == true,
|
||||||
loopMode = player.state.value.loopMode,
|
loopMode = player.state.value.loopMode,
|
||||||
)
|
)
|
||||||
val recent = library.findAllListByGroup(
|
val key = current?.musicId to (current?.isLove == true)
|
||||||
group = "all",
|
if (key != recentKey) {
|
||||||
enableHttp = settings.enableHttp,
|
recentKey = key
|
||||||
).recent.withSortOrder(settings.recentSortOrder)
|
// 冷启动这一次交给延后的曲库快照,避免和首屏抢数据库。
|
||||||
PlatformServices.carPlayCatalog.updateRecent(recent, cover)
|
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()
|
PlatformServices.carPlay.refreshNowPlaying()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-41
@@ -48,9 +48,12 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.min
|
import androidx.compose.ui.unit.min
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import com.zhushenwudi.lovelivemusicplayer.AppContainer
|
import com.zhushenwudi.lovelivemusicplayer.AppContainer
|
||||||
import com.zhushenwudi.lovelivemusicplayer.Const
|
import com.zhushenwudi.lovelivemusicplayer.Const
|
||||||
import com.zhushenwudi.lovelivemusicplayer.domain.Album
|
import com.zhushenwudi.lovelivemusicplayer.domain.Album
|
||||||
@@ -145,8 +148,9 @@ fun MainShell(
|
|||||||
val section = if (topTabIndex == 0) TopSection.LIBRARY else TopSection.MINE
|
val section = if (topTabIndex == 0) TopSection.LIBRARY else TopSection.MINE
|
||||||
val weSlide = AppContainer.weSlide
|
val weSlide = AppContainer.weSlide
|
||||||
val weSlideFooter = AppContainer.weSlideFooter
|
val weSlideFooter = AppContainer.weSlideFooter
|
||||||
// 迷你播放器毛玻璃的 Haze 背景源
|
// 迷你播放器毛玻璃的 Haze 背景源。首屏先不挂,避免整页离屏层和封面解码叠在一起。
|
||||||
val miniPlayerHaze = rememberHazeState()
|
val miniPlayerHaze = rememberHazeState()
|
||||||
|
var homeHazeEnabled by remember { mutableStateOf(false) }
|
||||||
// 选择模式 > 0 时锁定歌库/我的标签与 WeSlide 手势
|
// 选择模式 > 0 时锁定歌库/我的标签与 WeSlide 手势
|
||||||
val batchLock = selectMode == LibrarySelectMode.Batch
|
val batchLock = selectMode == LibrarySelectMode.Batch
|
||||||
val detailOpen = libraryDetail != null
|
val detailOpen = libraryDetail != null
|
||||||
@@ -240,20 +244,14 @@ fun MainShell(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 存储根/SD 路径变更后修复过期的 existFile(轻量 exists 扫描)。
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
runCatching {
|
|
||||||
AppContainer.library.rescanExistFiles(AppContainer.pathResolver)
|
|
||||||
}
|
|
||||||
refreshToken++
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTP 关闭时按本地 existFile 过滤曲库。
|
// HTTP 关闭时按本地 existFile 过滤曲库。
|
||||||
// 正倒序按页独立计算,不写进查询 key。
|
// 正倒序按页独立计算,不写进查询 key。
|
||||||
val enableHttp = AppContainer.settings.enableHttp
|
val enableHttp = AppContainer.settings.enableHttp
|
||||||
val historyRevision by AppContainer.historyRevision.collectAsState()
|
val historyRevision by AppContainer.historyRevision.collectAsState()
|
||||||
val loveRevision by AppContainer.loveRevision.collectAsState()
|
val loveRevision by AppContainer.loveRevision.collectAsState()
|
||||||
val libraryRevision by AppContainer.libraryRevision.collectAsState()
|
val libraryRevision by AppContainer.libraryRevision.collectAsState()
|
||||||
|
val startupHistoryRevision = remember { historyRevision }
|
||||||
|
val startupLoveRevision = remember { loveRevision }
|
||||||
val libraryLists = remember(refreshToken, currentGroup, enableHttp, libraryRevision) {
|
val libraryLists = remember(refreshToken, currentGroup, enableHttp, libraryRevision) {
|
||||||
runCatching {
|
runCatching {
|
||||||
AppContainer.library.findAllListByGroup(
|
AppContainer.library.findAllListByGroup(
|
||||||
@@ -271,12 +269,12 @@ fun MainShell(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var music by remember { mutableStateOf(emptyList<Music>()) }
|
var music by remember { mutableStateOf(libraryLists.music) }
|
||||||
var albums by remember { mutableStateOf(emptyList<Album>()) }
|
var albums by remember { mutableStateOf(libraryLists.albums) }
|
||||||
var artists by remember { mutableStateOf(libraryLists.artists) }
|
var artists by remember { mutableStateOf(libraryLists.artists) }
|
||||||
var menus by remember { mutableStateOf(libraryLists.menus) }
|
var menus by remember { mutableStateOf(libraryLists.menus) }
|
||||||
var loved by remember { mutableStateOf(emptyList<Music>()) }
|
var loved by remember { mutableStateOf(libraryLists.loved) }
|
||||||
var recent by remember { mutableStateOf(emptyList<Music>()) }
|
var recent by remember { mutableStateOf(libraryLists.recent) }
|
||||||
LaunchedEffect(libraryLists) {
|
LaunchedEffect(libraryLists) {
|
||||||
music = libraryLists.music
|
music = libraryLists.music
|
||||||
albums = libraryLists.albums
|
albums = libraryLists.albums
|
||||||
@@ -285,35 +283,64 @@ fun MainShell(
|
|||||||
loved = libraryLists.loved
|
loved = libraryLists.loved
|
||||||
recent = libraryLists.recent
|
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) {
|
LaunchedEffect(historyRevision, currentGroup, enableHttp) {
|
||||||
if (historyRevision == 0L) return@LaunchedEffect
|
if (historyRevision == startupHistoryRevision) return@LaunchedEffect
|
||||||
runCatching {
|
val groupName = currentGroup.displayName
|
||||||
AppContainer.library.findAllListByGroup(
|
val http = enableHttp
|
||||||
group = currentGroup.displayName,
|
val next = withContext(Dispatchers.Default) {
|
||||||
enableHttp = enableHttp,
|
runCatching {
|
||||||
).recent
|
AppContainer.library.recentVisible(groupName, http)
|
||||||
}.getOrNull()?.let { recent = it }
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
if (next != null) recent = next
|
||||||
}
|
}
|
||||||
// 收藏切换时刷新收藏列表。
|
// 收藏切换时刷新收藏列表。
|
||||||
LaunchedEffect(loveRevision, currentGroup, enableHttp) {
|
LaunchedEffect(loveRevision, currentGroup, enableHttp) {
|
||||||
if (loveRevision == 0L) return@LaunchedEffect
|
if (loveRevision == startupLoveRevision) return@LaunchedEffect
|
||||||
runCatching {
|
val groupName = currentGroup.displayName
|
||||||
AppContainer.library.findAllListByGroup(
|
val http = enableHttp
|
||||||
group = currentGroup.displayName,
|
val lists = withContext(Dispatchers.Default) {
|
||||||
enableHttp = enableHttp,
|
runCatching {
|
||||||
)
|
AppContainer.library.findAllListByGroup(groupName, http)
|
||||||
}.getOrNull()?.let { lists ->
|
}.getOrNull()
|
||||||
loved = lists.loved
|
} ?: return@LaunchedEffect
|
||||||
// 同步歌曲/最近播放行的 isLove 与 Music.isLove 列。
|
loved = lists.loved
|
||||||
fun patch(list: List<Music>) = list.map { m ->
|
music = lists.music
|
||||||
val id = m.musicId ?: return@map m
|
recent = lists.recent
|
||||||
val love = AppContainer.library.getMusicById(id)?.isLove == true
|
|
||||||
if (m.isLove == love) m else m.copy(isLove = love)
|
|
||||||
}
|
|
||||||
music = patch(music)
|
|
||||||
recent = patch(recent)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 搜索模式:在歌曲/收藏/最近播放中按歌名过滤。
|
// 搜索模式:在歌曲/收藏/最近播放中按歌名过滤。
|
||||||
val displayMusic = remember(music, songsSort, searchQuery, selectMode) {
|
val displayMusic = remember(music, songsSort, searchQuery, selectMode) {
|
||||||
@@ -582,7 +609,7 @@ fun MainShell(
|
|||||||
) {
|
) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalLayoutDirection provides LayoutDirection.Ltr,
|
LocalLayoutDirection provides LayoutDirection.Ltr,
|
||||||
LocalMiniPlayerHaze provides miniPlayerHaze,
|
LocalMiniPlayerHaze provides if (homeHazeEnabled) miniPlayerHaze else null,
|
||||||
LocalHomeListBottomInset provides HomeListChrome.ListBottomInset,
|
LocalHomeListBottomInset provides HomeListChrome.ListBottomInset,
|
||||||
) {
|
) {
|
||||||
WeSlide(
|
WeSlide(
|
||||||
@@ -617,7 +644,13 @@ fun MainShell(
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.hazeSource(state = miniPlayerHaze),
|
.then(
|
||||||
|
if (homeHazeEnabled) {
|
||||||
|
Modifier.hazeSource(state = miniPlayerHaze)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -712,7 +745,7 @@ fun MainShell(
|
|||||||
state = contentPagerState,
|
state = contentPagerState,
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top,
|
||||||
beyondViewportPageCount = 1,
|
beyondViewportPageCount = 0,
|
||||||
userScrollEnabled = !weSlide.isOpened &&
|
userScrollEnabled = !weSlide.isOpened &&
|
||||||
!detailOpen &&
|
!detailOpen &&
|
||||||
selectMode == LibrarySelectMode.Normal,
|
selectMode == LibrarySelectMode.Normal,
|
||||||
|
|||||||
+95
-7
@@ -1,22 +1,29 @@
|
|||||||
package com.zhushenwudi.lovelivemusicplayer.ui.player
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -27,13 +34,20 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
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.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.HazeTint
|
import dev.chrisbanes.haze.HazeTint
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
@@ -48,6 +62,7 @@ import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset
|
|||||||
import com.zhushenwudi.lovelivemusicplayer.ui.icons.TintIcon
|
import com.zhushenwudi.lovelivemusicplayer.ui.icons.TintIcon
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
/** 迷你栏背后的主页列表内容,由 [com.zhushenwudi.lovelivemusicplayer.ui.MainShell] 提供。 */
|
/** 迷你栏背后的主页列表内容,由 [com.zhushenwudi.lovelivemusicplayer.ui.MainShell] 提供。 */
|
||||||
val LocalMiniPlayerHaze = compositionLocalOf<HazeState?> { null }
|
val LocalMiniPlayerHaze = compositionLocalOf<HazeState?> { null }
|
||||||
@@ -132,15 +147,9 @@ fun MiniPlayerBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
// 居中跑马灯歌名
|
MiniPlayerTitle(
|
||||||
Text(
|
|
||||||
text = title,
|
text = title,
|
||||||
color = titleColor,
|
color = titleColor,
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.then(
|
.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
|
@Composable
|
||||||
private fun MiniBarIconButton(
|
private fun MiniBarIconButton(
|
||||||
asset: SvgAsset,
|
asset: SvgAsset,
|
||||||
|
|||||||
+10
-2
@@ -196,7 +196,11 @@ fun SongList(
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = listBottomInset),
|
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()
|
val id = item.musicId.orEmpty()
|
||||||
SongListItem(
|
SongListItem(
|
||||||
music = item,
|
music = item,
|
||||||
@@ -267,7 +271,11 @@ private fun AlbumList(
|
|||||||
),
|
),
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
items(albums) { album ->
|
items(
|
||||||
|
items = albums,
|
||||||
|
key = { it.albumId ?: "${it.albumName}-${it.coverPath}" },
|
||||||
|
contentType = { "album" },
|
||||||
|
) { album ->
|
||||||
AlbumGridItem(
|
AlbumGridItem(
|
||||||
album = album,
|
album = album,
|
||||||
onClick = { album.albumId?.let(onOpen) },
|
onClick = { album.albumId?.let(onOpen) },
|
||||||
|
|||||||
+74
-24
@@ -1,12 +1,19 @@
|
|||||||
package com.zhushenwudi.lovelivemusicplayer.ui.theme
|
package com.zhushenwudi.lovelivemusicplayer.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
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.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalWindowInfo
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
@@ -24,31 +31,52 @@ val LocalDesignScale = staticCompositionLocalOf { 1f }
|
|||||||
/** [com.zhushenwudi.lovelivemusicplayer.ui.theme.ProvideDesignDensity] 缩放前的设备密度(供底部栏使用)。 */
|
/** [com.zhushenwudi.lovelivemusicplayer.ui.theme.ProvideDesignDensity] 缩放前的设备密度(供底部栏使用)。 */
|
||||||
val LocalUnscaledDensity = staticCompositionLocalOf<Density?> { null }
|
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 与设计尺寸一致。
|
* 按设计稿比例缩放 [androidx.compose.ui.platform.LocalDensity],使 dp/sp 与设计尺寸一致。
|
||||||
* 在应用根节点调用一次;底部栏需保持设备密度时用 [com.zhushenwudi.lovelivemusicplayer.ui.theme.ProvideUnscaledDensity] 包裹。
|
* 在应用根节点调用一次;底部栏需保持设备密度时用 [com.zhushenwudi.lovelivemusicplayer.ui.theme.ProvideUnscaledDensity] 包裹。
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ProvideDesignDensity(content: @Composable () -> Unit) {
|
fun ProvideDesignDensity(content: @Composable () -> Unit) {
|
||||||
val base = LocalDensity.current
|
val startup = rememberStartupWindowMetrics()
|
||||||
val containerPx = LocalWindowInfo.current.containerSize
|
CompositionLocalProvider(LocalStartupWindowMetrics provides startup) {
|
||||||
val heightDp = containerPx.height / base.density
|
val base = LocalDensity.current
|
||||||
// 按高度比例缩放;不低于设计尺寸;平板上限 1.35。忽略系统字体缩放(对齐 Flutter textScaler = 1.0)。
|
val containerPx = LocalWindowInfo.current.containerSize
|
||||||
val scale = remember(heightDp) {
|
val heightPx = if (containerPx.height > 0) containerPx.height else startup.heightPx
|
||||||
(heightDp / Const.UI_HEIGHT).coerceIn(1f, 1.35f)
|
if (heightPx <= 0) {
|
||||||
}
|
Box(Modifier.fillMaxSize())
|
||||||
val scaled = remember(base, scale) {
|
return@CompositionLocalProvider
|
||||||
Density(
|
}
|
||||||
density = base.density * scale,
|
val heightDp = heightPx / base.density
|
||||||
fontScale = 1f,
|
// 按高度比例缩放;不低于设计尺寸;平板上限 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,使用真实设备密度,忽略设计缩放)。 */
|
/** 状态栏顶部内边距(dp,使用真实设备密度,忽略设计缩放)。 */
|
||||||
@Composable
|
@Composable
|
||||||
fun unscaledStatusBarsTop(): Dp {
|
fun unscaledStatusBarsTop(): Dp = unscaledSystemBarPx(
|
||||||
val density = LocalUnscaledDensity.current ?: LocalDensity.current
|
composePx = WindowInsets.statusBars.getTop(systemBarDensity()),
|
||||||
return with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
startupPx = LocalStartupWindowMetrics.current.statusTopPx,
|
||||||
}
|
)
|
||||||
|
|
||||||
/** 导航栏底部内边距(dp,使用真实设备密度,忽略设计缩放)。 */
|
/** 导航栏底部内边距(dp,使用真实设备密度,忽略设计缩放)。 */
|
||||||
@Composable
|
@Composable
|
||||||
fun unscaledNavigationBarsBottom(): Dp {
|
fun unscaledNavigationBarsBottom(): Dp = unscaledSystemBarPx(
|
||||||
val density = LocalUnscaledDensity.current ?: LocalDensity.current
|
composePx = WindowInsets.navigationBars.getBottom(systemBarDensity()),
|
||||||
return with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
|
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 backButton: UIBarButtonItem!
|
||||||
private var forwardButton: UIBarButtonItem!
|
private var forwardButton: UIBarButtonItem!
|
||||||
private var refreshButton: UIBarButtonItem!
|
private var refreshButton: UIBarButtonItem!
|
||||||
|
private var adCleanupTimer: Timer?
|
||||||
|
|
||||||
init(url: String, title: String?) {
|
init(url: String, title: String?) {
|
||||||
urlString = url
|
urlString = url
|
||||||
@@ -59,15 +60,24 @@ final class InAppWebViewController: UIViewController {
|
|||||||
self.view.bringSubviewToFront(self.progressView)
|
self.view.bringSubviewToFront(self.progressView)
|
||||||
self.observeProgress(webView)
|
self.observeProgress(webView)
|
||||||
self.updateNavigationButtons()
|
self.updateNavigationButtons()
|
||||||
|
self.startAdCleanupTimer()
|
||||||
guard let url = Self.resolveURL(self.urlString) else { return }
|
guard let url = Self.resolveURL(self.urlString) else { return }
|
||||||
webView.load(URLRequest(url: url))
|
webView.load(URLRequest(url: url))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
adCleanupTimer?.invalidate()
|
||||||
progressObservation?.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() {
|
private func setupToolbar() {
|
||||||
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(toolbar)
|
view.addSubview(toolbar)
|
||||||
@@ -171,8 +181,7 @@ extension InAppWebViewController: WKNavigationDelegate {
|
|||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
progressView.isHidden = true
|
progressView.isHidden = true
|
||||||
updateNavigationButtons()
|
updateNavigationButtons()
|
||||||
let url = webView.url?.absoluteString ?? urlString
|
webView.evaluateJavaScript(WebViewAdBlock.shared.domCleanupScript(), completionHandler: nil)
|
||||||
webView.evaluateJavaScript(WebViewAdBlock.shared.injectionScript(url: url), completionHandler: nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
import ComposeApp
|
import ComposeApp
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
/// 将 Kotlin [WebViewAdBlock] 规则编译为 WKContentRuleList,并预装页面脚本。
|
/// 将 Kotlin [WebViewAdBlock] 规则编译为 WKContentRuleList。页面脚本由控制器定时执行,不预装进文档。
|
||||||
enum WebViewContentBlocker {
|
enum WebViewContentBlocker {
|
||||||
private static let identifier = "llmpAdBlock.v4"
|
private static let identifier = "llmpAdBlock.v5"
|
||||||
|
|
||||||
static func configure(
|
static func configure(
|
||||||
_ configuration: WKWebViewConfiguration,
|
_ configuration: WKWebViewConfiguration,
|
||||||
pageUrl: String,
|
pageUrl: String,
|
||||||
completion: @escaping () -> Void
|
completion: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
configuration.userContentController.addUserScript(
|
|
||||||
WKUserScript(
|
|
||||||
source: WebViewAdBlock.shared.injectionScript(url: pageUrl),
|
|
||||||
injectionTime: .atDocumentEnd,
|
|
||||||
forMainFrameOnly: true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
let json = WebViewAdBlock.shared.buildContentBlockerJson()
|
let json = WebViewAdBlock.shared.buildContentBlockerJson()
|
||||||
guard let store = WKContentRuleListStore.default() else {
|
guard let store = WKContentRuleListStore.default() else {
|
||||||
DispatchQueue.main.async { completion() }
|
DispatchQueue.main.async { completion() }
|
||||||
|
|||||||
+4
-1
@@ -22,9 +22,12 @@ object AndroidPlayerHolder {
|
|||||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||||
.setUsage(C.USAGE_MEDIA)
|
.setUsage(C.USAGE_MEDIA)
|
||||||
.build(),
|
.build(),
|
||||||
|
// 其他媒体永久抢焦点:暂停且不恢复。
|
||||||
|
// 电话/瞬时失去:暂停,焦点回来后恢复。
|
||||||
|
// 导航、通知等可闪避:音量降到约 0.2,结束后恢复原音量。
|
||||||
/* handleAudioFocus = */ true,
|
/* handleAudioFocus = */ true,
|
||||||
)
|
)
|
||||||
// 有线/蓝牙耳机拔出或断开时暂停。
|
// 有线/蓝牙耳机拔出或断开时暂停,不自动续播。媒体按键走 MediaSession。
|
||||||
.setHandleAudioBecomingNoisy(true)
|
.setHandleAudioBecomingNoisy(true)
|
||||||
.build()
|
.build()
|
||||||
player = created
|
player = created
|
||||||
|
|||||||
+27
-8
@@ -46,7 +46,8 @@ class LibraryRepository(private val db: LlmpDatabase) {
|
|||||||
val albumComparator = compareBy<Album>({ it.date.orEmpty() }, { it.albumId.orEmpty() })
|
val albumComparator = compareBy<Album>({ it.date.orEmpty() }, { it.albumId.orEmpty() })
|
||||||
|
|
||||||
// 各页正倒序由 UI 独立计算;此处只返回规范顺序。
|
// 各页正倒序由 UI 独立计算;此处只返回规范顺序。
|
||||||
val music = getAllMusic().filter { it.matches() }.sortedWith(musicComparator)
|
val allMusic = getAllMusic()
|
||||||
|
val music = allMusic.filter { it.matches() }.sortedWith(musicComparator)
|
||||||
// 可见歌曲已满足:导出筛选 +(HTTP 或本地 existFile)。
|
// 可见歌曲已满足:导出筛选 +(HTTP 或本地 existFile)。
|
||||||
val musicIds = music.mapNotNull { it.musicId }.toSet()
|
val musicIds = music.mapNotNull { it.musicId }.toSet()
|
||||||
val visibleAlbumIds = music.mapNotNull { it.albumId }.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()
|
val loved = getLovedMusic().filter { it.matches() }.asReversed()
|
||||||
// 最近播放与已筛选歌曲列表取交集。
|
// 最近播放与已筛选歌曲列表取交集。
|
||||||
// selectAllHistory 已按 timestamp DESC(最新在队首),规范顺序保持最新在前。
|
// selectAllHistory 已按 timestamp DESC(最新在队首),规范顺序保持最新在前。
|
||||||
val recent = getHistoryMusic().filter { m ->
|
val recent = historyFrom(allMusic).filter { m ->
|
||||||
m.musicId in musicIds && (allGroup || m.group == group)
|
m.musicId in musicIds && (allGroup || m.group == group)
|
||||||
}
|
}
|
||||||
return LibraryLists(
|
return LibraryLists(
|
||||||
@@ -192,10 +193,25 @@ class LibraryRepository(private val db: LlmpDatabase) {
|
|||||||
fun getLovedMusic(): List<Music> =
|
fun getLovedMusic(): List<Music> =
|
||||||
q.selectLovedMusic().executeAsList().map { it.toDomain() }
|
q.selectLovedMusic().executeAsList().map { it.toDomain() }
|
||||||
|
|
||||||
fun getHistoryMusic(): List<Music> =
|
fun getHistoryMusic(): List<Music> = historyFrom(getAllMusic())
|
||||||
q.selectAllHistory().executeAsList().mapNotNull { h ->
|
|
||||||
getMusicById(h.musicId)?.copy(timestamp = h.timestamp)
|
/**
|
||||||
|
* 最近播放里、当前团组且(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) {
|
fun upsertMusic(music: Music) {
|
||||||
val id = music.musicId ?: return
|
val id = music.musicId ?: return
|
||||||
@@ -374,11 +390,14 @@ class LibraryRepository(private val db: LlmpDatabase) {
|
|||||||
* 按 `ids` 顺序获取曲目;
|
* 按 `ids` 顺序获取曲目;
|
||||||
* HTTP 关闭时跳过缺失或非本地文件。
|
* HTTP 关闭时跳过缺失或非本地文件。
|
||||||
*/
|
*/
|
||||||
fun getMusicByIdsPreservingOrder(ids: List<String>, enableHttp: Boolean): List<Music> =
|
fun getMusicByIdsPreservingOrder(ids: List<String>, enableHttp: Boolean): List<Music> {
|
||||||
ids.mapNotNull { id ->
|
if (ids.isEmpty()) return emptyList()
|
||||||
val music = getMusicById(id) ?: return@mapNotNull null
|
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
|
if (!enableHttp && music.existFile != true) null else music
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun savePlayList(items: List<Music>, playingId: String?) {
|
fun savePlayList(items: List<Music>, playingId: String?) {
|
||||||
q.deletePlayList()
|
q.deletePlayList()
|
||||||
|
|||||||
+56
-184
@@ -5,215 +5,87 @@ import kotlinx.serialization.Serializable
|
|||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
private val contentBlockerJson = Json { explicitNulls = false }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用内 WebView 广告过滤。
|
* 对齐 Flutter 萌娘百科页:少量 URL 拦截 + 选择器隐藏,DOM 清理由原生每秒执行一次。
|
||||||
* Android:拦截请求 + 页面脚本;iOS:WKContentRuleList + UserScript。
|
* 不在页面里安装具名函数或 setInterval,避免被站点当成广告拦截脚本。
|
||||||
*/
|
*/
|
||||||
object WebViewAdBlock {
|
object WebViewAdBlock {
|
||||||
val blockedHostSuffixes: List<String> = listOf(
|
private val adUrlFilters: List<Regex> = listOf(
|
||||||
"doubleclick.net",
|
".*.doubleclick.net/.*",
|
||||||
"googlesyndication.com",
|
".*.ads.pubmatic.com/.*",
|
||||||
"googleadservices.com",
|
".*.googlesyndication.com/.*",
|
||||||
"google-analytics.com",
|
".*.google-analytics.com/.*",
|
||||||
"googletagmanager.com",
|
".*.adservice.google.*/.*",
|
||||||
"googletagservices.com",
|
".*.adbrite.com/.*",
|
||||||
"adservice.google.com",
|
".*.exponential.com/.*",
|
||||||
"pagead2.googlesyndication.com",
|
".*.quantserve.com/.*",
|
||||||
"ads.youtube.com",
|
".*.scorecardresearch.com/.*",
|
||||||
"cpro.baidu.com",
|
".*.zedo.com/.*",
|
||||||
"pos.baidu.com",
|
".*.adsafeprotected.com/.*",
|
||||||
"hm.baidu.com",
|
".*.teads.tv/.*",
|
||||||
"tanx.com",
|
".*.outbrain.com/.*",
|
||||||
"alimama.com",
|
).map { Regex(it, RegexOption.IGNORE_CASE) }
|
||||||
"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 cssHideSelectors: List<String> = listOf(
|
const val cosmeticSelector: String = ".banner, .banners, .ads, .ad, .advert"
|
||||||
"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",
|
|
||||||
)
|
|
||||||
|
|
||||||
fun isMoegirlUrl(url: String): Boolean =
|
fun isBlockedUrl(url: String): Boolean {
|
||||||
url.contains("moegirl.org", ignoreCase = true)
|
if (url.isBlank()) return false
|
||||||
|
return adUrlFilters.any { it.containsMatchIn(url) }
|
||||||
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 injectionScript(url: String): String =
|
/** 只跑一遍,由原生定时器重复调用。 */
|
||||||
if (isMoegirlUrl(url)) moegirlCleanupScript() else cssHideScript()
|
fun domCleanupScript(): String = """
|
||||||
|
|
||||||
/**
|
|
||||||
* 萌百广告清理:装一次后每秒自跑。
|
|
||||||
* 不要在 Kotlin raw string 里写 `\\.`,会变成 JS 里的字面反斜杠。
|
|
||||||
*/
|
|
||||||
fun moegirlCleanupScript(): String = """
|
|
||||||
(function() {
|
(function() {
|
||||||
if ((location.host || '').indexOf('moegirl.org') === -1) return;
|
var divElements = document.querySelectorAll('*');
|
||||||
if (window.__llmpAdblock) {
|
var keywords = ['广告', '推广', '谷歌广告', '加载中'];
|
||||||
window.__llmpAdblock();
|
for (var parent of divElements) {
|
||||||
return;
|
if (parent.className === 'adsbygoogle') {
|
||||||
}
|
parent.remove();
|
||||||
|
}
|
||||||
var KEYWORDS = ['广告','推广','推廣','廣告','谷歌广告','百度推广','百度推廣','加载中','ads','promotions'];
|
var childNodes = parent.childNodes;
|
||||||
|
if (childNodes.length <= 3) {
|
||||||
function compact(text) {
|
for (var node of childNodes) {
|
||||||
return (text || '').replace(/\s+/g, '').replace(/[×x✕✖X]/g, '').toLowerCase();
|
var elementText = node.textContent;
|
||||||
}
|
if (keywords.includes(elementText)) {
|
||||||
|
parent.remove();
|
||||||
function isBadge(el) {
|
break;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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()
|
""".trimIndent()
|
||||||
|
|
||||||
fun cssHideScript(): String {
|
/** Android 没有内容规则引擎,用一次性样式等价 CSS display none。 */
|
||||||
val selectors = cssHideSelectors.joinToString(",") { it.replace("'", "\\'") }
|
fun cosmeticStyleScript(): String = """
|
||||||
return """
|
(function() {
|
||||||
(function() {
|
var css = '$cosmeticSelector { display: none !important; }';
|
||||||
if (window.__llmpCssAdblock) return;
|
var nodes = document.getElementsByTagName('style');
|
||||||
window.__llmpCssAdblock = true;
|
for (var i = 0; i < nodes.length; i++) {
|
||||||
var selectors = '$selectors';
|
if (nodes[i].textContent === css) return;
|
||||||
function hide() {
|
}
|
||||||
try {
|
var style = document.createElement('style');
|
||||||
selectors.split(',').forEach(function(sel) {
|
style.appendChild(document.createTextNode(css));
|
||||||
document.querySelectorAll(sel).forEach(function(node) {
|
(document.head || document.documentElement).appendChild(style);
|
||||||
node.style.setProperty('display', 'none', 'important');
|
})();
|
||||||
});
|
""".trimIndent()
|
||||||
});
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
hide();
|
|
||||||
setInterval(hide, 1000);
|
|
||||||
})();
|
|
||||||
""".trimIndent()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun buildContentBlockerJson(): String {
|
fun buildContentBlockerJson(): String {
|
||||||
val rules = blockedHostSuffixes.map { host ->
|
val rules = adUrlFilters.map { pattern ->
|
||||||
ContentBlockerRule(
|
ContentBlockerRule(
|
||||||
trigger = ContentBlockerTrigger(urlFilter = ".*$host"),
|
trigger = ContentBlockerTrigger(urlFilter = pattern.pattern),
|
||||||
action = ContentBlockerAction(type = "block"),
|
action = ContentBlockerAction(type = "block"),
|
||||||
)
|
)
|
||||||
} + ContentBlockerRule(
|
} + ContentBlockerRule(
|
||||||
trigger = ContentBlockerTrigger(urlFilter = ".*"),
|
trigger = ContentBlockerTrigger(urlFilter = ".*"),
|
||||||
action = ContentBlockerAction(
|
action = ContentBlockerAction(
|
||||||
type = "css-display-none",
|
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(
|
class SleepTimer(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val onFire: () -> Unit,
|
private val onFire: suspend () -> Unit,
|
||||||
) {
|
) {
|
||||||
private val _remainingMs = MutableStateFlow(0L)
|
private val _remainingMs = MutableStateFlow(0L)
|
||||||
val remainingMs: StateFlow<Long> = _remainingMs.asStateFlow()
|
val remainingMs: StateFlow<Long> = _remainingMs.asStateFlow()
|
||||||
|
|||||||
+3
-8
@@ -98,11 +98,9 @@ class PathResolver(
|
|||||||
*/
|
*/
|
||||||
fun coverDisplayUri(music: Music): String? {
|
fun coverDisplayUri(music: Music): String? {
|
||||||
if (music.musicId == null) return null
|
if (music.musicId == null) return null
|
||||||
|
// 不在 UI 线程 stat。文件缺失时由图片加载失败落到 Logo。
|
||||||
if (music.existFile == true) {
|
if (music.existFile == true) {
|
||||||
val path = absoluteCoverPath(music)
|
absoluteCoverPath(music)?.let { return "file://$it" }
|
||||||
if (path != null && fs.exists(path)) {
|
|
||||||
return "file://$path"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (settings.enableHttp) {
|
if (settings.enableHttp) {
|
||||||
val relative = joinMediaRelativePath(music.baseUrl, music.coverPath) ?: return null
|
val relative = joinMediaRelativePath(music.baseUrl, music.coverPath) ?: return null
|
||||||
@@ -115,10 +113,7 @@ class PathResolver(
|
|||||||
fun coverDisplayUri(album: Album): String? {
|
fun coverDisplayUri(album: Album): String? {
|
||||||
val rel = album.coverPath ?: return null
|
val rel = album.coverPath ?: return null
|
||||||
if (album.existFile == true) {
|
if (album.existFile == true) {
|
||||||
val path = absoluteAlbumCoverPath(album)
|
absoluteAlbumCoverPath(album)?.let { return "file://$it" }
|
||||||
if (path != null && fs.exists(path)) {
|
|
||||||
return "file://$path"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (settings.enableHttp) {
|
if (settings.enableHttp) {
|
||||||
return joinHttpUrl(rel)
|
return joinHttpUrl(rel)
|
||||||
|
|||||||
+54
-1
@@ -13,6 +13,12 @@ import kotlinx.coroutines.isActive
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import platform.AVFAudio.AVAudioSession
|
import platform.AVFAudio.AVAudioSession
|
||||||
import platform.AVFAudio.AVAudioSessionCategoryPlayback
|
import platform.AVFAudio.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.AVAudioSessionRouteChangeNotification
|
||||||
import platform.AVFAudio.AVAudioSessionRouteChangeReasonKey
|
import platform.AVFAudio.AVAudioSessionRouteChangeReasonKey
|
||||||
import platform.AVFAudio.AVAudioSessionRouteChangeReasonOldDeviceUnavailable
|
import platform.AVFAudio.AVAudioSessionRouteChangeReasonOldDeviceUnavailable
|
||||||
@@ -82,6 +88,13 @@ actual class PlayerController {
|
|||||||
private var endObserver: Any? = null
|
private var endObserver: Any? = null
|
||||||
private var failObserver: Any? = null
|
private var failObserver: Any? = null
|
||||||
private var routeObserver: Any? = null
|
private var routeObserver: Any? = null
|
||||||
|
private var interruptionObserver: Any? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 电话、闹钟、其他音频会话打断前正在播放。
|
||||||
|
* 用户在打断期间手动暂停则清掉,结束后不再自动恢复。
|
||||||
|
*/
|
||||||
|
private var resumeAfterInterruption = false
|
||||||
|
|
||||||
private val _state = MutableStateFlow(PlayerState())
|
private val _state = MutableStateFlow(PlayerState())
|
||||||
actual val state: StateFlow<PlayerState> = _state.asStateFlow()
|
actual val state: StateFlow<PlayerState> = _state.asStateFlow()
|
||||||
@@ -120,6 +133,7 @@ actual class PlayerController {
|
|||||||
observePlaybackEnd()
|
observePlaybackEnd()
|
||||||
observePlaybackFailed()
|
observePlaybackFailed()
|
||||||
observeAudioRouteChanges()
|
observeAudioRouteChanges()
|
||||||
|
observeAudioInterruptions()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureAudioReady() {
|
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() {
|
private fun observeAudioRouteChanges() {
|
||||||
routeObserver = NSNotificationCenter.defaultCenter.addObserverForName(
|
routeObserver = NSNotificationCenter.defaultCenter.addObserverForName(
|
||||||
name = AVAudioSessionRouteChangeNotification,
|
name = AVAudioSessionRouteChangeNotification,
|
||||||
@@ -540,6 +592,7 @@ actual class PlayerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
actual fun pause() {
|
actual fun pause() {
|
||||||
|
resumeAfterInterruption = false
|
||||||
endScrub()
|
endScrub()
|
||||||
player.pause()
|
player.pause()
|
||||||
positionJob?.cancel()
|
positionJob?.cancel()
|
||||||
|
|||||||
Reference in New Issue
Block a user