fix android desktop card bugs

This commit is contained in:
2026-09-26 12:58:21 +08:00
parent 43e9e1b916
commit 284b9cc028
8 changed files with 339 additions and 211 deletions
@@ -18,6 +18,8 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat 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
@@ -39,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)
@@ -52,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
@@ -61,9 +64,7 @@ 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)
@@ -162,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() {
@@ -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,56 +217,45 @@ 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", modifier = GlanceModifier
modifier = GlanceModifier .size(size.height * radio)
.size(size.height * radio) .padding(
.padding( start = startPadding,
start = startPadding, top = -offsetY,
top = -offsetY, end = -offsetX,
end = -offsetX, bottom = offsetY
bottom = offsetY )
) .clickable(
) rippleOverride = R.drawable.click_ripple,
} else { onClick = controlAction(state, HomeWidgetCommand.TOGGLE_PLAY),
Image( )
provider = ImageProvider(cdDrawable), )
contentDescription = "cd",
modifier = GlanceModifier
.size(size.height * radio)
.padding(
start = startPadding,
top = -offsetY,
end = -offsetX,
bottom = offsetY
)
.clickable(rippleOverride = R.drawable.click_ripple, onClick = 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) .size(20.dp)
} else { .clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
GlanceModifier
.size(20.dp)
.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) .size(15.dp)
} else { .clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
GlanceModifier
.size(15.dp)
.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) .size(imageSize)
} else { .clickable(
GlanceModifier rippleOverride = R.drawable.click_ripple,
.size(imageSize) onClick = controlAction(state, HomeWidgetCommand.TOGGLE_FAVORITE),
.clickable( )
rippleOverride = R.drawable.click_ripple,
onClick = widgetAction(
LocalContext.current,
"${AppUtils.HOME_WIDGET_URI_PREFIX}toggle_love",
),
)
}
Image( Image(
provider = ImageProvider(loveIcon), provider = ImageProvider(loveIcon),
contentDescription = "love", contentDescription = "love",
@@ -433,6 +364,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
.width(size.width) .width(size.width)
.height(size.height) .height(size.height)
.cornerRadius(radius) .cornerRadius(radius)
.clickable(openAppAction(LocalContext.current))
) { ) {
Image( Image(
provider = ImageProvider(bitmap), provider = ImageProvider(bitmap),
@@ -446,7 +378,13 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val preferences = context.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE) val preferences = context.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
provideContent { GlanceContent(preferences = preferences) } val states = preferences.widgetStates()
val initialState = HomeWidgetState.fromPreferences(preferences.all)
provideContent {
val state by states.collectAsState(initialState)
val running by HomeWidgetRuntime.isRunning.collectAsState()
RenderAppWidget(state.withAppRunning(running))
}
} }
private companion object { private companion object {
@@ -0,0 +1,85 @@
package com.zhushenwudi.lovelivemusicplayer.native.home_widget
import android.content.Context
import androidx.glance.GlanceId
import androidx.glance.action.Action
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import com.zhushenwudi.lovelivemusicplayer.AppContainer
import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidHomeWidgetController
import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
import com.zhushenwudi.lovelivemusicplayer.player.PlaybackService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
/** Process-local availability: a persisted widget snapshot cannot prove the player is alive. */
internal object HomeWidgetRuntime {
private val running = MutableStateFlow(false)
val isRunning = running.asStateFlow()
fun setRunning(value: Boolean) {
running.value = value
}
}
internal enum class HomeWidgetCommand(val value: String) {
TOGGLE_PLAY("toggle_play"),
PREVIOUS("previous"),
NEXT("next"),
TOGGLE_FAVORITE("toggle_love");
companion object {
val key = ActionParameters.Key<String>("widget_command")
fun fromValue(value: String?): HomeWidgetCommand? = entries.firstOrNull { it.value == value }
}
}
internal fun homeWidgetControlAction(
command: HomeWidgetCommand,
controlsEnabled: Boolean,
openApp: () -> Action,
): Action = if (controlsEnabled) {
actionRunCallback<HomeWidgetPlaybackAction>(actionParametersOf(HomeWidgetCommand.key to command.value))
} else {
openApp()
}
/** Glance delivers this callback without bringing MainActivity to the foreground. */
class HomeWidgetPlaybackAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val command = HomeWidgetCommand.fromValue(parameters[HomeWidgetCommand.key]) ?: return
withContext(Dispatchers.Main.immediate) {
val shutdown = context.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
.getBoolean("isShutdown", false)
// The process may have died since RemoteViews were published. Do not replay playback
// commands on a cold start; refresh the controls into their open-app state instead.
if (AppContainer.isInitialized && HomeWidgetRuntime.isRunning.value && !shutdown) {
executeHomeWidgetCommand(context, command)
}
}
AndroidHomeWidgetController(context).refresh()
}
}
/** Both legacy activity intents and widget callbacks execute ExoPlayer commands on the main thread. */
internal fun executeHomeWidgetCommand(context: Context, command: HomeWidgetCommand) {
if (!AppContainer.isInitialized) return
val player = AppContainer.player
when (command) {
HomeWidgetCommand.TOGGLE_PLAY -> {
if (!player.state.value.isPlaying) PlaybackService.start(context)
player.toggle()
}
HomeWidgetCommand.PREVIOUS -> player.previous()
HomeWidgetCommand.NEXT -> player.next()
HomeWidgetCommand.TOGGLE_FAVORITE -> {
val id = player.state.value.current?.musicId ?: return
AppContainer.toggleLove(id)
}
}
}
@@ -0,0 +1,73 @@
package com.zhushenwudi.lovelivemusicplayer.native.home_widget
import android.content.SharedPreferences
import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
/** Immutable snapshots also invalidate an already running Glance composition. */
data class HomeWidgetState(
val songName: String = "",
val songArtist: String = "",
val isFavorite: Boolean = false,
val isPlaying: Boolean = false,
val isShutdown: Boolean = false,
val controlsEnabled: Boolean = false,
val playText: String = "Playing,Paused",
val coverPath: String? = null,
val backgroundColor: String = "",
) {
fun withAppRunning(running: Boolean): HomeWidgetState = copy(
controlsEnabled = running && !isShutdown,
isPlaying = isPlaying && running && !isShutdown,
)
val playStateText: String
get() {
val labels = playText.split(',', limit = 2)
val index = if (isPlaying) 0 else 1
return labels.getOrNull(index)?.takeIf { it.isNotBlank() }
?: if (isPlaying) "Playing" else "Paused"
}
val musicColor: Color
get() {
val components = backgroundColor.split(',').map { it.toFloatOrNull() }
if (components.size != 3 || components.any { it == null || !it.isFinite() }) {
return Color.White
}
return Color(
red = components[0]!!.coerceIn(0f, 1f),
green = components[1]!!.coerceIn(0f, 1f),
blue = components[2]!!.coerceIn(0f, 1f),
)
}
companion object {
fun fromPreferences(values: Map<String, *>): HomeWidgetState {
val shutdown = values["isShutdown"] == true
return HomeWidgetState(
songName = values["songName"] as? String ?: "",
songArtist = values["songArtist"] as? String ?: "",
isFavorite = values["songFavorite"] == true,
isPlaying = values["isPlaying"] == true && !shutdown,
isShutdown = shutdown,
playText = values["playText"] as? String ?: "Playing,Paused",
coverPath = values["shareImage"] as? String,
backgroundColor = values["bgColor"] as? String ?: "",
)
}
}
}
internal fun SharedPreferences.widgetStates() = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { preferences, _ ->
trySend(HomeWidgetState.fromPreferences(preferences.all))
}
// Register first so a write during the initial read cannot be missed.
registerOnSharedPreferenceChangeListener(listener)
trySend(HomeWidgetState.fromPreferences(all))
awaitClose { unregisterOnSharedPreferenceChangeListener(listener) }
}.conflate().distinctUntilChanged()
@@ -8,6 +8,7 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetContent import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetContent
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetState
import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
@SuppressLint("CommitPrefEdits", "DefaultLocale") @SuppressLint("CommitPrefEdits", "DefaultLocale")
@@ -17,43 +18,39 @@ abstract class LargeHomeWidgetContent(
override var cdSize: Dp = 120.dp, override var cdSize: Dp = 120.dp,
) : HomeWidgetContent() { ) : HomeWidgetContent() {
private fun isLargePauseWidget(): Boolean {
return !isPlaying || isShutdown
}
@Composable @Composable
override fun RenderLogo(size: Dp, paddingInDp: Dp) { override fun RenderLogo(size: Dp, paddingInDp: Dp) {
super.RenderLogo(size = 30.dp, paddingInDp) super.RenderLogo(size = 30.dp, paddingInDp)
} }
@Composable @Composable
override fun RenderCD(isLargePauseWidget: Boolean) { override fun RenderCD(state: HomeWidgetState, isLargePauseWidget: Boolean) {
super.RenderCD(isLargePauseWidget = isLargePauseWidget()) super.RenderCD(state, isLargePauseWidget = !state.isPlaying)
} }
@Composable @Composable
override fun RenderCover(isLargePauseWidget: Boolean) { override fun RenderCover(state: HomeWidgetState, isLargePauseWidget: Boolean) {
super.RenderCover(isLargePauseWidget = isLargePauseWidget()) super.RenderCover(state, isLargePauseWidget = !state.isPlaying)
} }
@Composable @Composable
override fun RenderPlayController(isLargePauseWidget: Boolean) { override fun RenderPlayController(state: HomeWidgetState, isLargePauseWidget: Boolean) {
super.RenderPlayController(isLargePauseWidget = isLargePauseWidget()) super.RenderPlayController(state, isLargePauseWidget = !state.isPlaying)
} }
@Composable @Composable
override fun RenderTextArr(stateFontSize: TextUnit, infoFontSize: TextUnit) { override fun RenderTextArr(state: HomeWidgetState, stateFontSize: TextUnit, infoFontSize: TextUnit) {
super.RenderTextArr(stateFontSize = 12.sp, infoFontSize = 13.sp) super.RenderTextArr(state, stateFontSize = 12.sp, infoFontSize = 13.sp)
} }
@Composable @Composable
override fun RenderFavorite(imageSize: Dp, paddingInDp: Dp) { override fun RenderFavorite(state: HomeWidgetState, imageSize: Dp, paddingInDp: Dp) {
super.RenderFavorite(imageSize = 28.dp, paddingInDp = radius / 2) super.RenderFavorite(state, imageSize = 28.dp, paddingInDp = radius / 2)
} }
override fun calcCdOffsetX(): Dp = override fun calcCdOffsetX(state: HomeWidgetState): Dp =
if (isPlaying) cdSize / 2 + 5.dp else 12.dp if (state.isPlaying) cdSize / 2 + 5.dp else 12.dp
override fun calcCdOffsetY(): Dp = override fun calcCdOffsetY(state: HomeWidgetState): Dp =
if (isPlaying) cdSize / 2 + 5.dp else 12.dp if (state.isPlaying) cdSize / 2 + 5.dp else 12.dp
} }
@@ -4,12 +4,15 @@ import android.appwidget.AppWidgetManager
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.util.Log
import androidx.core.content.edit import androidx.core.content.edit
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetRuntime
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget.BlackLargeHomeWidgetReceiver import com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget.BlackLargeHomeWidgetReceiver
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget.WhiteLargeHomeWidgetReceiver import com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget.WhiteLargeHomeWidgetReceiver
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget.BlackSmallHomeWidgetReceiver import com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget.BlackSmallHomeWidgetReceiver
@@ -18,6 +21,8 @@ import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
import com.zhushenwudi.lovelivemusicplayer.native.util.ImageUtil import com.zhushenwudi.lovelivemusicplayer.native.util.ImageUtil
import com.zhushenwudi.lovelivemusicplayer.platform.HomeWidgetController import com.zhushenwudi.lovelivemusicplayer.platform.HomeWidgetController
import java.io.File import java.io.File
import java.util.Locale
import java.util.UUID
class AndroidHomeWidgetController( class AndroidHomeWidgetController(
context: Context, context: Context,
@@ -25,6 +30,7 @@ class AndroidHomeWidgetController(
private val appContext = context.applicationContext private val appContext = context.applicationContext
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var preImagePath: String? = null private var preImagePath: String? = null
private var coverRevision = 0L
private var lastContent: WidgetContent? = null private var lastContent: WidgetContent? = null
private val receiverClasses = listOf( private val receiverClasses = listOf(
@@ -34,6 +40,7 @@ class AndroidHomeWidgetController(
BlackLargeHomeWidgetReceiver::class.java, BlackLargeHomeWidgetReceiver::class.java,
) )
@Synchronized
override fun update( override fun update(
songName: String?, songName: String?,
artist: String?, artist: String?,
@@ -50,28 +57,38 @@ 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) {
lastContent = content preImagePath = requestedCover
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE) coverRevision++
.edit(commit = true) {
putString("songName", content.songName)
putString("songArtist", content.artist)
putBoolean("songFavorite", content.favorite)
putBoolean("isPlaying", content.isPlaying)
putString("playText", content.playText)
if (content.songName.isNotBlank() || content.isPlaying) {
putBoolean("isShutdown", false)
}
}
refresh()
} }
if (coverChanged) coverPath?.let(::processCover) lastContent = content
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
.edit(commit = true) {
putString("songName", content.songName)
putString("songArtist", content.artist)
putBoolean("songFavorite", content.favorite)
putBoolean("isPlaying", content.isPlaying)
putString("playText", content.playText)
if (coverChanged || content.songName.isBlank()) {
remove("shareImage")
remove("bgColor")
}
if (content.songName.isNotBlank() || content.isPlaying) {
putBoolean("isShutdown", false)
}
}
refresh()
if (coverChanged && requestedCover != null) processCover(requestedCover, coverRevision)
} }
@Synchronized
override fun setShutdown(shutdown: Boolean) { 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,22 +105,45 @@ 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 ->
check(coverBitmap.compress(Bitmap.CompressFormat.PNG, 100, output))
}
synchronized(this@AndroidHomeWidgetController) {
// A slow decode for a previous track must not replace the current artwork.
if (revision != coverRevision) return@launch
val cacheKey = UUID.nameUUIDFromBytes(coverPath.toByteArray(Charsets.UTF_8))
val cachedFile = File(appContext.cacheDir, "widget-cover-$cacheKey.png")
if (!cachedFile.exists()) check(stagedFile.renameTo(cachedFile))
val preferences = appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
preferences.edit(commit = true) {
putString("shareImage", cachedFile.absolutePath)
putString("bgColor", String.format(Locale.US, "%.4f,%.4f,%.4f", color.red, color.green, color.blue))
}
published = true
refresh()
}
} catch (error: Exception) {
Log.w("LLMP-Widget", "Unable to prepare widget cover", error)
} finally { } finally {
refresh() originBitmap?.recycle()
coverBitmap?.recycle()
coverFile?.delete()
if (!published) synchronized(this@AndroidHomeWidgetController) {
if (revision == coverRevision) preImagePath = null
}
} }
} }
} }
@@ -167,6 +167,7 @@ class PlaybackService : MediaSessionService() {
private var instance: PlaybackService? = null private var instance: PlaybackService? = null
fun start(context: Context) { fun start(context: Context) {
if (instance != null) return
val intent = Intent(context, PlaybackService::class.java) val intent = Intent(context, PlaybackService::class.java)
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} }
@@ -590,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 {
@@ -675,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"),
) )