fix android desktop card bugs
This commit is contained in:
@@ -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() {
|
||||
|
||||
+84
-146
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package com.zhushenwudi.lovelivemusicplayer.native.home_widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.action.Action
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.actionParametersOf
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import com.zhushenwudi.lovelivemusicplayer.AppContainer
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidHomeWidgetController
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
|
||||
import com.zhushenwudi.lovelivemusicplayer.player.PlaybackService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Process-local availability: a persisted widget snapshot cannot prove the player is alive. */
|
||||
internal object HomeWidgetRuntime {
|
||||
private val running = MutableStateFlow(false)
|
||||
val isRunning = running.asStateFlow()
|
||||
|
||||
fun setRunning(value: Boolean) {
|
||||
running.value = value
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class HomeWidgetCommand(val value: String) {
|
||||
TOGGLE_PLAY("toggle_play"),
|
||||
PREVIOUS("previous"),
|
||||
NEXT("next"),
|
||||
TOGGLE_FAVORITE("toggle_love");
|
||||
|
||||
companion object {
|
||||
val key = ActionParameters.Key<String>("widget_command")
|
||||
|
||||
fun fromValue(value: String?): HomeWidgetCommand? = entries.firstOrNull { it.value == value }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun homeWidgetControlAction(
|
||||
command: HomeWidgetCommand,
|
||||
controlsEnabled: Boolean,
|
||||
openApp: () -> Action,
|
||||
): Action = if (controlsEnabled) {
|
||||
actionRunCallback<HomeWidgetPlaybackAction>(actionParametersOf(HomeWidgetCommand.key to command.value))
|
||||
} else {
|
||||
openApp()
|
||||
}
|
||||
|
||||
/** Glance delivers this callback without bringing MainActivity to the foreground. */
|
||||
class HomeWidgetPlaybackAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
val command = HomeWidgetCommand.fromValue(parameters[HomeWidgetCommand.key]) ?: return
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
val shutdown = context.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
|
||||
.getBoolean("isShutdown", false)
|
||||
// The process may have died since RemoteViews were published. Do not replay playback
|
||||
// commands on a cold start; refresh the controls into their open-app state instead.
|
||||
if (AppContainer.isInitialized && HomeWidgetRuntime.isRunning.value && !shutdown) {
|
||||
executeHomeWidgetCommand(context, command)
|
||||
}
|
||||
}
|
||||
AndroidHomeWidgetController(context).refresh()
|
||||
}
|
||||
}
|
||||
|
||||
/** Both legacy activity intents and widget callbacks execute ExoPlayer commands on the main thread. */
|
||||
internal fun executeHomeWidgetCommand(context: Context, command: HomeWidgetCommand) {
|
||||
if (!AppContainer.isInitialized) return
|
||||
val player = AppContainer.player
|
||||
when (command) {
|
||||
HomeWidgetCommand.TOGGLE_PLAY -> {
|
||||
if (!player.state.value.isPlaying) PlaybackService.start(context)
|
||||
player.toggle()
|
||||
}
|
||||
HomeWidgetCommand.PREVIOUS -> player.previous()
|
||||
HomeWidgetCommand.NEXT -> player.next()
|
||||
HomeWidgetCommand.TOGGLE_FAVORITE -> {
|
||||
val id = player.state.value.current?.musicId ?: return
|
||||
AppContainer.toggleLove(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.zhushenwudi.lovelivemusicplayer.native.home_widget
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
/** Immutable snapshots also invalidate an already running Glance composition. */
|
||||
data class HomeWidgetState(
|
||||
val songName: String = "",
|
||||
val songArtist: String = "",
|
||||
val isFavorite: Boolean = false,
|
||||
val isPlaying: Boolean = false,
|
||||
val isShutdown: Boolean = false,
|
||||
val controlsEnabled: Boolean = false,
|
||||
val playText: String = "Playing,Paused",
|
||||
val coverPath: String? = null,
|
||||
val backgroundColor: String = "",
|
||||
) {
|
||||
fun withAppRunning(running: Boolean): HomeWidgetState = copy(
|
||||
controlsEnabled = running && !isShutdown,
|
||||
isPlaying = isPlaying && running && !isShutdown,
|
||||
)
|
||||
|
||||
val playStateText: String
|
||||
get() {
|
||||
val labels = playText.split(',', limit = 2)
|
||||
val index = if (isPlaying) 0 else 1
|
||||
return labels.getOrNull(index)?.takeIf { it.isNotBlank() }
|
||||
?: if (isPlaying) "Playing" else "Paused"
|
||||
}
|
||||
|
||||
val musicColor: Color
|
||||
get() {
|
||||
val components = backgroundColor.split(',').map { it.toFloatOrNull() }
|
||||
if (components.size != 3 || components.any { it == null || !it.isFinite() }) {
|
||||
return Color.White
|
||||
}
|
||||
return Color(
|
||||
red = components[0]!!.coerceIn(0f, 1f),
|
||||
green = components[1]!!.coerceIn(0f, 1f),
|
||||
blue = components[2]!!.coerceIn(0f, 1f),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromPreferences(values: Map<String, *>): HomeWidgetState {
|
||||
val shutdown = values["isShutdown"] == true
|
||||
return HomeWidgetState(
|
||||
songName = values["songName"] as? String ?: "",
|
||||
songArtist = values["songArtist"] as? String ?: "",
|
||||
isFavorite = values["songFavorite"] == true,
|
||||
isPlaying = values["isPlaying"] == true && !shutdown,
|
||||
isShutdown = shutdown,
|
||||
playText = values["playText"] as? String ?: "Playing,Paused",
|
||||
coverPath = values["shareImage"] as? String,
|
||||
backgroundColor = values["bgColor"] as? String ?: "",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun SharedPreferences.widgetStates() = callbackFlow {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { preferences, _ ->
|
||||
trySend(HomeWidgetState.fromPreferences(preferences.all))
|
||||
}
|
||||
// Register first so a write during the initial read cannot be missed.
|
||||
registerOnSharedPreferenceChangeListener(listener)
|
||||
trySend(HomeWidgetState.fromPreferences(all))
|
||||
awaitClose { unregisterOnSharedPreferenceChangeListener(listener) }
|
||||
}.conflate().distinctUntilChanged()
|
||||
+15
-18
@@ -8,6 +8,7 @@ import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.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
|
||||
}
|
||||
|
||||
+68
-28
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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)
|
||||
}
|
||||
|
||||
+6
-6
@@ -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"),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user