From 284b9cc02851e69bfadb911533851b8e2515c8ab Mon Sep 17 00:00:00 2001 From: zhushenwudi <55681140@163.com> Date: Sat, 26 Sep 2026 12:56:22 +0800 Subject: [PATCH] fix android desktop card bugs --- .../lovelivemusicplayer/MainActivity.kt | 20 +- .../native/home_widget/HomeWidgetContent.kt | 230 +++++++----------- .../home_widget/HomeWidgetPlaybackAction.kt | 85 +++++++ .../native/home_widget/HomeWidgetState.kt | 73 ++++++ .../LargeHomeWidgetContent.kt | 33 ++- .../platform/AndroidHomeWidgetController.kt | 96 +++++--- .../player/PlaybackService.kt | 1 + .../lovelivemusicplayer/AppContainer.kt | 12 +- 8 files changed, 339 insertions(+), 211 deletions(-) create mode 100644 androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/HomeWidgetPlaybackAction.kt create mode 100644 androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/HomeWidgetState.kt diff --git a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/MainActivity.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/MainActivity.kt index 200d434..57c64fa 100644 --- a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/MainActivity.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/MainActivity.kt @@ -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() { diff --git a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/HomeWidgetContent.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/HomeWidgetContent.kt index bd466b6..bd4eb77 100644 --- a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/HomeWidgetContent.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/HomeWidgetContent.kt @@ -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 } -} \ No newline at end of file +} diff --git a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/HomeWidgetPlaybackAction.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/HomeWidgetPlaybackAction.kt new file mode 100644 index 0000000..2ed8d91 --- /dev/null +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/HomeWidgetPlaybackAction.kt @@ -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("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(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) + } + } +} diff --git a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/HomeWidgetState.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/HomeWidgetState.kt new file mode 100644 index 0000000..fdab0ab --- /dev/null +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/HomeWidgetState.kt @@ -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): 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() diff --git a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/LargeHomeWidgetContent.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/LargeHomeWidgetContent.kt index d035eb5..f50ef16 100644 --- a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/LargeHomeWidgetContent.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/LargeHomeWidgetContent.kt @@ -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 } diff --git a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidHomeWidgetController.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidHomeWidgetController.kt index d167ceb..2818781 100644 --- a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidHomeWidgetController.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidHomeWidgetController.kt @@ -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 + } } } } diff --git a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlaybackService.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlaybackService.kt index 9820465..ff8369e 100644 --- a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlaybackService.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlaybackService.kt @@ -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) } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt index 2b588c6..d988409 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt @@ -590,7 +590,7 @@ object AppContainer { homeWidgetJob = scope.launch { player.state .map { st -> - Triple(st.current?.musicId, st.isPlaying, st.current?.isLove == true) + st.current to st.isPlaying } .distinctUntilChanged() .collect { @@ -675,13 +675,13 @@ object AppContainer { updateCover: Boolean = true, ) { val st = player.state.value - val current = st.current ?: return - val coverPath = if (updateCover) widgetCoverCache.resolveForWidget(current) else null + val current = st.current + val coverPath = if (updateCover && current != null) widgetCoverCache.resolveForWidget(current) else null PlatformServices.homeWidget.update( - songName = current.musicName, - artist = current.artist, + songName = current?.musicName, + artist = current?.artist, isPlaying = st.isPlaying, - favorite = current.isLove == true, + favorite = current?.isLove == true, coverPath = coverPath, playText = I18n.t("play_text"), )