Compare commits

...
2 Commits
Author SHA1 Message Date
zhushenwudi 284b9cc028 fix android desktop card bugs 2026-09-26 12:58:21 +08:00
zhushenwudi 43e9e1b916 fix moegirl ad blocker and optimize func 2026-09-26 12:58:12 +08:00
23 changed files with 889 additions and 513 deletions
@@ -6,14 +6,20 @@ 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.home_widget.HomeWidgetCommand
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.executeHomeWidgetCommand
import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidShareBridge import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidShareBridge
import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidDesktopLyricController import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidDesktopLyricController
import com.zhushenwudi.lovelivemusicplayer.native.platform.installAndroidPlatformServices import com.zhushenwudi.lovelivemusicplayer.native.platform.installAndroidPlatformServices
@@ -35,6 +41,8 @@ class MainActivity : ComponentActivity() {
initAndroidFileSystem(applicationContext) initAndroidFileSystem(applicationContext)
if (!AppContainer.isInitialized) { if (!AppContainer.isInitialized) {
// Install before init() starts observing/restoring playback state.
installAndroidPlatformServices(applicationContext)
val settings = createAppSettings(applicationContext) val settings = createAppSettings(applicationContext)
val database = createDatabase(DriverFactory(applicationContext)) val database = createDatabase(DriverFactory(applicationContext))
player = PlayerController(applicationContext) player = PlayerController(applicationContext)
@@ -48,7 +56,6 @@ class MainActivity : ComponentActivity() {
player = AppContainer.player player = AppContainer.player
} }
installAndroidPlatformServices(applicationContext)
val lyricEnabled = AppContainer.settings.enableDesktopLyric val lyricEnabled = AppContainer.settings.enableDesktopLyric
if (!PlatformServices.desktopLyric.pipAutoOpen(lyricEnabled) && lyricEnabled) { if (!PlatformServices.desktopLyric.pipAutoOpen(lyricEnabled) && lyricEnabled) {
AppContainer.settings.enableDesktopLyric = false AppContainer.settings.enableDesktopLyric = false
@@ -57,18 +64,39 @@ class MainActivity : ComponentActivity() {
getSharedPreferences("my_prefs", MODE_PRIVATE).getBoolean("key_agreed", false), getSharedPreferences("my_prefs", MODE_PRIVATE).getBoolean("key_agreed", false),
) )
getSharedPreferences(AppUtils.SP_NAME, MODE_PRIVATE).edit(commit = true) { PlatformServices.homeWidget.setShutdown(false)
putBoolean("isShutdown", false)
}
handleIncomingIntent(intent) handleIncomingIntent(intent)
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)
@@ -135,15 +163,8 @@ class MainActivity : ComponentActivity() {
} }
private fun handleWidgetUri(uri: String) { private fun handleWidgetUri(uri: String) {
when { val command = HomeWidgetCommand.fromValue(uri.removePrefix(AppUtils.HOME_WIDGET_URI_PREFIX)) ?: return
uri.endsWith("toggle_play") -> player?.toggle() executeHomeWidgetCommand(this, command)
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)
}
}
} }
private fun requestNotifyPermission() { private fun requestNotifyPermission() {
@@ -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()
} }
@@ -8,9 +8,10 @@ import androidx.glance.LocalContext
import androidx.glance.action.Action import androidx.glance.action.Action
import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.appwidget.action.actionStartActivity
import com.zhushenwudi.lovelivemusicplayer.MainActivity import com.zhushenwudi.lovelivemusicplayer.MainActivity
import android.content.SharedPreferences
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.compose.runtime.Composable 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.graphics.Color
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
@@ -51,70 +52,32 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
open var radius: Dp = 6.dp open var radius: Dp = 6.dp
open val cdSize = 80.dp open val cdSize = 80.dp
abstract var isWhite: Boolean 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 fun openAppAction(context: Context): Action {
private var coverPath: String? = null
@Composable
private fun widgetAction(context: Context, uri: String): Action {
val intent = Intent(context, MainActivity::class.java).apply { val intent = Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW action = Intent.ACTION_VIEW
data = uri.toUri() data = "${AppUtils.HOME_WIDGET_URI_PREFIX}open".toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP
} }
return actionStartActivity(intent) return actionStartActivity(intent)
} }
@Composable @Composable
fun GlanceContent(preferences: SharedPreferences) { private fun controlAction(state: HomeWidgetState, command: HomeWidgetCommand): Action {
sp = preferences val context = LocalContext.current
songName = sp.getString("songName", "")!! return homeWidgetControlAction(command, state.controlsEnabled) { openAppAction(context) }
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()
} }
@Composable @Composable
fun RenderAppWidget() { fun RenderAppWidget(state: HomeWidgetState) {
Box( Box(
modifier = GlanceModifier modifier = GlanceModifier
.width(size.width) .width(size.width)
.height(size.height) .height(size.height)
) { ) {
if (isWhite) { if (isWhite) {
GradientRectangle(color = musicColor) GradientRectangle(color = state.musicColor)
} else { } else {
Box( Box(
modifier = GlanceModifier modifier = GlanceModifier
@@ -122,6 +85,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
.width(size.width) .width(size.width)
.height(size.height) .height(size.height)
.cornerRadius(radius) .cornerRadius(radius)
.clickable(openAppAction(LocalContext.current))
) {} ) {}
} }
@@ -138,9 +102,9 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
.size(size.height) .size(size.height)
.cornerRadius(radius) .cornerRadius(radius)
) { ) {
RenderCD(isLargePauseWidget = false) RenderCD(state, isLargePauseWidget = false)
RenderCover(isLargePauseWidget = false) RenderCover(state, isLargePauseWidget = false)
RenderPlayController(false) RenderPlayController(state, false)
} }
} }
@@ -156,20 +120,21 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
RenderLogo(size = 22.dp, paddingInDp = (-6).dp) 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") @SuppressLint("RestrictedApi")
@Composable @Composable
open fun RenderTextArr( open fun RenderTextArr(
state: HomeWidgetState,
stateFontSize: TextUnit, stateFontSize: TextUnit,
infoFontSize: TextUnit infoFontSize: TextUnit
) { ) {
@@ -178,13 +143,8 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
} else { } else {
size.width - 75.dp size.width - 75.dp
} }
val playTextArr = playText.split(",")
var playStateText = playTextArr[1]
if (!isShutdown && isPlaying) {
playStateText = playTextArr[0]
}
Text( Text(
text = playStateText, text = state.playStateText,
style = TextStyle( style = TextStyle(
fontSize = stateFontSize, fontSize = stateFontSize,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -195,7 +155,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
) )
Text( Text(
text = songName, text = state.songName,
style = TextStyle( style = TextStyle(
fontSize = infoFontSize, fontSize = infoFontSize,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -206,7 +166,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
) )
Text( Text(
text = songArtist, text = state.songArtist,
style = TextStyle( style = TextStyle(
fontSize = infoFontSize, fontSize = infoFontSize,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -225,26 +185,24 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
modifier = GlanceModifier modifier = GlanceModifier
.size(size) .size(size)
.padding(start = paddingInDp) .padding(start = paddingInDp)
.clickable(openAppAction(LocalContext.current))
) )
} }
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
@Composable @Composable
private fun RenderTransportButton( private fun RenderTransportButton(
state: HomeWidgetState,
drawable: Int, drawable: Int,
description: String, description: String,
uri: String, command: HomeWidgetCommand,
imageSize: Dp, imageSize: Dp,
) { ) {
val base = GlanceModifier.size(imageSize) val modifier = GlanceModifier.size(imageSize)
val modifier = if (isShutdown) { .clickable(
base
} else {
base.clickable(
rippleOverride = R.drawable.click_ripple, rippleOverride = R.drawable.click_ripple,
onClick = widgetAction(LocalContext.current, uri), onClick = controlAction(state, command),
) )
}
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = modifier, modifier = modifier,
@@ -259,14 +217,13 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
} }
@Composable @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 cdDrawable = if (isWhite) R.drawable.cd_white else R.drawable.cd_black
val offsetX = calcCdOffsetX() val offsetX = calcCdOffsetX(state)
val offsetY = calcCdOffsetY() val offsetY = calcCdOffsetY(state)
val startPadding = if (isLargePauseWidget) 0.dp else offsetX val startPadding = if (isLargePauseWidget) 0.dp else offsetX
val radio = if (isLargePauseWidget) 0.9f else 1f val radio = if (isLargePauseWidget) 0.9f else 1f
if (isShutdown) {
Image( Image(
provider = ImageProvider(cdDrawable), provider = ImageProvider(cdDrawable),
contentDescription = "cd", contentDescription = "cd",
@@ -278,37 +235,27 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
end = -offsetX, end = -offsetX,
bottom = offsetY bottom = offsetY
) )
.clickable(
rippleOverride = R.drawable.click_ripple,
onClick = controlAction(state, HomeWidgetCommand.TOGGLE_PLAY),
) )
} 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"))
)
}
} }
@Composable @Composable
open fun RenderCover(isLargePauseWidget: Boolean) { open fun RenderCover(state: HomeWidgetState, isLargePauseWidget: Boolean) {
val coverSize = cdSize * 0.4f val coverSize = cdSize * 0.4f
val coverAndCdDiffSize = (cdSize - coverSize) / 2 val coverAndCdDiffSize = (cdSize - coverSize) / 2
val offsetX = calcCdOffsetX() val offsetX = calcCdOffsetX(state)
val offsetY = calcCdOffsetY() val offsetY = calcCdOffsetY(state)
val startPadding = val startPadding =
if (isLargePauseWidget) coverAndCdDiffSize else offsetX + coverAndCdDiffSize if (isLargePauseWidget) coverAndCdDiffSize else offsetX + coverAndCdDiffSize
val radio = if (isLargePauseWidget) 0.95f else 1f val radio = if (isLargePauseWidget) 0.95f else 1f
coverPath?.let { state.coverPath?.let {
if (File(it).exists()) { if (File(it).exists()) {
val bitmap = BitmapFactory.decodeFile(it) ?: return
Image( Image(
provider = ImageProvider(BitmapFactory.decodeFile(it)), provider = ImageProvider(bitmap),
contentDescription = "cover", contentDescription = "cover",
modifier = GlanceModifier modifier = GlanceModifier
.size(size.height * radio) .size(size.height * radio)
@@ -325,21 +272,14 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
@Composable @Composable
open fun RenderPlayController(isLargePauseWidget: Boolean) { open fun RenderPlayController(state: HomeWidgetState, isLargePauseWidget: Boolean) {
val resId = if (isPlaying) R.drawable.vector_pause else R.drawable.vector_play val resId = if (state.isPlaying) R.drawable.vector_pause else R.drawable.vector_play
val toggleAction = widgetAction( val toggleAction = controlAction(state, HomeWidgetCommand.TOGGLE_PLAY)
LocalContext.current,
"${AppUtils.HOME_WIDGET_URI_PREFIX}toggle_play",
)
if (isLargePauseWidget) { if (isLargePauseWidget) {
val modifier = if (isShutdown) { val modifier = GlanceModifier
GlanceModifier.size(20.dp)
} else {
GlanceModifier
.size(20.dp) .size(20.dp)
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction) .clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
}
Image( Image(
provider = ImageProvider(resId), provider = ImageProvider(resId),
contentDescription = "playButton", contentDescription = "playButton",
@@ -349,13 +289,9 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
} else { } else {
val padding = val padding =
if (size.width == AppUtils.HORIZONTAL_RECTANGLE.width) 26.dp else 23.dp if (size.width == AppUtils.HORIZONTAL_RECTANGLE.width) 26.dp else 23.dp
val playModifier = if (isShutdown) { val playModifier = GlanceModifier
GlanceModifier.size(15.dp)
} else {
GlanceModifier
.size(15.dp) .size(15.dp)
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction) .clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
}
Box( Box(
contentAlignment = Alignment.TopEnd, contentAlignment = Alignment.TopEnd,
modifier = GlanceModifier modifier = GlanceModifier
@@ -374,7 +310,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
} }
@Composable @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 val isLarge = size.width == AppUtils.HORIZONTAL_RECTANGLE.width
Box( Box(
contentAlignment = Alignment.BottomEnd, contentAlignment = Alignment.BottomEnd,
@@ -386,35 +322,30 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (isLarge) { if (isLarge) {
RenderTransportButton( RenderTransportButton(
state = state,
drawable = R.drawable.player_play_prev, drawable = R.drawable.player_play_prev,
description = "previous", description = "previous",
uri = "${AppUtils.HOME_WIDGET_URI_PREFIX}previous", command = HomeWidgetCommand.PREVIOUS,
imageSize = imageSize, imageSize = imageSize,
) )
Spacer(GlanceModifier.width(8.dp)) Spacer(GlanceModifier.width(8.dp))
} }
RenderTransportButton( RenderTransportButton(
state = state,
drawable = R.drawable.player_play_next, drawable = R.drawable.player_play_next,
description = "next", description = "next",
uri = "${AppUtils.HOME_WIDGET_URI_PREFIX}next", command = HomeWidgetCommand.NEXT,
imageSize = imageSize, imageSize = imageSize,
) )
Spacer(GlanceModifier.width(8.dp)) Spacer(GlanceModifier.width(8.dp))
val loveIcon = val loveIcon =
if (isFavorite) R.drawable.widget_fav_click else R.drawable.widget_fav_unclick if (state.isFavorite) R.drawable.widget_fav_click else R.drawable.widget_fav_unclick
val favoriteModifier = if (isShutdown) { val favoriteModifier = GlanceModifier
GlanceModifier.size(imageSize)
} else {
GlanceModifier
.size(imageSize) .size(imageSize)
.clickable( .clickable(
rippleOverride = R.drawable.click_ripple, rippleOverride = R.drawable.click_ripple,
onClick = widgetAction( onClick = controlAction(state, HomeWidgetCommand.TOGGLE_FAVORITE),
LocalContext.current,
"${AppUtils.HOME_WIDGET_URI_PREFIX}toggle_love",
),
) )
}
Image( Image(
provider = ImageProvider(loveIcon), provider = ImageProvider(loveIcon),
contentDescription = "love", contentDescription = "love",
@@ -433,6 +364,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
.width(size.width) .width(size.width)
.height(size.height) .height(size.height)
.cornerRadius(radius) .cornerRadius(radius)
.clickable(openAppAction(LocalContext.current))
) { ) {
Image( Image(
provider = ImageProvider(bitmap), provider = ImageProvider(bitmap),
@@ -446,7 +378,13 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val preferences = context.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE) 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 { private companion object {
@@ -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)
}
}
}
@@ -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()
@@ -8,6 +8,7 @@ import androidx.compose.ui.unit.TextUnit
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 com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetContent import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetContent
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetState
import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
@SuppressLint("CommitPrefEdits", "DefaultLocale") @SuppressLint("CommitPrefEdits", "DefaultLocale")
@@ -17,43 +18,39 @@ abstract class LargeHomeWidgetContent(
override var cdSize: Dp = 120.dp, override var cdSize: Dp = 120.dp,
) : HomeWidgetContent() { ) : HomeWidgetContent() {
private fun isLargePauseWidget(): Boolean {
return !isPlaying || isShutdown
}
@Composable @Composable
override fun RenderLogo(size: Dp, paddingInDp: Dp) { override fun RenderLogo(size: Dp, paddingInDp: Dp) {
super.RenderLogo(size = 30.dp, paddingInDp) super.RenderLogo(size = 30.dp, paddingInDp)
} }
@Composable @Composable
override fun RenderCD(isLargePauseWidget: Boolean) { override fun RenderCD(state: HomeWidgetState, isLargePauseWidget: Boolean) {
super.RenderCD(isLargePauseWidget = isLargePauseWidget()) super.RenderCD(state, isLargePauseWidget = !state.isPlaying)
} }
@Composable @Composable
override fun RenderCover(isLargePauseWidget: Boolean) { override fun RenderCover(state: HomeWidgetState, isLargePauseWidget: Boolean) {
super.RenderCover(isLargePauseWidget = isLargePauseWidget()) super.RenderCover(state, isLargePauseWidget = !state.isPlaying)
} }
@Composable @Composable
override fun RenderPlayController(isLargePauseWidget: Boolean) { override fun RenderPlayController(state: HomeWidgetState, isLargePauseWidget: Boolean) {
super.RenderPlayController(isLargePauseWidget = isLargePauseWidget()) super.RenderPlayController(state, isLargePauseWidget = !state.isPlaying)
} }
@Composable @Composable
override fun RenderTextArr(stateFontSize: TextUnit, infoFontSize: TextUnit) { override fun RenderTextArr(state: HomeWidgetState, stateFontSize: TextUnit, infoFontSize: TextUnit) {
super.RenderTextArr(stateFontSize = 12.sp, infoFontSize = 13.sp) super.RenderTextArr(state, stateFontSize = 12.sp, infoFontSize = 13.sp)
} }
@Composable @Composable
override fun RenderFavorite(imageSize: Dp, paddingInDp: Dp) { override fun RenderFavorite(state: HomeWidgetState, imageSize: Dp, paddingInDp: Dp) {
super.RenderFavorite(imageSize = 28.dp, paddingInDp = radius / 2) super.RenderFavorite(state, imageSize = 28.dp, paddingInDp = radius / 2)
} }
override fun calcCdOffsetX(): Dp = override fun calcCdOffsetX(state: HomeWidgetState): Dp =
if (isPlaying) cdSize / 2 + 5.dp else 12.dp if (state.isPlaying) cdSize / 2 + 5.dp else 12.dp
override fun calcCdOffsetY(): Dp = override fun calcCdOffsetY(state: HomeWidgetState): Dp =
if (isPlaying) cdSize / 2 + 5.dp else 12.dp if (state.isPlaying) cdSize / 2 + 5.dp else 12.dp
} }
@@ -4,12 +4,15 @@ import android.appwidget.AppWidgetManager
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.util.Log
import androidx.core.content.edit import androidx.core.content.edit
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch 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.BlackLargeHomeWidgetReceiver
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget.WhiteLargeHomeWidgetReceiver import com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget.WhiteLargeHomeWidgetReceiver
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget.BlackSmallHomeWidgetReceiver 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.native.util.ImageUtil
import com.zhushenwudi.lovelivemusicplayer.platform.HomeWidgetController import com.zhushenwudi.lovelivemusicplayer.platform.HomeWidgetController
import java.io.File import java.io.File
import java.util.Locale
import java.util.UUID
class AndroidHomeWidgetController( class AndroidHomeWidgetController(
context: Context, context: Context,
@@ -25,6 +30,7 @@ class AndroidHomeWidgetController(
private val appContext = context.applicationContext private val appContext = context.applicationContext
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var preImagePath: String? = null private var preImagePath: String? = null
private var coverRevision = 0L
private var lastContent: WidgetContent? = null private var lastContent: WidgetContent? = null
private val receiverClasses = listOf( private val receiverClasses = listOf(
@@ -34,6 +40,7 @@ class AndroidHomeWidgetController(
BlackLargeHomeWidgetReceiver::class.java, BlackLargeHomeWidgetReceiver::class.java,
) )
@Synchronized
override fun update( override fun update(
songName: String?, songName: String?,
artist: String?, artist: String?,
@@ -50,10 +57,14 @@ class AndroidHomeWidgetController(
playText = playText, playText = playText,
) )
val contentChanged = content != lastContent 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 && !coverChanged) return
if (contentChanged) { if (coverChanged) {
preImagePath = requestedCover
coverRevision++
}
lastContent = content lastContent = content
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE) appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
.edit(commit = true) { .edit(commit = true) {
@@ -62,16 +73,22 @@ class AndroidHomeWidgetController(
putBoolean("songFavorite", content.favorite) putBoolean("songFavorite", content.favorite)
putBoolean("isPlaying", content.isPlaying) putBoolean("isPlaying", content.isPlaying)
putString("playText", content.playText) putString("playText", content.playText)
if (coverChanged || content.songName.isBlank()) {
remove("shareImage")
remove("bgColor")
}
if (content.songName.isNotBlank() || content.isPlaying) { if (content.songName.isNotBlank() || content.isPlaying) {
putBoolean("isShutdown", false) putBoolean("isShutdown", false)
} }
} }
refresh() refresh()
} if (coverChanged && requestedCover != null) processCover(requestedCover, coverRevision)
if (coverChanged) coverPath?.let(::processCover)
} }
@Synchronized
override fun setShutdown(shutdown: Boolean) { override fun setShutdown(shutdown: Boolean) {
HomeWidgetRuntime.setRunning(!shutdown)
lastContent = null
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE) appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
.edit(commit = true) { .edit(commit = true) {
putBoolean("isShutdown", shutdown) putBoolean("isShutdown", shutdown)
@@ -88,23 +105,46 @@ class AndroidHomeWidgetController(
} }
} }
private fun processCover(coverPath: String) { private fun processCover(coverPath: String, revision: Long) {
if (preImagePath == coverPath || !File(coverPath).exists()) return
preImagePath = coverPath
scope.launch { scope.launch {
var originBitmap: Bitmap? = null
var coverBitmap: Bitmap? = null
var coverFile: File? = null
var published = false
try { try {
val originBitmap = BitmapFactory.decodeFile(coverPath) originBitmap = BitmapFactory.decodeFile(coverPath) ?: return@launch
val coverBitmap = ImageUtil.squareAndCircularBitmap(originBitmap, 180) coverBitmap = ImageUtil.squareAndCircularBitmap(originBitmap, 180)
val handledCoverPath = ImageUtil.saveBitmapToFile(appContext, coverBitmap)
ImageUtil.savePathToSp(appContext, handledCoverPath)
val color = ImageUtil.parsePhotoDomainColor(coverBitmap) val color = ImageUtil.parsePhotoDomainColor(coverBitmap)
ImageUtil.saveColorToSp(appContext, color) // Publish only complete files; keep one cached bitmap per source, not per update.
originBitmap.recycle() val stagedFile = File(appContext.cacheDir, "widget-cover-${UUID.randomUUID()}.tmp")
coverBitmap.recycle() coverFile = stagedFile
} catch (_: Exception) { stagedFile.outputStream().use { output ->
} finally { 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() refresh()
} }
} catch (error: Exception) {
Log.w("LLMP-Widget", "Unable to prepare widget cover", error)
} finally {
originBitmap?.recycle()
coverBitmap?.recycle()
coverFile?.delete()
if (!published) synchronized(this@AndroidHomeWidgetController) {
if (revision == coverRevision) preImagePath = null
}
}
} }
} }
@@ -167,6 +167,7 @@ class PlaybackService : MediaSessionService() {
private var instance: PlaybackService? = null private var instance: PlaybackService? = null
fun start(context: Context) { fun start(context: Context) {
if (instance != null) return
val intent = Intent(context, PlaybackService::class.java) val intent = Intent(context, PlaybackService::class.java)
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} }
@@ -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
}
@@ -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)
if (key != recentKey) {
recentKey = key
// 冷启动这一次交给延后的曲库快照,避免和首屏抢数据库。
if (skipStartupRecent) {
skipStartupRecent = false
} else {
val recent = library.recentVisible(
group = "all", group = "all",
enableHttp = settings.enableHttp, enableHttp = settings.enableHttp,
).recent.withSortOrder(settings.recentSortOrder) ).withSortOrder(settings.recentSortOrder)
PlatformServices.carPlayCatalog.updateRecent(recent, cover) PlatformServices.carPlayCatalog.updateRecent(recent, cover)
}
}
PlatformServices.carPlay.refreshNowPlaying() PlatformServices.carPlay.refreshNowPlaying()
} }
} }
@@ -573,7 +590,7 @@ object AppContainer {
homeWidgetJob = scope.launch { homeWidgetJob = scope.launch {
player.state player.state
.map { st -> .map { st ->
Triple(st.current?.musicId, st.isPlaying, st.current?.isLove == true) st.current to st.isPlaying
} }
.distinctUntilChanged() .distinctUntilChanged()
.collect { .collect {
@@ -658,13 +675,13 @@ object AppContainer {
updateCover: Boolean = true, updateCover: Boolean = true,
) { ) {
val st = player.state.value val st = player.state.value
val current = st.current ?: return val current = st.current
val coverPath = if (updateCover) widgetCoverCache.resolveForWidget(current) else null val coverPath = if (updateCover && current != null) widgetCoverCache.resolveForWidget(current) else null
PlatformServices.homeWidget.update( PlatformServices.homeWidget.update(
songName = current.musicName, songName = current?.musicName,
artist = current.artist, artist = current?.artist,
isPlaying = st.isPlaying, isPlaying = st.isPlaying,
favorite = current.isLove == true, favorite = current?.isLove == true,
coverPath = coverPath, coverPath = coverPath,
playText = I18n.t("play_text"), playText = I18n.t("play_text"),
) )
@@ -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(historyRevision, currentGroup, enableHttp) { LaunchedEffect(Unit) {
if (historyRevision == 0L) return@LaunchedEffect delay(1_500)
homeHazeEnabled = true
val groupAtStart = currentGroup.displayName
val httpAtStart = enableHttp
val changed = withContext(Dispatchers.IO) {
runCatching { runCatching {
AppContainer.library.findAllListByGroup( AppContainer.library.rescanExistFiles(AppContainer.pathResolver)
group = currentGroup.displayName, }.getOrDefault(0)
enableHttp = enableHttp, }
).recent if (changed > 0 &&
}.getOrNull()?.let { recent = it } 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 == 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) { LaunchedEffect(loveRevision, currentGroup, enableHttp) {
if (loveRevision == 0L) return@LaunchedEffect if (loveRevision == startupLoveRevision) return@LaunchedEffect
val groupName = currentGroup.displayName
val http = enableHttp
val lists = withContext(Dispatchers.Default) {
runCatching { runCatching {
AppContainer.library.findAllListByGroup( AppContainer.library.findAllListByGroup(groupName, http)
group = currentGroup.displayName, }.getOrNull()
enableHttp = enableHttp, } ?: return@LaunchedEffect
)
}.getOrNull()?.let { lists ->
loved = lists.loved loved = lists.loved
// 同步歌曲/最近播放行的 isLove 与 Music.isLove 列。 music = lists.music
fun patch(list: List<Music>) = list.map { m -> recent = lists.recent
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)
}
} }
// 搜索模式:在歌曲/收藏/最近播放中按歌名过滤。 // 搜索模式:在歌曲/收藏/最近播放中按歌名过滤。
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,
@@ -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,
@@ -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) },
@@ -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,20 +31,40 @@ 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 startup = rememberStartupWindowMetrics()
CompositionLocalProvider(LocalStartupWindowMetrics provides startup) {
val base = LocalDensity.current val base = LocalDensity.current
val containerPx = LocalWindowInfo.current.containerSize val containerPx = LocalWindowInfo.current.containerSize
val heightDp = containerPx.height / base.density val heightPx = if (containerPx.height > 0) containerPx.height else startup.heightPx
// 按高度比例缩放;不低于设计尺寸;平板上限 1.35。忽略系统字体缩放(对齐 Flutter textScaler = 1.0)。 if (heightPx <= 0) {
val scale = remember(heightDp) { Box(Modifier.fillMaxSize())
(heightDp / Const.UI_HEIGHT).coerceIn(1f, 1.35f) return@CompositionLocalProvider
} }
val scaled = remember(base, scale) { 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(
density = base.density * scale, density = base.density * scale,
fontScale = 1f, fontScale = 1f,
@@ -49,6 +76,7 @@ fun ProvideDesignDensity(content: @Composable () -> Unit) {
LocalDesignScale provides scale, LocalDesignScale provides scale,
content = content, 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() }
} }
/** /**
@@ -0,0 +1,6 @@
package com.zhushenwudi.lovelivemusicplayer.ui.theme
import androidx.compose.runtime.Composable
@Composable
internal actual fun rememberStartupWindowMetrics(): StartupWindowMetrics = StartupWindowMetrics()
+11 -2
View File
@@ -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) {
+2 -10
View File
@@ -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() }
@@ -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
@@ -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,9 +193,24 @@ 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) {
@@ -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()
@@ -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 childNodes = parent.childNodes;
var KEYWORDS = ['广告','推广','推廣','廣告','谷歌广告','百度推广','百度推廣','加载中','ads','promotions']; if (childNodes.length <= 3) {
for (var node of childNodes) {
function compact(text) { var elementText = node.textContent;
return (text || '').replace(/\s+/g, '').replace(/[×x✕✖X]/g, '').toLowerCase(); if (keywords.includes(elementText)) {
} parent.remove();
break;
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;
} }
} }
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() {
if (window.__llmpCssAdblock) return; var css = '$cosmeticSelector { display: none !important; }';
window.__llmpCssAdblock = true; var nodes = document.getElementsByTagName('style');
var selectors = '$selectors'; for (var i = 0; i < nodes.length; i++) {
function hide() { if (nodes[i].textContent === css) return;
try {
selectors.split(',').forEach(function(sel) {
document.querySelectorAll(sel).forEach(function(node) {
node.style.setProperty('display', 'none', 'important');
});
});
} catch (e) {}
} }
hide(); var style = document.createElement('style');
setInterval(hide, 1000); style.appendChild(document.createTextNode(css));
(document.head || document.documentElement).appendChild(style);
})(); })();
""".trimIndent() """.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)
} }
} }
@@ -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()
@@ -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)
@@ -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()