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 com.zhushenwudi.lovelivemusicplayer.db.DriverFactory
import com.zhushenwudi.lovelivemusicplayer.db.createDatabase
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetCommand
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.executeHomeWidgetCommand
import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidShareBridge
import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidDesktopLyricController
import com.zhushenwudi.lovelivemusicplayer.native.platform.installAndroidPlatformServices
@@ -39,6 +41,8 @@ class MainActivity : ComponentActivity() {
initAndroidFileSystem(applicationContext)
if (!AppContainer.isInitialized) {
// Install before init() starts observing/restoring playback state.
installAndroidPlatformServices(applicationContext)
val settings = createAppSettings(applicationContext)
val database = createDatabase(DriverFactory(applicationContext))
player = PlayerController(applicationContext)
@@ -52,7 +56,6 @@ class MainActivity : ComponentActivity() {
player = AppContainer.player
}
installAndroidPlatformServices(applicationContext)
val lyricEnabled = AppContainer.settings.enableDesktopLyric
if (!PlatformServices.desktopLyric.pipAutoOpen(lyricEnabled) && lyricEnabled) {
AppContainer.settings.enableDesktopLyric = false
@@ -61,9 +64,7 @@ class MainActivity : ComponentActivity() {
getSharedPreferences("my_prefs", MODE_PRIVATE).getBoolean("key_agreed", false),
)
getSharedPreferences(AppUtils.SP_NAME, MODE_PRIVATE).edit(commit = true) {
putBoolean("isShutdown", false)
}
PlatformServices.homeWidget.setShutdown(false)
handleIncomingIntent(intent)
@@ -162,15 +163,8 @@ class MainActivity : ComponentActivity() {
}
private fun handleWidgetUri(uri: String) {
when {
uri.endsWith("toggle_play") -> player?.toggle()
uri.endsWith("next") -> player?.next()
uri.endsWith("previous") -> player?.previous()
uri.endsWith("toggle_love") -> {
val id = player?.state?.value?.current?.musicId ?: return
AppContainer.toggleLove(id)
}
}
val command = HomeWidgetCommand.fromValue(uri.removePrefix(AppUtils.HOME_WIDGET_URI_PREFIX)) ?: return
executeHomeWidgetCommand(this, command)
}
private fun requestNotifyPermission() {
@@ -8,9 +8,10 @@ import androidx.glance.LocalContext
import androidx.glance.action.Action
import androidx.glance.appwidget.action.actionStartActivity
import com.zhushenwudi.lovelivemusicplayer.MainActivity
import android.content.SharedPreferences
import android.graphics.BitmapFactory
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
@@ -51,70 +52,32 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
open var radius: Dp = 6.dp
open val cdSize = 80.dp
abstract var isWhite: Boolean
var isPlaying = true
var isShutdown = false
private var isFavorite = false
private lateinit var sp: SharedPreferences
private lateinit var songName: String
private lateinit var songArtist: String
private lateinit var playText: String
private var musicColor = Color.White
private var coverPath: String? = null
@Composable
private fun widgetAction(context: Context, uri: String): Action {
private fun openAppAction(context: Context): Action {
val intent = Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = uri.toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
data = "${AppUtils.HOME_WIDGET_URI_PREFIX}open".toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP
}
return actionStartActivity(intent)
}
@Composable
fun GlanceContent(preferences: SharedPreferences) {
sp = preferences
songName = sp.getString("songName", "")!!
songArtist = sp.getString("songArtist", "")!!
isFavorite = sp.getBoolean("songFavorite", false)
isPlaying = sp.getBoolean("isPlaying", false)
playText = sp.getString("playText", "")!!
isShutdown = sp.getBoolean("isShutdown", false)
coverPath = sp.getString("shareImage", null)
val strBgColor = sp.getString("bgColor", "")!!
coverPath?.let {
if (!File(it).exists()) {
coverPath = null
}
}
if (strBgColor.contains(",")) {
val bgColorArr = strBgColor.split(",")
musicColor = Color(
red = bgColorArr[0].toDoubleOrNull()?.toInt() ?: 1,
green = bgColorArr[1].toDoubleOrNull()?.toInt() ?: 1,
blue = bgColorArr[2].toDoubleOrNull()?.toInt() ?: 1
)
}
if (isShutdown) {
isPlaying = false
}
RenderAppWidget()
private fun controlAction(state: HomeWidgetState, command: HomeWidgetCommand): Action {
val context = LocalContext.current
return homeWidgetControlAction(command, state.controlsEnabled) { openAppAction(context) }
}
@Composable
fun RenderAppWidget() {
fun RenderAppWidget(state: HomeWidgetState) {
Box(
modifier = GlanceModifier
.width(size.width)
.height(size.height)
) {
if (isWhite) {
GradientRectangle(color = musicColor)
GradientRectangle(color = state.musicColor)
} else {
Box(
modifier = GlanceModifier
@@ -122,6 +85,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
.width(size.width)
.height(size.height)
.cornerRadius(radius)
.clickable(openAppAction(LocalContext.current))
) {}
}
@@ -138,9 +102,9 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
.size(size.height)
.cornerRadius(radius)
) {
RenderCD(isLargePauseWidget = false)
RenderCover(isLargePauseWidget = false)
RenderPlayController(false)
RenderCD(state, isLargePauseWidget = false)
RenderCover(state, isLargePauseWidget = false)
RenderPlayController(state, false)
}
}
@@ -156,20 +120,21 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
RenderLogo(size = 22.dp, paddingInDp = (-6).dp)
}
RenderTextArr(stateFontSize = 9.sp, infoFontSize = 11.sp)
RenderTextArr(state, stateFontSize = 9.sp, infoFontSize = 11.sp)
}
RenderFavorite(imageSize = 20.dp, paddingInDp = radius)
RenderFavorite(state, imageSize = 20.dp, paddingInDp = radius)
}
}
open fun calcCdOffsetX(): Dp = cdSize / 2 - 10.dp
open fun calcCdOffsetX(state: HomeWidgetState): Dp = cdSize / 2 - 10.dp
open fun calcCdOffsetY(): Dp = cdSize / 2 - 10.dp
open fun calcCdOffsetY(state: HomeWidgetState): Dp = cdSize / 2 - 10.dp
@SuppressLint("RestrictedApi")
@Composable
open fun RenderTextArr(
state: HomeWidgetState,
stateFontSize: TextUnit,
infoFontSize: TextUnit
) {
@@ -178,13 +143,8 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
} else {
size.width - 75.dp
}
val playTextArr = playText.split(",")
var playStateText = playTextArr[1]
if (!isShutdown && isPlaying) {
playStateText = playTextArr[0]
}
Text(
text = playStateText,
text = state.playStateText,
style = TextStyle(
fontSize = stateFontSize,
fontWeight = FontWeight.Bold,
@@ -195,7 +155,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
)
Text(
text = songName,
text = state.songName,
style = TextStyle(
fontSize = infoFontSize,
fontWeight = FontWeight.Bold,
@@ -206,7 +166,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
)
Text(
text = songArtist,
text = state.songArtist,
style = TextStyle(
fontSize = infoFontSize,
fontWeight = FontWeight.Bold,
@@ -225,26 +185,24 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
modifier = GlanceModifier
.size(size)
.padding(start = paddingInDp)
.clickable(openAppAction(LocalContext.current))
)
}
@SuppressLint("RestrictedApi")
@Composable
private fun RenderTransportButton(
state: HomeWidgetState,
drawable: Int,
description: String,
uri: String,
command: HomeWidgetCommand,
imageSize: Dp,
) {
val base = GlanceModifier.size(imageSize)
val modifier = if (isShutdown) {
base
} else {
base.clickable(
val modifier = GlanceModifier.size(imageSize)
.clickable(
rippleOverride = R.drawable.click_ripple,
onClick = widgetAction(LocalContext.current, uri),
onClick = controlAction(state, command),
)
}
Box(
contentAlignment = Alignment.Center,
modifier = modifier,
@@ -259,56 +217,45 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
}
@Composable
open fun RenderCD(isLargePauseWidget: Boolean) {
open fun RenderCD(state: HomeWidgetState, isLargePauseWidget: Boolean) {
val cdDrawable = if (isWhite) R.drawable.cd_white else R.drawable.cd_black
val offsetX = calcCdOffsetX()
val offsetY = calcCdOffsetY()
val offsetX = calcCdOffsetX(state)
val offsetY = calcCdOffsetY(state)
val startPadding = if (isLargePauseWidget) 0.dp else offsetX
val radio = if (isLargePauseWidget) 0.9f else 1f
if (isShutdown) {
Image(
provider = ImageProvider(cdDrawable),
contentDescription = "cd",
modifier = GlanceModifier
.size(size.height * radio)
.padding(
start = startPadding,
top = -offsetY,
end = -offsetX,
bottom = offsetY
)
)
} else {
Image(
provider = ImageProvider(cdDrawable),
contentDescription = "cd",
modifier = GlanceModifier
.size(size.height * radio)
.padding(
start = startPadding,
top = -offsetY,
end = -offsetX,
bottom = offsetY
)
.clickable(rippleOverride = R.drawable.click_ripple, onClick = widgetAction(LocalContext.current, "${AppUtils.HOME_WIDGET_URI_PREFIX}toggle_play"))
)
}
Image(
provider = ImageProvider(cdDrawable),
contentDescription = "cd",
modifier = GlanceModifier
.size(size.height * radio)
.padding(
start = startPadding,
top = -offsetY,
end = -offsetX,
bottom = offsetY
)
.clickable(
rippleOverride = R.drawable.click_ripple,
onClick = controlAction(state, HomeWidgetCommand.TOGGLE_PLAY),
)
)
}
@Composable
open fun RenderCover(isLargePauseWidget: Boolean) {
open fun RenderCover(state: HomeWidgetState, isLargePauseWidget: Boolean) {
val coverSize = cdSize * 0.4f
val coverAndCdDiffSize = (cdSize - coverSize) / 2
val offsetX = calcCdOffsetX()
val offsetY = calcCdOffsetY()
val offsetX = calcCdOffsetX(state)
val offsetY = calcCdOffsetY(state)
val startPadding =
if (isLargePauseWidget) coverAndCdDiffSize else offsetX + coverAndCdDiffSize
val radio = if (isLargePauseWidget) 0.95f else 1f
coverPath?.let {
state.coverPath?.let {
if (File(it).exists()) {
val bitmap = BitmapFactory.decodeFile(it) ?: return
Image(
provider = ImageProvider(BitmapFactory.decodeFile(it)),
provider = ImageProvider(bitmap),
contentDescription = "cover",
modifier = GlanceModifier
.size(size.height * radio)
@@ -325,21 +272,14 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
@SuppressLint("RestrictedApi")
@Composable
open fun RenderPlayController(isLargePauseWidget: Boolean) {
val resId = if (isPlaying) R.drawable.vector_pause else R.drawable.vector_play
val toggleAction = widgetAction(
LocalContext.current,
"${AppUtils.HOME_WIDGET_URI_PREFIX}toggle_play",
)
open fun RenderPlayController(state: HomeWidgetState, isLargePauseWidget: Boolean) {
val resId = if (state.isPlaying) R.drawable.vector_pause else R.drawable.vector_play
val toggleAction = controlAction(state, HomeWidgetCommand.TOGGLE_PLAY)
if (isLargePauseWidget) {
val modifier = if (isShutdown) {
GlanceModifier.size(20.dp)
} else {
GlanceModifier
.size(20.dp)
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
}
val modifier = GlanceModifier
.size(20.dp)
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
Image(
provider = ImageProvider(resId),
contentDescription = "playButton",
@@ -349,13 +289,9 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
} else {
val padding =
if (size.width == AppUtils.HORIZONTAL_RECTANGLE.width) 26.dp else 23.dp
val playModifier = if (isShutdown) {
GlanceModifier.size(15.dp)
} else {
GlanceModifier
.size(15.dp)
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
}
val playModifier = GlanceModifier
.size(15.dp)
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
Box(
contentAlignment = Alignment.TopEnd,
modifier = GlanceModifier
@@ -374,7 +310,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
}
@Composable
open fun RenderFavorite(imageSize: Dp, paddingInDp: Dp) {
open fun RenderFavorite(state: HomeWidgetState, imageSize: Dp, paddingInDp: Dp) {
val isLarge = size.width == AppUtils.HORIZONTAL_RECTANGLE.width
Box(
contentAlignment = Alignment.BottomEnd,
@@ -386,35 +322,30 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
Row(verticalAlignment = Alignment.CenterVertically) {
if (isLarge) {
RenderTransportButton(
state = state,
drawable = R.drawable.player_play_prev,
description = "previous",
uri = "${AppUtils.HOME_WIDGET_URI_PREFIX}previous",
command = HomeWidgetCommand.PREVIOUS,
imageSize = imageSize,
)
Spacer(GlanceModifier.width(8.dp))
}
RenderTransportButton(
state = state,
drawable = R.drawable.player_play_next,
description = "next",
uri = "${AppUtils.HOME_WIDGET_URI_PREFIX}next",
command = HomeWidgetCommand.NEXT,
imageSize = imageSize,
)
Spacer(GlanceModifier.width(8.dp))
val loveIcon =
if (isFavorite) R.drawable.widget_fav_click else R.drawable.widget_fav_unclick
val favoriteModifier = if (isShutdown) {
GlanceModifier.size(imageSize)
} else {
GlanceModifier
.size(imageSize)
.clickable(
rippleOverride = R.drawable.click_ripple,
onClick = widgetAction(
LocalContext.current,
"${AppUtils.HOME_WIDGET_URI_PREFIX}toggle_love",
),
)
}
if (state.isFavorite) R.drawable.widget_fav_click else R.drawable.widget_fav_unclick
val favoriteModifier = GlanceModifier
.size(imageSize)
.clickable(
rippleOverride = R.drawable.click_ripple,
onClick = controlAction(state, HomeWidgetCommand.TOGGLE_FAVORITE),
)
Image(
provider = ImageProvider(loveIcon),
contentDescription = "love",
@@ -433,6 +364,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
.width(size.width)
.height(size.height)
.cornerRadius(radius)
.clickable(openAppAction(LocalContext.current))
) {
Image(
provider = ImageProvider(bitmap),
@@ -446,10 +378,16 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val preferences = context.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
provideContent { GlanceContent(preferences = preferences) }
val states = preferences.widgetStates()
val initialState = HomeWidgetState.fromPreferences(preferences.all)
provideContent {
val state by states.collectAsState(initialState)
val running by HomeWidgetRuntime.isRunning.collectAsState()
RenderAppWidget(state.withAppRunning(running))
}
}
private companion object {
const val TRANSPORT_ICON_SCALE = 0.58f
}
}
}
@@ -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.sp
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetContent
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetState
import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
@SuppressLint("CommitPrefEdits", "DefaultLocale")
@@ -17,43 +18,39 @@ abstract class LargeHomeWidgetContent(
override var cdSize: Dp = 120.dp,
) : HomeWidgetContent() {
private fun isLargePauseWidget(): Boolean {
return !isPlaying || isShutdown
}
@Composable
override fun RenderLogo(size: Dp, paddingInDp: Dp) {
super.RenderLogo(size = 30.dp, paddingInDp)
}
@Composable
override fun RenderCD(isLargePauseWidget: Boolean) {
super.RenderCD(isLargePauseWidget = isLargePauseWidget())
override fun RenderCD(state: HomeWidgetState, isLargePauseWidget: Boolean) {
super.RenderCD(state, isLargePauseWidget = !state.isPlaying)
}
@Composable
override fun RenderCover(isLargePauseWidget: Boolean) {
super.RenderCover(isLargePauseWidget = isLargePauseWidget())
override fun RenderCover(state: HomeWidgetState, isLargePauseWidget: Boolean) {
super.RenderCover(state, isLargePauseWidget = !state.isPlaying)
}
@Composable
override fun RenderPlayController(isLargePauseWidget: Boolean) {
super.RenderPlayController(isLargePauseWidget = isLargePauseWidget())
override fun RenderPlayController(state: HomeWidgetState, isLargePauseWidget: Boolean) {
super.RenderPlayController(state, isLargePauseWidget = !state.isPlaying)
}
@Composable
override fun RenderTextArr(stateFontSize: TextUnit, infoFontSize: TextUnit) {
super.RenderTextArr(stateFontSize = 12.sp, infoFontSize = 13.sp)
override fun RenderTextArr(state: HomeWidgetState, stateFontSize: TextUnit, infoFontSize: TextUnit) {
super.RenderTextArr(state, stateFontSize = 12.sp, infoFontSize = 13.sp)
}
@Composable
override fun RenderFavorite(imageSize: Dp, paddingInDp: Dp) {
super.RenderFavorite(imageSize = 28.dp, paddingInDp = radius / 2)
override fun RenderFavorite(state: HomeWidgetState, imageSize: Dp, paddingInDp: Dp) {
super.RenderFavorite(state, imageSize = 28.dp, paddingInDp = radius / 2)
}
override fun calcCdOffsetX(): Dp =
if (isPlaying) cdSize / 2 + 5.dp else 12.dp
override fun calcCdOffsetX(state: HomeWidgetState): Dp =
if (state.isPlaying) cdSize / 2 + 5.dp else 12.dp
override fun calcCdOffsetY(): Dp =
if (isPlaying) cdSize / 2 + 5.dp else 12.dp
override fun calcCdOffsetY(state: HomeWidgetState): Dp =
if (state.isPlaying) cdSize / 2 + 5.dp else 12.dp
}
@@ -4,12 +4,15 @@ import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import androidx.core.content.edit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetRuntime
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget.BlackLargeHomeWidgetReceiver
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget.WhiteLargeHomeWidgetReceiver
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget.BlackSmallHomeWidgetReceiver
@@ -18,6 +21,8 @@ import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
import com.zhushenwudi.lovelivemusicplayer.native.util.ImageUtil
import com.zhushenwudi.lovelivemusicplayer.platform.HomeWidgetController
import java.io.File
import java.util.Locale
import java.util.UUID
class AndroidHomeWidgetController(
context: Context,
@@ -25,6 +30,7 @@ class AndroidHomeWidgetController(
private val appContext = context.applicationContext
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var preImagePath: String? = null
private var coverRevision = 0L
private var lastContent: WidgetContent? = null
private val receiverClasses = listOf(
@@ -34,6 +40,7 @@ class AndroidHomeWidgetController(
BlackLargeHomeWidgetReceiver::class.java,
)
@Synchronized
override fun update(
songName: String?,
artist: String?,
@@ -50,28 +57,38 @@ class AndroidHomeWidgetController(
playText = playText,
)
val contentChanged = content != lastContent
val coverChanged = !coverPath.isNullOrBlank() && coverPath != preImagePath
val requestedCover = coverPath?.takeIf { it.isNotBlank() }
val coverChanged = requestedCover != preImagePath || (lastContent == null && requestedCover == null)
if (!contentChanged && !coverChanged) return
if (contentChanged) {
lastContent = content
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
.edit(commit = true) {
putString("songName", content.songName)
putString("songArtist", content.artist)
putBoolean("songFavorite", content.favorite)
putBoolean("isPlaying", content.isPlaying)
putString("playText", content.playText)
if (content.songName.isNotBlank() || content.isPlaying) {
putBoolean("isShutdown", false)
}
}
refresh()
if (coverChanged) {
preImagePath = requestedCover
coverRevision++
}
if (coverChanged) coverPath?.let(::processCover)
lastContent = content
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
.edit(commit = true) {
putString("songName", content.songName)
putString("songArtist", content.artist)
putBoolean("songFavorite", content.favorite)
putBoolean("isPlaying", content.isPlaying)
putString("playText", content.playText)
if (coverChanged || content.songName.isBlank()) {
remove("shareImage")
remove("bgColor")
}
if (content.songName.isNotBlank() || content.isPlaying) {
putBoolean("isShutdown", false)
}
}
refresh()
if (coverChanged && requestedCover != null) processCover(requestedCover, coverRevision)
}
@Synchronized
override fun setShutdown(shutdown: Boolean) {
HomeWidgetRuntime.setRunning(!shutdown)
lastContent = null
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
.edit(commit = true) {
putBoolean("isShutdown", shutdown)
@@ -88,22 +105,45 @@ class AndroidHomeWidgetController(
}
}
private fun processCover(coverPath: String) {
if (preImagePath == coverPath || !File(coverPath).exists()) return
preImagePath = coverPath
private fun processCover(coverPath: String, revision: Long) {
scope.launch {
var originBitmap: Bitmap? = null
var coverBitmap: Bitmap? = null
var coverFile: File? = null
var published = false
try {
val originBitmap = BitmapFactory.decodeFile(coverPath)
val coverBitmap = ImageUtil.squareAndCircularBitmap(originBitmap, 180)
val handledCoverPath = ImageUtil.saveBitmapToFile(appContext, coverBitmap)
ImageUtil.savePathToSp(appContext, handledCoverPath)
originBitmap = BitmapFactory.decodeFile(coverPath) ?: return@launch
coverBitmap = ImageUtil.squareAndCircularBitmap(originBitmap, 180)
val color = ImageUtil.parsePhotoDomainColor(coverBitmap)
ImageUtil.saveColorToSp(appContext, color)
originBitmap.recycle()
coverBitmap.recycle()
} catch (_: Exception) {
// Publish only complete files; keep one cached bitmap per source, not per update.
val stagedFile = File(appContext.cacheDir, "widget-cover-${UUID.randomUUID()}.tmp")
coverFile = stagedFile
stagedFile.outputStream().use { output ->
check(coverBitmap.compress(Bitmap.CompressFormat.PNG, 100, output))
}
synchronized(this@AndroidHomeWidgetController) {
// A slow decode for a previous track must not replace the current artwork.
if (revision != coverRevision) return@launch
val cacheKey = UUID.nameUUIDFromBytes(coverPath.toByteArray(Charsets.UTF_8))
val cachedFile = File(appContext.cacheDir, "widget-cover-$cacheKey.png")
if (!cachedFile.exists()) check(stagedFile.renameTo(cachedFile))
val preferences = appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
preferences.edit(commit = true) {
putString("shareImage", cachedFile.absolutePath)
putString("bgColor", String.format(Locale.US, "%.4f,%.4f,%.4f", color.red, color.green, color.blue))
}
published = true
refresh()
}
} catch (error: Exception) {
Log.w("LLMP-Widget", "Unable to prepare widget cover", error)
} finally {
refresh()
originBitmap?.recycle()
coverBitmap?.recycle()
coverFile?.delete()
if (!published) synchronized(this@AndroidHomeWidgetController) {
if (revision == coverRevision) preImagePath = null
}
}
}
}
@@ -167,6 +167,7 @@ class PlaybackService : MediaSessionService() {
private var instance: PlaybackService? = null
fun start(context: Context) {
if (instance != null) return
val intent = Intent(context, PlaybackService::class.java)
ContextCompat.startForegroundService(context, intent)
}