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.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() {
|
||||||
|
|||||||
+29
-5
@@ -11,6 +11,7 @@ import android.webkit.WebViewClient
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.addCallback
|
import androidx.activity.addCallback
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
@@ -28,16 +30,21 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
import com.zhushenwudi.lovelivemusicplayer.i18n.tr
|
import com.zhushenwudi.lovelivemusicplayer.i18n.tr
|
||||||
import com.zhushenwudi.lovelivemusicplayer.platform.WebViewAdBlock
|
import com.zhushenwudi.lovelivemusicplayer.platform.WebViewAdBlock
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
class WebViewActivity : ComponentActivity() {
|
class WebViewActivity : ComponentActivity() {
|
||||||
private var webView: WebView? = null
|
private var webView: WebView? = null
|
||||||
@@ -48,6 +55,7 @@ class WebViewActivity : ComponentActivity() {
|
|||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
val url = intent.getStringExtra(EXTRA_URL).orEmpty()
|
val url = intent.getStringExtra(EXTRA_URL).orEmpty()
|
||||||
val title = intent.getStringExtra(EXTRA_TITLE)
|
val title = intent.getStringExtra(EXTRA_TITLE)
|
||||||
title?.let { setTitle(it) }
|
title?.let { setTitle(it) }
|
||||||
@@ -67,7 +75,22 @@ class WebViewActivity : ComponentActivity() {
|
|||||||
val progress by pageProgress
|
val progress by pageProgress
|
||||||
|
|
||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
Column(Modifier.fillMaxSize()) {
|
SideEffect {
|
||||||
|
WindowCompat.getInsetsController(window, window.decorView)
|
||||||
|
.isAppearanceLightStatusBars = true
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
delay(1_000)
|
||||||
|
webView?.evaluateJavascript(WebViewAdBlock.domCleanupScript(), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.White)
|
||||||
|
.statusBarsPadding(),
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -162,8 +185,8 @@ class WebViewActivity : ComponentActivity() {
|
|||||||
view: WebView?,
|
view: WebView?,
|
||||||
request: WebResourceRequest?,
|
request: WebResourceRequest?,
|
||||||
): WebResourceResponse? {
|
): WebResourceResponse? {
|
||||||
val host = request?.url?.host
|
val target = request?.url?.toString().orEmpty()
|
||||||
if (!host.isNullOrBlank() && WebViewAdBlock.isBlockedHost(host)) {
|
if (WebViewAdBlock.isBlockedUrl(target)) {
|
||||||
return WebResourceResponse(
|
return WebResourceResponse(
|
||||||
"text/plain",
|
"text/plain",
|
||||||
"utf-8",
|
"utf-8",
|
||||||
@@ -180,8 +203,9 @@ class WebViewActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onPageFinished(view: WebView?, url: String?) {
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
super.onPageFinished(view, url)
|
super.onPageFinished(view, url)
|
||||||
if (view != null && !url.isNullOrBlank()) {
|
if (view != null) {
|
||||||
view.evaluateJavascript(WebViewAdBlock.injectionScript(url), null)
|
view.evaluateJavascript(WebViewAdBlock.cosmeticStyleScript(), null)
|
||||||
|
view.evaluateJavascript(WebViewAdBlock.domCleanupScript(), null)
|
||||||
}
|
}
|
||||||
onNavigationChanged()
|
onNavigationChanged()
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-124
@@ -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 {
|
||||||
|
|||||||
+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.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
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-16
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+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
|
||||||
|
}
|
||||||
+28
-11
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|||||||
+72
-39
@@ -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,
|
||||||
|
|||||||
+95
-7
@@ -1,22 +1,29 @@
|
|||||||
package com.zhushenwudi.lovelivemusicplayer.ui.player
|
package com.zhushenwudi.lovelivemusicplayer.ui.player
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -27,13 +34,20 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.HazeTint
|
import dev.chrisbanes.haze.HazeTint
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
@@ -48,6 +62,7 @@ import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset
|
|||||||
import com.zhushenwudi.lovelivemusicplayer.ui.icons.TintIcon
|
import com.zhushenwudi.lovelivemusicplayer.ui.icons.TintIcon
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
/** 迷你栏背后的主页列表内容,由 [com.zhushenwudi.lovelivemusicplayer.ui.MainShell] 提供。 */
|
/** 迷你栏背后的主页列表内容,由 [com.zhushenwudi.lovelivemusicplayer.ui.MainShell] 提供。 */
|
||||||
val LocalMiniPlayerHaze = compositionLocalOf<HazeState?> { null }
|
val LocalMiniPlayerHaze = compositionLocalOf<HazeState?> { null }
|
||||||
@@ -132,15 +147,9 @@ fun MiniPlayerBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
// 居中跑马灯歌名
|
MiniPlayerTitle(
|
||||||
Text(
|
|
||||||
text = title,
|
text = title,
|
||||||
color = titleColor,
|
color = titleColor,
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.then(
|
.then(
|
||||||
@@ -205,6 +214,85 @@ fun MiniPlayerBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val MiniTitleStyle = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 略慢于常见 30dp/s 走马灯。 */
|
||||||
|
private val MiniTitleMarqueeVelocity = 20.dp
|
||||||
|
|
||||||
|
private const val MiniTitleMarqueeHoldMs = 2_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 歌名宽于显示区域时走马灯:左侧对齐停 2 秒,匀速滚到右侧对齐再停 2 秒,
|
||||||
|
* 然后跳回起点,再等 2 秒继续。未超出时居中静止。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun MiniPlayerTitle(
|
||||||
|
text: String,
|
||||||
|
color: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val textMeasurer = rememberTextMeasurer()
|
||||||
|
val style = MiniTitleStyle.copy(color = color)
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = modifier.clipToBounds(),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
val maxWidthPx = constraints.maxWidth
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val textWidthPx = remember(text, style, textMeasurer, maxWidthPx) {
|
||||||
|
textMeasurer.measure(
|
||||||
|
text = text,
|
||||||
|
style = style,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
overflow = TextOverflow.Visible,
|
||||||
|
).size.width
|
||||||
|
}
|
||||||
|
val overflowPx = (textWidthPx - maxWidthPx).coerceAtLeast(0)
|
||||||
|
if (overflowPx <= 0) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = style,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val offsetX = remember(text) { Animatable(0f) }
|
||||||
|
LaunchedEffect(text, overflowPx) {
|
||||||
|
val velocityPxPerSec = with(density) { MiniTitleMarqueeVelocity.toPx() }
|
||||||
|
val durationMs = (overflowPx / velocityPxPerSec * 1_000f)
|
||||||
|
.toInt()
|
||||||
|
.coerceAtLeast(1)
|
||||||
|
offsetX.snapTo(0f)
|
||||||
|
while (true) {
|
||||||
|
delay(MiniTitleMarqueeHoldMs.toLong().milliseconds)
|
||||||
|
offsetX.animateTo(
|
||||||
|
targetValue = -overflowPx.toFloat(),
|
||||||
|
animationSpec = tween(durationMs, easing = LinearEasing),
|
||||||
|
)
|
||||||
|
delay(MiniTitleMarqueeHoldMs.toLong().milliseconds)
|
||||||
|
offsetX.snapTo(0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = style,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
overflow = TextOverflow.Visible,
|
||||||
|
modifier = Modifier
|
||||||
|
.wrapContentWidth(unbounded = true, align = Alignment.Start)
|
||||||
|
.offset { IntOffset(offsetX.value.roundToInt(), 0) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MiniBarIconButton(
|
private fun MiniBarIconButton(
|
||||||
asset: SvgAsset,
|
asset: SvgAsset,
|
||||||
|
|||||||
+10
-2
@@ -196,7 +196,11 @@ fun SongList(
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = listBottomInset),
|
contentPadding = PaddingValues(bottom = listBottomInset),
|
||||||
) {
|
) {
|
||||||
items(music) { item ->
|
items(
|
||||||
|
items = music,
|
||||||
|
key = { it.musicId ?: "${it.musicName}-${it.musicPath}-${it.albumId}" },
|
||||||
|
contentType = { "song" },
|
||||||
|
) { item ->
|
||||||
val id = item.musicId.orEmpty()
|
val id = item.musicId.orEmpty()
|
||||||
SongListItem(
|
SongListItem(
|
||||||
music = item,
|
music = item,
|
||||||
@@ -267,7 +271,11 @@ private fun AlbumList(
|
|||||||
),
|
),
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
items(albums) { album ->
|
items(
|
||||||
|
items = albums,
|
||||||
|
key = { it.albumId ?: "${it.albumName}-${it.coverPath}" },
|
||||||
|
contentType = { "album" },
|
||||||
|
) { album ->
|
||||||
AlbumGridItem(
|
AlbumGridItem(
|
||||||
album = album,
|
album = album,
|
||||||
onClick = { album.albumId?.let(onOpen) },
|
onClick = { album.albumId?.let(onOpen) },
|
||||||
|
|||||||
+62
-12
@@ -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,
|
||||||
@@ -50,6 +77,7 @@ fun ProvideDesignDensity(content: @Composable () -> Unit) {
|
|||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 恢复设备密度,保持底部栏物理尺寸不变。 */
|
/** 恢复设备密度,保持底部栏物理尺寸不变。 */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -71,16 +99,38 @@ fun Dp.asUnscaledInScaledContext(): Dp {
|
|||||||
|
|
||||||
/** 状态栏顶部内边距(dp,使用真实设备密度,忽略设计缩放)。 */
|
/** 状态栏顶部内边距(dp,使用真实设备密度,忽略设计缩放)。 */
|
||||||
@Composable
|
@Composable
|
||||||
fun unscaledStatusBarsTop(): Dp {
|
fun unscaledStatusBarsTop(): Dp = unscaledSystemBarPx(
|
||||||
val density = LocalUnscaledDensity.current ?: LocalDensity.current
|
composePx = WindowInsets.statusBars.getTop(systemBarDensity()),
|
||||||
return with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
startupPx = LocalStartupWindowMetrics.current.statusTopPx,
|
||||||
}
|
)
|
||||||
|
|
||||||
/** 导航栏底部内边距(dp,使用真实设备密度,忽略设计缩放)。 */
|
/** 导航栏底部内边距(dp,使用真实设备密度,忽略设计缩放)。 */
|
||||||
@Composable
|
@Composable
|
||||||
fun unscaledNavigationBarsBottom(): Dp {
|
fun unscaledNavigationBarsBottom(): Dp = unscaledSystemBarPx(
|
||||||
val density = LocalUnscaledDensity.current ?: LocalDensity.current
|
composePx = WindowInsets.navigationBars.getBottom(systemBarDensity()),
|
||||||
return with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
|
startupPx = LocalStartupWindowMetrics.current.navBottomPx,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun systemBarDensity(): Density =
|
||||||
|
LocalUnscaledDensity.current ?: LocalDensity.current
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose 首帧系统栏 inset 常为 0。在它真正派发之前用启动时的窗口度量,
|
||||||
|
* 派发之后以 Compose 为准(含沉浸式隐藏)。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun unscaledSystemBarPx(composePx: Int, startupPx: Int): Dp {
|
||||||
|
var dispatched by remember { mutableStateOf(false) }
|
||||||
|
SideEffect {
|
||||||
|
if (composePx > 0) dispatched = true
|
||||||
|
}
|
||||||
|
val px = when {
|
||||||
|
composePx > 0 -> composePx
|
||||||
|
dispatched -> 0
|
||||||
|
else -> startupPx
|
||||||
|
}
|
||||||
|
return with(systemBarDensity()) { px.toDp() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
package com.zhushenwudi.lovelivemusicplayer.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal actual fun rememberStartupWindowMetrics(): StartupWindowMetrics = StartupWindowMetrics()
|
||||||
@@ -13,6 +13,7 @@ final class InAppWebViewController: UIViewController {
|
|||||||
private var backButton: UIBarButtonItem!
|
private var backButton: UIBarButtonItem!
|
||||||
private var forwardButton: UIBarButtonItem!
|
private var forwardButton: UIBarButtonItem!
|
||||||
private var refreshButton: UIBarButtonItem!
|
private var refreshButton: UIBarButtonItem!
|
||||||
|
private var adCleanupTimer: Timer?
|
||||||
|
|
||||||
init(url: String, title: String?) {
|
init(url: String, title: String?) {
|
||||||
urlString = url
|
urlString = url
|
||||||
@@ -59,15 +60,24 @@ final class InAppWebViewController: UIViewController {
|
|||||||
self.view.bringSubviewToFront(self.progressView)
|
self.view.bringSubviewToFront(self.progressView)
|
||||||
self.observeProgress(webView)
|
self.observeProgress(webView)
|
||||||
self.updateNavigationButtons()
|
self.updateNavigationButtons()
|
||||||
|
self.startAdCleanupTimer()
|
||||||
guard let url = Self.resolveURL(self.urlString) else { return }
|
guard let url = Self.resolveURL(self.urlString) else { return }
|
||||||
webView.load(URLRequest(url: url))
|
webView.load(URLRequest(url: url))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
adCleanupTimer?.invalidate()
|
||||||
progressObservation?.invalidate()
|
progressObservation?.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func startAdCleanupTimer() {
|
||||||
|
adCleanupTimer?.invalidate()
|
||||||
|
adCleanupTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||||
|
self?.webView?.evaluateJavaScript(WebViewAdBlock.shared.domCleanupScript(), completionHandler: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func setupToolbar() {
|
private func setupToolbar() {
|
||||||
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(toolbar)
|
view.addSubview(toolbar)
|
||||||
@@ -171,8 +181,7 @@ extension InAppWebViewController: WKNavigationDelegate {
|
|||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
progressView.isHidden = true
|
progressView.isHidden = true
|
||||||
updateNavigationButtons()
|
updateNavigationButtons()
|
||||||
let url = webView.url?.absoluteString ?? urlString
|
webView.evaluateJavaScript(WebViewAdBlock.shared.domCleanupScript(), completionHandler: nil)
|
||||||
webView.evaluateJavaScript(WebViewAdBlock.shared.injectionScript(url: url), completionHandler: nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
import ComposeApp
|
import ComposeApp
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
/// 将 Kotlin [WebViewAdBlock] 规则编译为 WKContentRuleList,并预装页面脚本。
|
/// 将 Kotlin [WebViewAdBlock] 规则编译为 WKContentRuleList。页面脚本由控制器定时执行,不预装进文档。
|
||||||
enum WebViewContentBlocker {
|
enum WebViewContentBlocker {
|
||||||
private static let identifier = "llmpAdBlock.v4"
|
private static let identifier = "llmpAdBlock.v5"
|
||||||
|
|
||||||
static func configure(
|
static func configure(
|
||||||
_ configuration: WKWebViewConfiguration,
|
_ configuration: WKWebViewConfiguration,
|
||||||
pageUrl: String,
|
pageUrl: String,
|
||||||
completion: @escaping () -> Void
|
completion: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
configuration.userContentController.addUserScript(
|
|
||||||
WKUserScript(
|
|
||||||
source: WebViewAdBlock.shared.injectionScript(url: pageUrl),
|
|
||||||
injectionTime: .atDocumentEnd,
|
|
||||||
forMainFrameOnly: true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
let json = WebViewAdBlock.shared.buildContentBlockerJson()
|
let json = WebViewAdBlock.shared.buildContentBlockerJson()
|
||||||
guard let store = WKContentRuleListStore.default() else {
|
guard let store = WKContentRuleListStore.default() else {
|
||||||
DispatchQueue.main.async { completion() }
|
DispatchQueue.main.async { completion() }
|
||||||
|
|||||||
+4
-1
@@ -22,9 +22,12 @@ object AndroidPlayerHolder {
|
|||||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||||
.setUsage(C.USAGE_MEDIA)
|
.setUsage(C.USAGE_MEDIA)
|
||||||
.build(),
|
.build(),
|
||||||
|
// 其他媒体永久抢焦点:暂停且不恢复。
|
||||||
|
// 电话/瞬时失去:暂停,焦点回来后恢复。
|
||||||
|
// 导航、通知等可闪避:音量降到约 0.2,结束后恢复原音量。
|
||||||
/* handleAudioFocus = */ true,
|
/* handleAudioFocus = */ true,
|
||||||
)
|
)
|
||||||
// 有线/蓝牙耳机拔出或断开时暂停。
|
// 有线/蓝牙耳机拔出或断开时暂停,不自动续播。媒体按键走 MediaSession。
|
||||||
.setHandleAudioBecomingNoisy(true)
|
.setHandleAudioBecomingNoisy(true)
|
||||||
.build()
|
.build()
|
||||||
player = created
|
player = created
|
||||||
|
|||||||
+27
-8
@@ -46,7 +46,8 @@ class LibraryRepository(private val db: LlmpDatabase) {
|
|||||||
val albumComparator = compareBy<Album>({ it.date.orEmpty() }, { it.albumId.orEmpty() })
|
val albumComparator = compareBy<Album>({ it.date.orEmpty() }, { it.albumId.orEmpty() })
|
||||||
|
|
||||||
// 各页正倒序由 UI 独立计算;此处只返回规范顺序。
|
// 各页正倒序由 UI 独立计算;此处只返回规范顺序。
|
||||||
val music = getAllMusic().filter { it.matches() }.sortedWith(musicComparator)
|
val allMusic = getAllMusic()
|
||||||
|
val music = allMusic.filter { it.matches() }.sortedWith(musicComparator)
|
||||||
// 可见歌曲已满足:导出筛选 +(HTTP 或本地 existFile)。
|
// 可见歌曲已满足:导出筛选 +(HTTP 或本地 existFile)。
|
||||||
val musicIds = music.mapNotNull { it.musicId }.toSet()
|
val musicIds = music.mapNotNull { it.musicId }.toSet()
|
||||||
val visibleAlbumIds = music.mapNotNull { it.albumId }.toSet()
|
val visibleAlbumIds = music.mapNotNull { it.albumId }.toSet()
|
||||||
@@ -66,7 +67,7 @@ class LibraryRepository(private val db: LlmpDatabase) {
|
|||||||
val loved = getLovedMusic().filter { it.matches() }.asReversed()
|
val loved = getLovedMusic().filter { it.matches() }.asReversed()
|
||||||
// 最近播放与已筛选歌曲列表取交集。
|
// 最近播放与已筛选歌曲列表取交集。
|
||||||
// selectAllHistory 已按 timestamp DESC(最新在队首),规范顺序保持最新在前。
|
// selectAllHistory 已按 timestamp DESC(最新在队首),规范顺序保持最新在前。
|
||||||
val recent = getHistoryMusic().filter { m ->
|
val recent = historyFrom(allMusic).filter { m ->
|
||||||
m.musicId in musicIds && (allGroup || m.group == group)
|
m.musicId in musicIds && (allGroup || m.group == group)
|
||||||
}
|
}
|
||||||
return LibraryLists(
|
return LibraryLists(
|
||||||
@@ -192,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()
|
||||||
|
|||||||
+50
-178
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
class SleepTimer(
|
class SleepTimer(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val onFire: () -> Unit,
|
private val onFire: suspend () -> Unit,
|
||||||
) {
|
) {
|
||||||
private val _remainingMs = MutableStateFlow(0L)
|
private val _remainingMs = MutableStateFlow(0L)
|
||||||
val remainingMs: StateFlow<Long> = _remainingMs.asStateFlow()
|
val remainingMs: StateFlow<Long> = _remainingMs.asStateFlow()
|
||||||
|
|||||||
+3
-8
@@ -98,11 +98,9 @@ class PathResolver(
|
|||||||
*/
|
*/
|
||||||
fun coverDisplayUri(music: Music): String? {
|
fun coverDisplayUri(music: Music): String? {
|
||||||
if (music.musicId == null) return null
|
if (music.musicId == null) return null
|
||||||
|
// 不在 UI 线程 stat。文件缺失时由图片加载失败落到 Logo。
|
||||||
if (music.existFile == true) {
|
if (music.existFile == true) {
|
||||||
val path = absoluteCoverPath(music)
|
absoluteCoverPath(music)?.let { return "file://$it" }
|
||||||
if (path != null && fs.exists(path)) {
|
|
||||||
return "file://$path"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (settings.enableHttp) {
|
if (settings.enableHttp) {
|
||||||
val relative = joinMediaRelativePath(music.baseUrl, music.coverPath) ?: return null
|
val relative = joinMediaRelativePath(music.baseUrl, music.coverPath) ?: return null
|
||||||
@@ -115,10 +113,7 @@ class PathResolver(
|
|||||||
fun coverDisplayUri(album: Album): String? {
|
fun coverDisplayUri(album: Album): String? {
|
||||||
val rel = album.coverPath ?: return null
|
val rel = album.coverPath ?: return null
|
||||||
if (album.existFile == true) {
|
if (album.existFile == true) {
|
||||||
val path = absoluteAlbumCoverPath(album)
|
absoluteAlbumCoverPath(album)?.let { return "file://$it" }
|
||||||
if (path != null && fs.exists(path)) {
|
|
||||||
return "file://$path"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (settings.enableHttp) {
|
if (settings.enableHttp) {
|
||||||
return joinHttpUrl(rel)
|
return joinHttpUrl(rel)
|
||||||
|
|||||||
+54
-1
@@ -13,6 +13,12 @@ import kotlinx.coroutines.isActive
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import platform.AVFAudio.AVAudioSession
|
import platform.AVFAudio.AVAudioSession
|
||||||
import platform.AVFAudio.AVAudioSessionCategoryPlayback
|
import platform.AVFAudio.AVAudioSessionCategoryPlayback
|
||||||
|
import platform.AVFAudio.AVAudioSessionInterruptionNotification
|
||||||
|
import platform.AVFAudio.AVAudioSessionInterruptionOptionKey
|
||||||
|
import platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume
|
||||||
|
import platform.AVFAudio.AVAudioSessionInterruptionTypeBegan
|
||||||
|
import platform.AVFAudio.AVAudioSessionInterruptionTypeEnded
|
||||||
|
import platform.AVFAudio.AVAudioSessionInterruptionTypeKey
|
||||||
import platform.AVFAudio.AVAudioSessionRouteChangeNotification
|
import platform.AVFAudio.AVAudioSessionRouteChangeNotification
|
||||||
import platform.AVFAudio.AVAudioSessionRouteChangeReasonKey
|
import platform.AVFAudio.AVAudioSessionRouteChangeReasonKey
|
||||||
import platform.AVFAudio.AVAudioSessionRouteChangeReasonOldDeviceUnavailable
|
import platform.AVFAudio.AVAudioSessionRouteChangeReasonOldDeviceUnavailable
|
||||||
@@ -82,6 +88,13 @@ actual class PlayerController {
|
|||||||
private var endObserver: Any? = null
|
private var endObserver: Any? = null
|
||||||
private var failObserver: Any? = null
|
private var failObserver: Any? = null
|
||||||
private var routeObserver: Any? = null
|
private var routeObserver: Any? = null
|
||||||
|
private var interruptionObserver: Any? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 电话、闹钟、其他音频会话打断前正在播放。
|
||||||
|
* 用户在打断期间手动暂停则清掉,结束后不再自动恢复。
|
||||||
|
*/
|
||||||
|
private var resumeAfterInterruption = false
|
||||||
|
|
||||||
private val _state = MutableStateFlow(PlayerState())
|
private val _state = MutableStateFlow(PlayerState())
|
||||||
actual val state: StateFlow<PlayerState> = _state.asStateFlow()
|
actual val state: StateFlow<PlayerState> = _state.asStateFlow()
|
||||||
@@ -120,6 +133,7 @@ actual class PlayerController {
|
|||||||
observePlaybackEnd()
|
observePlaybackEnd()
|
||||||
observePlaybackFailed()
|
observePlaybackFailed()
|
||||||
observeAudioRouteChanges()
|
observeAudioRouteChanges()
|
||||||
|
observeAudioInterruptions()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureAudioReady() {
|
private fun ensureAudioReady() {
|
||||||
@@ -284,7 +298,45 @@ actual class PlayerController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 耳机/蓝牙音频路由不可用时暂停(拔出/断开)。 */
|
/**
|
||||||
|
* 电话 / 闹钟 / 其他 App 占用音频会话时暂停。
|
||||||
|
* 结束且系统给出 ShouldResume 才恢复;导航等短提示由系统把本会话音量闪避,不必自己改 volume。
|
||||||
|
*/
|
||||||
|
private fun observeAudioInterruptions() {
|
||||||
|
interruptionObserver = NSNotificationCenter.defaultCenter.addObserverForName(
|
||||||
|
name = AVAudioSessionInterruptionNotification,
|
||||||
|
`object` = AVAudioSession.sharedInstance(),
|
||||||
|
queue = null,
|
||||||
|
) { notification: NSNotification? ->
|
||||||
|
val info = notification?.userInfo
|
||||||
|
val type = (info?.get(AVAudioSessionInterruptionTypeKey) as? NSNumber)?.unsignedLongValue
|
||||||
|
val options =
|
||||||
|
(info?.get(AVAudioSessionInterruptionOptionKey) as? NSNumber)?.unsignedLongValue ?: 0uL
|
||||||
|
dispatch_async(dispatch_get_main_queue()) {
|
||||||
|
when (type) {
|
||||||
|
AVAudioSessionInterruptionTypeBegan -> {
|
||||||
|
val wasPlaying = _state.value.isPlaying
|
||||||
|
if (wasPlaying) {
|
||||||
|
pause()
|
||||||
|
resumeAfterInterruption = true
|
||||||
|
} else {
|
||||||
|
resumeAfterInterruption = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AVAudioSessionInterruptionTypeEnded -> {
|
||||||
|
val shouldResume =
|
||||||
|
(options and AVAudioSessionInterruptionOptionShouldResume) != 0uL
|
||||||
|
val resume = shouldResume && resumeAfterInterruption
|
||||||
|
resumeAfterInterruption = false
|
||||||
|
if (resume) play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 耳机/蓝牙音频路由不可用时暂停(拔出/断开),不自动续播。 */
|
||||||
private fun observeAudioRouteChanges() {
|
private fun observeAudioRouteChanges() {
|
||||||
routeObserver = NSNotificationCenter.defaultCenter.addObserverForName(
|
routeObserver = NSNotificationCenter.defaultCenter.addObserverForName(
|
||||||
name = AVAudioSessionRouteChangeNotification,
|
name = AVAudioSessionRouteChangeNotification,
|
||||||
@@ -540,6 +592,7 @@ actual class PlayerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
actual fun pause() {
|
actual fun pause() {
|
||||||
|
resumeAfterInterruption = false
|
||||||
endScrub()
|
endScrub()
|
||||||
player.pause()
|
player.pause()
|
||||||
positionJob?.cancel()
|
positionJob?.cancel()
|
||||||
|
|||||||
Reference in New Issue
Block a user