Compare commits
2
Commits
e3e346b93a
...
284b9cc028
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
284b9cc028 | ||
|
|
43e9e1b916 |
@@ -6,14 +6,20 @@ 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.home_widget.HomeWidgetCommand
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.executeHomeWidgetCommand
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidShareBridge
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidDesktopLyricController
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.platform.installAndroidPlatformServices
|
||||
@@ -35,6 +41,8 @@ class MainActivity : ComponentActivity() {
|
||||
initAndroidFileSystem(applicationContext)
|
||||
|
||||
if (!AppContainer.isInitialized) {
|
||||
// Install before init() starts observing/restoring playback state.
|
||||
installAndroidPlatformServices(applicationContext)
|
||||
val settings = createAppSettings(applicationContext)
|
||||
val database = createDatabase(DriverFactory(applicationContext))
|
||||
player = PlayerController(applicationContext)
|
||||
@@ -48,7 +56,6 @@ class MainActivity : ComponentActivity() {
|
||||
player = AppContainer.player
|
||||
}
|
||||
|
||||
installAndroidPlatformServices(applicationContext)
|
||||
val lyricEnabled = AppContainer.settings.enableDesktopLyric
|
||||
if (!PlatformServices.desktopLyric.pipAutoOpen(lyricEnabled) && lyricEnabled) {
|
||||
AppContainer.settings.enableDesktopLyric = false
|
||||
@@ -57,18 +64,39 @@ class MainActivity : ComponentActivity() {
|
||||
getSharedPreferences("my_prefs", MODE_PRIVATE).getBoolean("key_agreed", false),
|
||||
)
|
||||
|
||||
getSharedPreferences(AppUtils.SP_NAME, MODE_PRIVATE).edit(commit = true) {
|
||||
putBoolean("isShutdown", false)
|
||||
}
|
||||
PlatformServices.homeWidget.setShutdown(false)
|
||||
|
||||
handleIncomingIntent(intent)
|
||||
|
||||
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)
|
||||
@@ -135,15 +163,8 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun handleWidgetUri(uri: String) {
|
||||
when {
|
||||
uri.endsWith("toggle_play") -> player?.toggle()
|
||||
uri.endsWith("next") -> player?.next()
|
||||
uri.endsWith("previous") -> player?.previous()
|
||||
uri.endsWith("toggle_love") -> {
|
||||
val id = player?.state?.value?.current?.musicId ?: return
|
||||
AppContainer.toggleLove(id)
|
||||
}
|
||||
}
|
||||
val command = HomeWidgetCommand.fromValue(uri.removePrefix(AppUtils.HOME_WIDGET_URI_PREFIX)) ?: return
|
||||
executeHomeWidgetCommand(this, command)
|
||||
}
|
||||
|
||||
private fun requestNotifyPermission() {
|
||||
|
||||
+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()
|
||||
}
|
||||
|
||||
+83
-145
@@ -8,9 +8,10 @@ import androidx.glance.LocalContext
|
||||
import androidx.glance.action.Action
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import com.zhushenwudi.lovelivemusicplayer.MainActivity
|
||||
import android.content.SharedPreferences
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
@@ -51,70 +52,32 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
||||
open var radius: Dp = 6.dp
|
||||
open val cdSize = 80.dp
|
||||
abstract var isWhite: Boolean
|
||||
var isPlaying = true
|
||||
var isShutdown = false
|
||||
private var isFavorite = false
|
||||
private lateinit var sp: SharedPreferences
|
||||
private lateinit var songName: String
|
||||
private lateinit var songArtist: String
|
||||
private lateinit var playText: String
|
||||
|
||||
private var musicColor = Color.White
|
||||
private var coverPath: String? = null
|
||||
|
||||
@Composable
|
||||
private fun widgetAction(context: Context, uri: String): Action {
|
||||
private fun openAppAction(context: Context): Action {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
data = uri.toUri()
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
data = "${AppUtils.HOME_WIDGET_URI_PREFIX}open".toUri()
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
return actionStartActivity(intent)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GlanceContent(preferences: SharedPreferences) {
|
||||
sp = preferences
|
||||
songName = sp.getString("songName", "")!!
|
||||
songArtist = sp.getString("songArtist", "")!!
|
||||
isFavorite = sp.getBoolean("songFavorite", false)
|
||||
isPlaying = sp.getBoolean("isPlaying", false)
|
||||
playText = sp.getString("playText", "")!!
|
||||
isShutdown = sp.getBoolean("isShutdown", false)
|
||||
coverPath = sp.getString("shareImage", null)
|
||||
val strBgColor = sp.getString("bgColor", "")!!
|
||||
|
||||
coverPath?.let {
|
||||
if (!File(it).exists()) {
|
||||
coverPath = null
|
||||
}
|
||||
}
|
||||
|
||||
if (strBgColor.contains(",")) {
|
||||
val bgColorArr = strBgColor.split(",")
|
||||
musicColor = Color(
|
||||
red = bgColorArr[0].toDoubleOrNull()?.toInt() ?: 1,
|
||||
green = bgColorArr[1].toDoubleOrNull()?.toInt() ?: 1,
|
||||
blue = bgColorArr[2].toDoubleOrNull()?.toInt() ?: 1
|
||||
)
|
||||
}
|
||||
|
||||
if (isShutdown) {
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
RenderAppWidget()
|
||||
private fun controlAction(state: HomeWidgetState, command: HomeWidgetCommand): Action {
|
||||
val context = LocalContext.current
|
||||
return homeWidgetControlAction(command, state.controlsEnabled) { openAppAction(context) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderAppWidget() {
|
||||
fun RenderAppWidget(state: HomeWidgetState) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(size.width)
|
||||
.height(size.height)
|
||||
) {
|
||||
if (isWhite) {
|
||||
GradientRectangle(color = musicColor)
|
||||
GradientRectangle(color = state.musicColor)
|
||||
} else {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
@@ -122,6 +85,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
||||
.width(size.width)
|
||||
.height(size.height)
|
||||
.cornerRadius(radius)
|
||||
.clickable(openAppAction(LocalContext.current))
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -138,9 +102,9 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
||||
.size(size.height)
|
||||
.cornerRadius(radius)
|
||||
) {
|
||||
RenderCD(isLargePauseWidget = false)
|
||||
RenderCover(isLargePauseWidget = false)
|
||||
RenderPlayController(false)
|
||||
RenderCD(state, isLargePauseWidget = false)
|
||||
RenderCover(state, isLargePauseWidget = false)
|
||||
RenderPlayController(state, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,20 +120,21 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
||||
RenderLogo(size = 22.dp, paddingInDp = (-6).dp)
|
||||
}
|
||||
|
||||
RenderTextArr(stateFontSize = 9.sp, infoFontSize = 11.sp)
|
||||
RenderTextArr(state, stateFontSize = 9.sp, infoFontSize = 11.sp)
|
||||
}
|
||||
|
||||
RenderFavorite(imageSize = 20.dp, paddingInDp = radius)
|
||||
RenderFavorite(state, imageSize = 20.dp, paddingInDp = radius)
|
||||
}
|
||||
}
|
||||
|
||||
open fun calcCdOffsetX(): Dp = cdSize / 2 - 10.dp
|
||||
open fun calcCdOffsetX(state: HomeWidgetState): Dp = cdSize / 2 - 10.dp
|
||||
|
||||
open fun calcCdOffsetY(): Dp = cdSize / 2 - 10.dp
|
||||
open fun calcCdOffsetY(state: HomeWidgetState): Dp = cdSize / 2 - 10.dp
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
@Composable
|
||||
open fun RenderTextArr(
|
||||
state: HomeWidgetState,
|
||||
stateFontSize: TextUnit,
|
||||
infoFontSize: TextUnit
|
||||
) {
|
||||
@@ -178,13 +143,8 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
||||
} else {
|
||||
size.width - 75.dp
|
||||
}
|
||||
val playTextArr = playText.split(",")
|
||||
var playStateText = playTextArr[1]
|
||||
if (!isShutdown && isPlaying) {
|
||||
playStateText = playTextArr[0]
|
||||
}
|
||||
Text(
|
||||
text = playStateText,
|
||||
text = state.playStateText,
|
||||
style = TextStyle(
|
||||
fontSize = stateFontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -195,7 +155,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
||||
)
|
||||
|
||||
Text(
|
||||
text = songName,
|
||||
text = state.songName,
|
||||
style = TextStyle(
|
||||
fontSize = infoFontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -206,7 +166,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
||||
)
|
||||
|
||||
Text(
|
||||
text = songArtist,
|
||||
text = state.songArtist,
|
||||
style = TextStyle(
|
||||
fontSize = infoFontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -225,26 +185,24 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
||||
modifier = GlanceModifier
|
||||
.size(size)
|
||||
.padding(start = paddingInDp)
|
||||
.clickable(openAppAction(LocalContext.current))
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
@Composable
|
||||
private fun RenderTransportButton(
|
||||
state: HomeWidgetState,
|
||||
drawable: Int,
|
||||
description: String,
|
||||
uri: String,
|
||||
command: HomeWidgetCommand,
|
||||
imageSize: Dp,
|
||||
) {
|
||||
val base = GlanceModifier.size(imageSize)
|
||||
val modifier = if (isShutdown) {
|
||||
base
|
||||
} else {
|
||||
base.clickable(
|
||||
val modifier = GlanceModifier.size(imageSize)
|
||||
.clickable(
|
||||
rippleOverride = R.drawable.click_ripple,
|
||||
onClick = widgetAction(LocalContext.current, uri),
|
||||
onClick = controlAction(state, command),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier,
|
||||
@@ -259,56 +217,45 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
open fun RenderCD(isLargePauseWidget: Boolean) {
|
||||
open fun RenderCD(state: HomeWidgetState, isLargePauseWidget: Boolean) {
|
||||
val cdDrawable = if (isWhite) R.drawable.cd_white else R.drawable.cd_black
|
||||
val offsetX = calcCdOffsetX()
|
||||
val offsetY = calcCdOffsetY()
|
||||
val offsetX = calcCdOffsetX(state)
|
||||
val offsetY = calcCdOffsetY(state)
|
||||
|
||||
val startPadding = if (isLargePauseWidget) 0.dp else offsetX
|
||||
val radio = if (isLargePauseWidget) 0.9f else 1f
|
||||
if (isShutdown) {
|
||||
Image(
|
||||
provider = ImageProvider(cdDrawable),
|
||||
contentDescription = "cd",
|
||||
modifier = GlanceModifier
|
||||
.size(size.height * radio)
|
||||
.padding(
|
||||
start = startPadding,
|
||||
top = -offsetY,
|
||||
end = -offsetX,
|
||||
bottom = offsetY
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
provider = ImageProvider(cdDrawable),
|
||||
contentDescription = "cd",
|
||||
modifier = GlanceModifier
|
||||
.size(size.height * radio)
|
||||
.padding(
|
||||
start = startPadding,
|
||||
top = -offsetY,
|
||||
end = -offsetX,
|
||||
bottom = offsetY
|
||||
)
|
||||
.clickable(rippleOverride = R.drawable.click_ripple, onClick = widgetAction(LocalContext.current, "${AppUtils.HOME_WIDGET_URI_PREFIX}toggle_play"))
|
||||
)
|
||||
}
|
||||
Image(
|
||||
provider = ImageProvider(cdDrawable),
|
||||
contentDescription = "cd",
|
||||
modifier = GlanceModifier
|
||||
.size(size.height * radio)
|
||||
.padding(
|
||||
start = startPadding,
|
||||
top = -offsetY,
|
||||
end = -offsetX,
|
||||
bottom = offsetY
|
||||
)
|
||||
.clickable(
|
||||
rippleOverride = R.drawable.click_ripple,
|
||||
onClick = controlAction(state, HomeWidgetCommand.TOGGLE_PLAY),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
open fun RenderCover(isLargePauseWidget: Boolean) {
|
||||
open fun RenderCover(state: HomeWidgetState, isLargePauseWidget: Boolean) {
|
||||
val coverSize = cdSize * 0.4f
|
||||
val coverAndCdDiffSize = (cdSize - coverSize) / 2
|
||||
val offsetX = calcCdOffsetX()
|
||||
val offsetY = calcCdOffsetY()
|
||||
val offsetX = calcCdOffsetX(state)
|
||||
val offsetY = calcCdOffsetY(state)
|
||||
val startPadding =
|
||||
if (isLargePauseWidget) coverAndCdDiffSize else offsetX + coverAndCdDiffSize
|
||||
val radio = if (isLargePauseWidget) 0.95f else 1f
|
||||
coverPath?.let {
|
||||
state.coverPath?.let {
|
||||
if (File(it).exists()) {
|
||||
val bitmap = BitmapFactory.decodeFile(it) ?: return
|
||||
Image(
|
||||
provider = ImageProvider(BitmapFactory.decodeFile(it)),
|
||||
provider = ImageProvider(bitmap),
|
||||
contentDescription = "cover",
|
||||
modifier = GlanceModifier
|
||||
.size(size.height * radio)
|
||||
@@ -325,21 +272,14 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
@Composable
|
||||
open fun RenderPlayController(isLargePauseWidget: Boolean) {
|
||||
val resId = if (isPlaying) R.drawable.vector_pause else R.drawable.vector_play
|
||||
val toggleAction = widgetAction(
|
||||
LocalContext.current,
|
||||
"${AppUtils.HOME_WIDGET_URI_PREFIX}toggle_play",
|
||||
)
|
||||
open fun RenderPlayController(state: HomeWidgetState, isLargePauseWidget: Boolean) {
|
||||
val resId = if (state.isPlaying) R.drawable.vector_pause else R.drawable.vector_play
|
||||
val toggleAction = controlAction(state, HomeWidgetCommand.TOGGLE_PLAY)
|
||||
|
||||
if (isLargePauseWidget) {
|
||||
val modifier = if (isShutdown) {
|
||||
GlanceModifier.size(20.dp)
|
||||
} else {
|
||||
GlanceModifier
|
||||
.size(20.dp)
|
||||
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
|
||||
}
|
||||
val modifier = GlanceModifier
|
||||
.size(20.dp)
|
||||
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
|
||||
Image(
|
||||
provider = ImageProvider(resId),
|
||||
contentDescription = "playButton",
|
||||
@@ -349,13 +289,9 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
||||
} else {
|
||||
val padding =
|
||||
if (size.width == AppUtils.HORIZONTAL_RECTANGLE.width) 26.dp else 23.dp
|
||||
val playModifier = if (isShutdown) {
|
||||
GlanceModifier.size(15.dp)
|
||||
} else {
|
||||
GlanceModifier
|
||||
.size(15.dp)
|
||||
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
|
||||
}
|
||||
val playModifier = GlanceModifier
|
||||
.size(15.dp)
|
||||
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
|
||||
Box(
|
||||
contentAlignment = Alignment.TopEnd,
|
||||
modifier = GlanceModifier
|
||||
@@ -374,7 +310,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
open fun RenderFavorite(imageSize: Dp, paddingInDp: Dp) {
|
||||
open fun RenderFavorite(state: HomeWidgetState, imageSize: Dp, paddingInDp: Dp) {
|
||||
val isLarge = size.width == AppUtils.HORIZONTAL_RECTANGLE.width
|
||||
Box(
|
||||
contentAlignment = Alignment.BottomEnd,
|
||||
@@ -386,35 +322,30 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (isLarge) {
|
||||
RenderTransportButton(
|
||||
state = state,
|
||||
drawable = R.drawable.player_play_prev,
|
||||
description = "previous",
|
||||
uri = "${AppUtils.HOME_WIDGET_URI_PREFIX}previous",
|
||||
command = HomeWidgetCommand.PREVIOUS,
|
||||
imageSize = imageSize,
|
||||
)
|
||||
Spacer(GlanceModifier.width(8.dp))
|
||||
}
|
||||
RenderTransportButton(
|
||||
state = state,
|
||||
drawable = R.drawable.player_play_next,
|
||||
description = "next",
|
||||
uri = "${AppUtils.HOME_WIDGET_URI_PREFIX}next",
|
||||
command = HomeWidgetCommand.NEXT,
|
||||
imageSize = imageSize,
|
||||
)
|
||||
Spacer(GlanceModifier.width(8.dp))
|
||||
val loveIcon =
|
||||
if (isFavorite) R.drawable.widget_fav_click else R.drawable.widget_fav_unclick
|
||||
val favoriteModifier = if (isShutdown) {
|
||||
GlanceModifier.size(imageSize)
|
||||
} else {
|
||||
GlanceModifier
|
||||
.size(imageSize)
|
||||
.clickable(
|
||||
rippleOverride = R.drawable.click_ripple,
|
||||
onClick = widgetAction(
|
||||
LocalContext.current,
|
||||
"${AppUtils.HOME_WIDGET_URI_PREFIX}toggle_love",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (state.isFavorite) R.drawable.widget_fav_click else R.drawable.widget_fav_unclick
|
||||
val favoriteModifier = GlanceModifier
|
||||
.size(imageSize)
|
||||
.clickable(
|
||||
rippleOverride = R.drawable.click_ripple,
|
||||
onClick = controlAction(state, HomeWidgetCommand.TOGGLE_FAVORITE),
|
||||
)
|
||||
Image(
|
||||
provider = ImageProvider(loveIcon),
|
||||
contentDescription = "love",
|
||||
@@ -433,6 +364,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
||||
.width(size.width)
|
||||
.height(size.height)
|
||||
.cornerRadius(radius)
|
||||
.clickable(openAppAction(LocalContext.current))
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(bitmap),
|
||||
@@ -446,7 +378,13 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val preferences = context.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
|
||||
provideContent { GlanceContent(preferences = preferences) }
|
||||
val states = preferences.widgetStates()
|
||||
val initialState = HomeWidgetState.fromPreferences(preferences.all)
|
||||
provideContent {
|
||||
val state by states.collectAsState(initialState)
|
||||
val running by HomeWidgetRuntime.isRunning.collectAsState()
|
||||
RenderAppWidget(state.withAppRunning(running))
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package com.zhushenwudi.lovelivemusicplayer.native.home_widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.action.Action
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.actionParametersOf
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import com.zhushenwudi.lovelivemusicplayer.AppContainer
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidHomeWidgetController
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
|
||||
import com.zhushenwudi.lovelivemusicplayer.player.PlaybackService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Process-local availability: a persisted widget snapshot cannot prove the player is alive. */
|
||||
internal object HomeWidgetRuntime {
|
||||
private val running = MutableStateFlow(false)
|
||||
val isRunning = running.asStateFlow()
|
||||
|
||||
fun setRunning(value: Boolean) {
|
||||
running.value = value
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class HomeWidgetCommand(val value: String) {
|
||||
TOGGLE_PLAY("toggle_play"),
|
||||
PREVIOUS("previous"),
|
||||
NEXT("next"),
|
||||
TOGGLE_FAVORITE("toggle_love");
|
||||
|
||||
companion object {
|
||||
val key = ActionParameters.Key<String>("widget_command")
|
||||
|
||||
fun fromValue(value: String?): HomeWidgetCommand? = entries.firstOrNull { it.value == value }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun homeWidgetControlAction(
|
||||
command: HomeWidgetCommand,
|
||||
controlsEnabled: Boolean,
|
||||
openApp: () -> Action,
|
||||
): Action = if (controlsEnabled) {
|
||||
actionRunCallback<HomeWidgetPlaybackAction>(actionParametersOf(HomeWidgetCommand.key to command.value))
|
||||
} else {
|
||||
openApp()
|
||||
}
|
||||
|
||||
/** Glance delivers this callback without bringing MainActivity to the foreground. */
|
||||
class HomeWidgetPlaybackAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
val command = HomeWidgetCommand.fromValue(parameters[HomeWidgetCommand.key]) ?: return
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
val shutdown = context.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
|
||||
.getBoolean("isShutdown", false)
|
||||
// The process may have died since RemoteViews were published. Do not replay playback
|
||||
// commands on a cold start; refresh the controls into their open-app state instead.
|
||||
if (AppContainer.isInitialized && HomeWidgetRuntime.isRunning.value && !shutdown) {
|
||||
executeHomeWidgetCommand(context, command)
|
||||
}
|
||||
}
|
||||
AndroidHomeWidgetController(context).refresh()
|
||||
}
|
||||
}
|
||||
|
||||
/** Both legacy activity intents and widget callbacks execute ExoPlayer commands on the main thread. */
|
||||
internal fun executeHomeWidgetCommand(context: Context, command: HomeWidgetCommand) {
|
||||
if (!AppContainer.isInitialized) return
|
||||
val player = AppContainer.player
|
||||
when (command) {
|
||||
HomeWidgetCommand.TOGGLE_PLAY -> {
|
||||
if (!player.state.value.isPlaying) PlaybackService.start(context)
|
||||
player.toggle()
|
||||
}
|
||||
HomeWidgetCommand.PREVIOUS -> player.previous()
|
||||
HomeWidgetCommand.NEXT -> player.next()
|
||||
HomeWidgetCommand.TOGGLE_FAVORITE -> {
|
||||
val id = player.state.value.current?.musicId ?: return
|
||||
AppContainer.toggleLove(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.zhushenwudi.lovelivemusicplayer.native.home_widget
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
/** Immutable snapshots also invalidate an already running Glance composition. */
|
||||
data class HomeWidgetState(
|
||||
val songName: String = "",
|
||||
val songArtist: String = "",
|
||||
val isFavorite: Boolean = false,
|
||||
val isPlaying: Boolean = false,
|
||||
val isShutdown: Boolean = false,
|
||||
val controlsEnabled: Boolean = false,
|
||||
val playText: String = "Playing,Paused",
|
||||
val coverPath: String? = null,
|
||||
val backgroundColor: String = "",
|
||||
) {
|
||||
fun withAppRunning(running: Boolean): HomeWidgetState = copy(
|
||||
controlsEnabled = running && !isShutdown,
|
||||
isPlaying = isPlaying && running && !isShutdown,
|
||||
)
|
||||
|
||||
val playStateText: String
|
||||
get() {
|
||||
val labels = playText.split(',', limit = 2)
|
||||
val index = if (isPlaying) 0 else 1
|
||||
return labels.getOrNull(index)?.takeIf { it.isNotBlank() }
|
||||
?: if (isPlaying) "Playing" else "Paused"
|
||||
}
|
||||
|
||||
val musicColor: Color
|
||||
get() {
|
||||
val components = backgroundColor.split(',').map { it.toFloatOrNull() }
|
||||
if (components.size != 3 || components.any { it == null || !it.isFinite() }) {
|
||||
return Color.White
|
||||
}
|
||||
return Color(
|
||||
red = components[0]!!.coerceIn(0f, 1f),
|
||||
green = components[1]!!.coerceIn(0f, 1f),
|
||||
blue = components[2]!!.coerceIn(0f, 1f),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromPreferences(values: Map<String, *>): HomeWidgetState {
|
||||
val shutdown = values["isShutdown"] == true
|
||||
return HomeWidgetState(
|
||||
songName = values["songName"] as? String ?: "",
|
||||
songArtist = values["songArtist"] as? String ?: "",
|
||||
isFavorite = values["songFavorite"] == true,
|
||||
isPlaying = values["isPlaying"] == true && !shutdown,
|
||||
isShutdown = shutdown,
|
||||
playText = values["playText"] as? String ?: "Playing,Paused",
|
||||
coverPath = values["shareImage"] as? String,
|
||||
backgroundColor = values["bgColor"] as? String ?: "",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun SharedPreferences.widgetStates() = callbackFlow {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { preferences, _ ->
|
||||
trySend(HomeWidgetState.fromPreferences(preferences.all))
|
||||
}
|
||||
// Register first so a write during the initial read cannot be missed.
|
||||
registerOnSharedPreferenceChangeListener(listener)
|
||||
trySend(HomeWidgetState.fromPreferences(all))
|
||||
awaitClose { unregisterOnSharedPreferenceChangeListener(listener) }
|
||||
}.conflate().distinctUntilChanged()
|
||||
+15
-18
@@ -8,6 +8,7 @@ import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetContent
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetState
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
|
||||
|
||||
@SuppressLint("CommitPrefEdits", "DefaultLocale")
|
||||
@@ -17,43 +18,39 @@ abstract class LargeHomeWidgetContent(
|
||||
override var cdSize: Dp = 120.dp,
|
||||
) : HomeWidgetContent() {
|
||||
|
||||
private fun isLargePauseWidget(): Boolean {
|
||||
return !isPlaying || isShutdown
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun RenderLogo(size: Dp, paddingInDp: Dp) {
|
||||
super.RenderLogo(size = 30.dp, paddingInDp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun RenderCD(isLargePauseWidget: Boolean) {
|
||||
super.RenderCD(isLargePauseWidget = isLargePauseWidget())
|
||||
override fun RenderCD(state: HomeWidgetState, isLargePauseWidget: Boolean) {
|
||||
super.RenderCD(state, isLargePauseWidget = !state.isPlaying)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun RenderCover(isLargePauseWidget: Boolean) {
|
||||
super.RenderCover(isLargePauseWidget = isLargePauseWidget())
|
||||
override fun RenderCover(state: HomeWidgetState, isLargePauseWidget: Boolean) {
|
||||
super.RenderCover(state, isLargePauseWidget = !state.isPlaying)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun RenderPlayController(isLargePauseWidget: Boolean) {
|
||||
super.RenderPlayController(isLargePauseWidget = isLargePauseWidget())
|
||||
override fun RenderPlayController(state: HomeWidgetState, isLargePauseWidget: Boolean) {
|
||||
super.RenderPlayController(state, isLargePauseWidget = !state.isPlaying)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun RenderTextArr(stateFontSize: TextUnit, infoFontSize: TextUnit) {
|
||||
super.RenderTextArr(stateFontSize = 12.sp, infoFontSize = 13.sp)
|
||||
override fun RenderTextArr(state: HomeWidgetState, stateFontSize: TextUnit, infoFontSize: TextUnit) {
|
||||
super.RenderTextArr(state, stateFontSize = 12.sp, infoFontSize = 13.sp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun RenderFavorite(imageSize: Dp, paddingInDp: Dp) {
|
||||
super.RenderFavorite(imageSize = 28.dp, paddingInDp = radius / 2)
|
||||
override fun RenderFavorite(state: HomeWidgetState, imageSize: Dp, paddingInDp: Dp) {
|
||||
super.RenderFavorite(state, imageSize = 28.dp, paddingInDp = radius / 2)
|
||||
}
|
||||
|
||||
override fun calcCdOffsetX(): Dp =
|
||||
if (isPlaying) cdSize / 2 + 5.dp else 12.dp
|
||||
override fun calcCdOffsetX(state: HomeWidgetState): Dp =
|
||||
if (state.isPlaying) cdSize / 2 + 5.dp else 12.dp
|
||||
|
||||
override fun calcCdOffsetY(): Dp =
|
||||
if (isPlaying) cdSize / 2 + 5.dp else 12.dp
|
||||
override fun calcCdOffsetY(state: HomeWidgetState): Dp =
|
||||
if (state.isPlaying) cdSize / 2 + 5.dp else 12.dp
|
||||
}
|
||||
|
||||
+68
-28
@@ -4,12 +4,15 @@ import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetRuntime
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget.BlackLargeHomeWidgetReceiver
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget.WhiteLargeHomeWidgetReceiver
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget.BlackSmallHomeWidgetReceiver
|
||||
@@ -18,6 +21,8 @@ import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.util.ImageUtil
|
||||
import com.zhushenwudi.lovelivemusicplayer.platform.HomeWidgetController
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
class AndroidHomeWidgetController(
|
||||
context: Context,
|
||||
@@ -25,6 +30,7 @@ class AndroidHomeWidgetController(
|
||||
private val appContext = context.applicationContext
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var preImagePath: String? = null
|
||||
private var coverRevision = 0L
|
||||
private var lastContent: WidgetContent? = null
|
||||
|
||||
private val receiverClasses = listOf(
|
||||
@@ -34,6 +40,7 @@ class AndroidHomeWidgetController(
|
||||
BlackLargeHomeWidgetReceiver::class.java,
|
||||
)
|
||||
|
||||
@Synchronized
|
||||
override fun update(
|
||||
songName: String?,
|
||||
artist: String?,
|
||||
@@ -50,28 +57,38 @@ class AndroidHomeWidgetController(
|
||||
playText = playText,
|
||||
)
|
||||
val contentChanged = content != lastContent
|
||||
val coverChanged = !coverPath.isNullOrBlank() && coverPath != preImagePath
|
||||
val requestedCover = coverPath?.takeIf { it.isNotBlank() }
|
||||
val coverChanged = requestedCover != preImagePath || (lastContent == null && requestedCover == null)
|
||||
if (!contentChanged && !coverChanged) return
|
||||
|
||||
if (contentChanged) {
|
||||
lastContent = content
|
||||
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
|
||||
.edit(commit = true) {
|
||||
putString("songName", content.songName)
|
||||
putString("songArtist", content.artist)
|
||||
putBoolean("songFavorite", content.favorite)
|
||||
putBoolean("isPlaying", content.isPlaying)
|
||||
putString("playText", content.playText)
|
||||
if (content.songName.isNotBlank() || content.isPlaying) {
|
||||
putBoolean("isShutdown", false)
|
||||
}
|
||||
}
|
||||
refresh()
|
||||
if (coverChanged) {
|
||||
preImagePath = requestedCover
|
||||
coverRevision++
|
||||
}
|
||||
if (coverChanged) coverPath?.let(::processCover)
|
||||
lastContent = content
|
||||
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
|
||||
.edit(commit = true) {
|
||||
putString("songName", content.songName)
|
||||
putString("songArtist", content.artist)
|
||||
putBoolean("songFavorite", content.favorite)
|
||||
putBoolean("isPlaying", content.isPlaying)
|
||||
putString("playText", content.playText)
|
||||
if (coverChanged || content.songName.isBlank()) {
|
||||
remove("shareImage")
|
||||
remove("bgColor")
|
||||
}
|
||||
if (content.songName.isNotBlank() || content.isPlaying) {
|
||||
putBoolean("isShutdown", false)
|
||||
}
|
||||
}
|
||||
refresh()
|
||||
if (coverChanged && requestedCover != null) processCover(requestedCover, coverRevision)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun setShutdown(shutdown: Boolean) {
|
||||
HomeWidgetRuntime.setRunning(!shutdown)
|
||||
lastContent = null
|
||||
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
|
||||
.edit(commit = true) {
|
||||
putBoolean("isShutdown", shutdown)
|
||||
@@ -88,22 +105,45 @@ class AndroidHomeWidgetController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun processCover(coverPath: String) {
|
||||
if (preImagePath == coverPath || !File(coverPath).exists()) return
|
||||
preImagePath = coverPath
|
||||
private fun processCover(coverPath: String, revision: Long) {
|
||||
scope.launch {
|
||||
var originBitmap: Bitmap? = null
|
||||
var coverBitmap: Bitmap? = null
|
||||
var coverFile: File? = null
|
||||
var published = false
|
||||
try {
|
||||
val originBitmap = BitmapFactory.decodeFile(coverPath)
|
||||
val coverBitmap = ImageUtil.squareAndCircularBitmap(originBitmap, 180)
|
||||
val handledCoverPath = ImageUtil.saveBitmapToFile(appContext, coverBitmap)
|
||||
ImageUtil.savePathToSp(appContext, handledCoverPath)
|
||||
originBitmap = BitmapFactory.decodeFile(coverPath) ?: return@launch
|
||||
coverBitmap = ImageUtil.squareAndCircularBitmap(originBitmap, 180)
|
||||
val color = ImageUtil.parsePhotoDomainColor(coverBitmap)
|
||||
ImageUtil.saveColorToSp(appContext, color)
|
||||
originBitmap.recycle()
|
||||
coverBitmap.recycle()
|
||||
} catch (_: Exception) {
|
||||
// Publish only complete files; keep one cached bitmap per source, not per update.
|
||||
val stagedFile = File(appContext.cacheDir, "widget-cover-${UUID.randomUUID()}.tmp")
|
||||
coverFile = stagedFile
|
||||
stagedFile.outputStream().use { output ->
|
||||
check(coverBitmap.compress(Bitmap.CompressFormat.PNG, 100, output))
|
||||
}
|
||||
synchronized(this@AndroidHomeWidgetController) {
|
||||
// A slow decode for a previous track must not replace the current artwork.
|
||||
if (revision != coverRevision) return@launch
|
||||
val cacheKey = UUID.nameUUIDFromBytes(coverPath.toByteArray(Charsets.UTF_8))
|
||||
val cachedFile = File(appContext.cacheDir, "widget-cover-$cacheKey.png")
|
||||
if (!cachedFile.exists()) check(stagedFile.renameTo(cachedFile))
|
||||
val preferences = appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
|
||||
preferences.edit(commit = true) {
|
||||
putString("shareImage", cachedFile.absolutePath)
|
||||
putString("bgColor", String.format(Locale.US, "%.4f,%.4f,%.4f", color.red, color.green, color.blue))
|
||||
}
|
||||
published = true
|
||||
refresh()
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
Log.w("LLMP-Widget", "Unable to prepare widget cover", error)
|
||||
} finally {
|
||||
refresh()
|
||||
originBitmap?.recycle()
|
||||
coverBitmap?.recycle()
|
||||
coverFile?.delete()
|
||||
if (!published) synchronized(this@AndroidHomeWidgetController) {
|
||||
if (revision == coverRevision) preImagePath = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -167,6 +167,7 @@ class PlaybackService : MediaSessionService() {
|
||||
private var instance: PlaybackService? = null
|
||||
|
||||
fun start(context: Context) {
|
||||
if (instance != null) return
|
||||
val intent = Intent(context, PlaybackService::class.java)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
||||
+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
|
||||
}
|
||||
+31
-14
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -573,7 +590,7 @@ object AppContainer {
|
||||
homeWidgetJob = scope.launch {
|
||||
player.state
|
||||
.map { st ->
|
||||
Triple(st.current?.musicId, st.isPlaying, st.current?.isLove == true)
|
||||
st.current to st.isPlaying
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
@@ -658,13 +675,13 @@ object AppContainer {
|
||||
updateCover: Boolean = true,
|
||||
) {
|
||||
val st = player.state.value
|
||||
val current = st.current ?: return
|
||||
val coverPath = if (updateCover) widgetCoverCache.resolveForWidget(current) else null
|
||||
val current = st.current
|
||||
val coverPath = if (updateCover && current != null) widgetCoverCache.resolveForWidget(current) else null
|
||||
PlatformServices.homeWidget.update(
|
||||
songName = current.musicName,
|
||||
artist = current.artist,
|
||||
songName = current?.musicName,
|
||||
artist = current?.artist,
|
||||
isPlaying = st.isPlaying,
|
||||
favorite = current.isLove == true,
|
||||
favorite = current?.isLove == true,
|
||||
coverPath = coverPath,
|
||||
playText = I18n.t("play_text"),
|
||||
)
|
||||
|
||||
+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