Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa028266ee | ||
|
|
493c70ce81 | ||
|
|
fa61bcca38 | ||
|
|
7b856f574e | ||
|
|
53897bda1a | ||
|
|
52ab485e9c | ||
|
|
34842e2577 | ||
|
|
e3ce4aa9fd | ||
|
|
926dbd9ff3 | ||
|
|
cf7a5ed2ed | ||
|
|
027171cf59 | ||
|
|
a046790ab0 | ||
|
|
284b9cc028 | ||
|
|
43e9e1b916 |
@@ -6,18 +6,25 @@ import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.zhushenwudi.lovelivemusicplayer.db.DriverFactory
|
||||
import com.zhushenwudi.lovelivemusicplayer.db.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
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.util.applyScreenOrientationPolicy
|
||||
import com.zhushenwudi.lovelivemusicplayer.platform.AndroidUiHost
|
||||
import com.zhushenwudi.lovelivemusicplayer.platform.PlatformServices
|
||||
import com.zhushenwudi.lovelivemusicplayer.platform.initAndroidFileSystem
|
||||
@@ -30,11 +37,14 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
applyScreenOrientationPolicy()
|
||||
enableEdgeToEdge()
|
||||
AndroidUiHost.activity = this
|
||||
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)
|
||||
@@ -43,12 +53,10 @@ class MainActivity : ComponentActivity() {
|
||||
database = database,
|
||||
player = player!!,
|
||||
)
|
||||
AppContainer.musicTransfer.setPlatform("android")
|
||||
} else {
|
||||
player = AppContainer.player
|
||||
}
|
||||
|
||||
installAndroidPlatformServices(applicationContext)
|
||||
val lyricEnabled = AppContainer.settings.enableDesktopLyric
|
||||
if (!PlatformServices.desktopLyric.pipAutoOpen(lyricEnabled) && lyricEnabled) {
|
||||
AppContainer.settings.enableDesktopLyric = false
|
||||
@@ -57,18 +65,39 @@ 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)
|
||||
|
||||
requestNotifyPermission()
|
||||
startPlaybackService()
|
||||
|
||||
// 系统栏 inset 和窗口高度到位之前不画出第一帧,避免顶栏先铺进状态栏再缩回。
|
||||
holdDrawUntilSystemBarsReady()
|
||||
setContent { App() }
|
||||
}
|
||||
|
||||
private fun holdDrawUntilSystemBarsReady() {
|
||||
val content = findViewById<View>(android.R.id.content) ?: return
|
||||
content.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
|
||||
private var skips = 0
|
||||
|
||||
override fun onPreDraw(): Boolean {
|
||||
val top = ViewCompat.getRootWindowInsets(content)
|
||||
?.getInsets(WindowInsetsCompat.Type.statusBars())
|
||||
?.top ?: 0
|
||||
val ready = skips >= 3 || (content.height > 0 && top > 0)
|
||||
if (ready) {
|
||||
content.viewTreeObserver.removeOnPreDrawListener(this)
|
||||
return true
|
||||
}
|
||||
skips++
|
||||
content.postOnAnimation { content.invalidate() }
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
@@ -88,6 +117,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
AndroidUiHost.activity = this
|
||||
com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidAppIconArtwork.applyTaskIcon(this)
|
||||
AppUtils.getSchemeData(intent) { url ->
|
||||
PlatformServices.deepLink.handle(url)
|
||||
}
|
||||
@@ -135,15 +165,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() {
|
||||
|
||||
@@ -53,6 +53,10 @@ class DesktopLyricOverlay(
|
||||
@Volatile
|
||||
private var cachedCurrentLine: Int = -1
|
||||
|
||||
fun refreshAppIcon() {
|
||||
mainHandler.post { activeRef?.get()?.applyAppIcon() }
|
||||
}
|
||||
|
||||
fun updateLyric(lyricLine1: String?, lyricLine2: String?, currentLine: Int) {
|
||||
if (lyricLine1 != null) cachedLine1 = lyricLine1
|
||||
if (lyricLine2 != null) cachedLine2 = lyricLine2
|
||||
@@ -147,6 +151,12 @@ class DesktopLyricOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyAppIcon() {
|
||||
val bitmap = com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidAppIconArtwork.bitmap(context)
|
||||
ivIcon?.background = null
|
||||
if (bitmap == null) ivIcon?.setImageResource(R.drawable.logo) else ivIcon?.setImageBitmap(bitmap)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun bindView() {
|
||||
val rl = root ?: return
|
||||
@@ -157,6 +167,7 @@ class DesktopLyricOverlay(
|
||||
ivClose = rl.findViewById(R.id.ic_close)
|
||||
ivPip = rl.findViewById(R.id.ic_pip)
|
||||
ivIcon = rl.findViewById(R.id.ic_icon)
|
||||
applyAppIcon()
|
||||
ivTranslate = rl.findViewById(R.id.ic_translate)
|
||||
applyCachedToViews()
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import androidx.activity.ComponentActivity
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import com.zhushenwudi.lovelivemusicplayer.i18n.I18n
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.util.applyScreenOrientationPolicy
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.util.isLargeScreenDevice
|
||||
import com.zhushenwudi.lovelivemusicplayer.platform.AndroidUiHost
|
||||
|
||||
class QrScanActivity : ComponentActivity() {
|
||||
@@ -22,11 +24,12 @@ class QrScanActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
applyScreenOrientationPolicy()
|
||||
val options = ScanOptions().apply {
|
||||
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||
setPrompt(I18n.t("scan_qr_prompt"))
|
||||
setBeepEnabled(false)
|
||||
setOrientationLocked(false)
|
||||
setOrientationLocked(!isLargeScreenDevice())
|
||||
setCaptureActivity(com.journeyapps.barcodescanner.CaptureActivity::class.java)
|
||||
}
|
||||
barcodeLauncher.launch(options)
|
||||
|
||||
@@ -11,6 +11,7 @@ import android.webkit.WebViewClient
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
@@ -28,16 +30,23 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.zhushenwudi.lovelivemusicplayer.i18n.tr
|
||||
import com.zhushenwudi.lovelivemusicplayer.native.util.applyScreenOrientationPolicy
|
||||
import com.zhushenwudi.lovelivemusicplayer.platform.WebViewAdBlock
|
||||
import java.io.ByteArrayInputStream
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class WebViewActivity : ComponentActivity() {
|
||||
private var webView: WebView? = null
|
||||
@@ -48,6 +57,8 @@ class WebViewActivity : ComponentActivity() {
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
applyScreenOrientationPolicy()
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
val url = intent.getStringExtra(EXTRA_URL).orEmpty()
|
||||
val title = intent.getStringExtra(EXTRA_TITLE)
|
||||
title?.let { setTitle(it) }
|
||||
@@ -67,7 +78,22 @@ class WebViewActivity : ComponentActivity() {
|
||||
val progress by pageProgress
|
||||
|
||||
MaterialTheme {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
SideEffect {
|
||||
WindowCompat.getInsetsController(window, window.decorView)
|
||||
.isAppearanceLightStatusBars = true
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(1.seconds)
|
||||
webView?.evaluateJavascript(WebViewAdBlock.domCleanupScript(), null)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White)
|
||||
.statusBarsPadding(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
@@ -162,8 +188,8 @@ class WebViewActivity : ComponentActivity() {
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
): WebResourceResponse? {
|
||||
val host = request?.url?.host
|
||||
if (!host.isNullOrBlank() && WebViewAdBlock.isBlockedHost(host)) {
|
||||
val target = request?.url?.toString().orEmpty()
|
||||
if (WebViewAdBlock.isBlockedUrl(target)) {
|
||||
return WebResourceResponse(
|
||||
"text/plain",
|
||||
"utf-8",
|
||||
@@ -180,8 +206,9 @@ class WebViewActivity : ComponentActivity() {
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
if (view != null && !url.isNullOrBlank()) {
|
||||
view.evaluateJavascript(WebViewAdBlock.injectionScript(url), null)
|
||||
if (view != null) {
|
||||
view.evaluateJavascript(WebViewAdBlock.cosmeticStyleScript(), null)
|
||||
view.evaluateJavascript(WebViewAdBlock.domCleanupScript(), null)
|
||||
}
|
||||
onNavigationChanged()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -219,32 +179,31 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
||||
|
||||
@Composable
|
||||
open fun RenderLogo(size: Dp, paddingInDp: Dp) {
|
||||
val bitmap = com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidAppIconArtwork.bitmap(LocalContext.current, dark = !isWhite)
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.logo),
|
||||
provider = if (bitmap == null) ImageProvider(R.drawable.logo) else ImageProvider(bitmap),
|
||||
contentDescription = "logo",
|
||||
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,14 +218,13 @@ 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",
|
||||
@@ -278,37 +236,27 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
||||
end = -offsetX,
|
||||
bottom = offsetY
|
||||
)
|
||||
.clickable(
|
||||
rippleOverride = R.drawable.click_ripple,
|
||||
onClick = controlAction(state, HomeWidgetCommand.TOGGLE_PLAY),
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
provider = ImageProvider(cdDrawable),
|
||||
contentDescription = "cd",
|
||||
modifier = GlanceModifier
|
||||
.size(size.height * radio)
|
||||
.padding(
|
||||
start = startPadding,
|
||||
top = -offsetY,
|
||||
end = -offsetX,
|
||||
bottom = offsetY
|
||||
)
|
||||
.clickable(rippleOverride = R.drawable.click_ripple, onClick = widgetAction(LocalContext.current, "${AppUtils.HOME_WIDGET_URI_PREFIX}toggle_play"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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 +273,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
|
||||
val modifier = GlanceModifier
|
||||
.size(20.dp)
|
||||
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
|
||||
}
|
||||
Image(
|
||||
provider = ImageProvider(resId),
|
||||
contentDescription = "playButton",
|
||||
@@ -349,13 +290,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
|
||||
val playModifier = GlanceModifier
|
||||
.size(15.dp)
|
||||
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.TopEnd,
|
||||
modifier = GlanceModifier
|
||||
@@ -374,7 +311,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 +323,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
|
||||
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 = widgetAction(
|
||||
LocalContext.current,
|
||||
"${AppUtils.HOME_WIDGET_URI_PREFIX}toggle_love",
|
||||
),
|
||||
onClick = controlAction(state, HomeWidgetCommand.TOGGLE_FAVORITE),
|
||||
)
|
||||
}
|
||||
Image(
|
||||
provider = ImageProvider(loveIcon),
|
||||
contentDescription = "love",
|
||||
@@ -433,6 +365,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
||||
.width(size.width)
|
||||
.height(size.height)
|
||||
.cornerRadius(radius)
|
||||
.clickable(openAppAction(LocalContext.current))
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(bitmap),
|
||||
@@ -446,7 +379,13 @@ 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.zhushenwudi.lovelivemusicplayer.native.platform
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.AtomicFile
|
||||
import com.zhushenwudi.lovelivemusicplayer.appicon.AppIconCatalog
|
||||
import java.io.File
|
||||
|
||||
/** Persist previews independently of the Activity so widgets can render after process death. */
|
||||
object AndroidAppIconArtwork {
|
||||
private fun prefs(context: Context) = context.getSharedPreferences("llmp_icon_artwork", Context.MODE_PRIVATE)
|
||||
private fun directory(context: Context, id: String) = File(context.filesDir, "app-icon-artwork/$id")
|
||||
private fun isDark(context: Context) = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
|
||||
|
||||
fun file(context: Context, dark: Boolean = isDark(context)): File? {
|
||||
val id = prefs(context).getString("id", null) ?: return null
|
||||
if (!AppIconCatalog.isValid(id) || id == AppIconCatalog.DEFAULT) return null
|
||||
return File(directory(context, id), if (dark) "dark.png" else "light.png").takeIf { it.isFile }
|
||||
}
|
||||
|
||||
fun bitmap(context: Context, dark: Boolean = isDark(context)): Bitmap? =
|
||||
file(context, dark)?.let { BitmapFactory.decodeFile(it.path) }
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun applyTaskIcon(activity: Activity) {
|
||||
val label = activity.applicationInfo.loadLabel(activity.packageManager).toString()
|
||||
val image = bitmap(activity)
|
||||
activity.setTaskDescription(if (image == null) ActivityManager.TaskDescription(label) else ActivityManager.TaskDescription(label, image))
|
||||
}
|
||||
|
||||
fun publish(context: Context, id: String, light: ByteArray?, dark: ByteArray?): Boolean {
|
||||
require(AppIconCatalog.isValid(id))
|
||||
var changed = false
|
||||
if (id != AppIconCatalog.DEFAULT) {
|
||||
require(light != null && dark != null)
|
||||
val dir = directory(context, id)
|
||||
check(dir.isDirectory || dir.mkdirs())
|
||||
listOf("light.png" to light, "dark.png" to dark).forEach { (name, bytes) ->
|
||||
val file = File(dir, name)
|
||||
if (!file.isFile || !file.readBytes().contentEquals(bytes)) {
|
||||
val atomic = AtomicFile(file)
|
||||
val stream = atomic.startWrite()
|
||||
try { stream.write(bytes); atomic.finishWrite(stream) }
|
||||
catch (error: Exception) { atomic.failWrite(stream); throw error }
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
val prefs = prefs(context)
|
||||
val systemDark = isDark(context)
|
||||
changed = changed || prefs.getString("id", null) != id || prefs.getBoolean("dark", !systemDark) != systemDark
|
||||
check(prefs.edit().putString("id", id).putBoolean("dark", systemDark).commit())
|
||||
return changed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.zhushenwudi.lovelivemusicplayer.native.platform
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.zhushenwudi.lovelivemusicplayer.appicon.AppIconCatalog
|
||||
import com.zhushenwudi.lovelivemusicplayer.appicon.AppIconController
|
||||
|
||||
class AndroidAppIconController(context: Context) : AppIconController {
|
||||
private val context = context.applicationContext
|
||||
private val pm = context.packageManager
|
||||
override val supported = true
|
||||
override val appearanceHintKey = "app_icon_android_hint"
|
||||
private val aliases = listOf("Default") + AppIconCatalog.characters.flatMap {
|
||||
listOf(it.id.replace('-', '_') + "_light", it.id.replace('-', '_') + "_dark")
|
||||
}
|
||||
private fun component(alias: String) = ComponentName(context.packageName, "${context.packageName}.icon.$alias")
|
||||
private fun enabled(alias: String): Boolean = when (pm.getComponentEnabledSetting(component(alias))) {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> true
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> alias == "Default"
|
||||
else -> false
|
||||
}
|
||||
override suspend fun publishArtwork(characterId: String, light: ByteArray?, dark: ByteArray?) {
|
||||
val changed = withContext(Dispatchers.IO) { AndroidAppIconArtwork.publish(context, characterId, light, dark) }
|
||||
if (changed) {
|
||||
withContext(Dispatchers.Main) {
|
||||
com.zhushenwudi.lovelivemusicplayer.platform.AndroidUiHost.activity?.let(AndroidAppIconArtwork::applyTaskIcon)
|
||||
}
|
||||
com.zhushenwudi.lovelivemusicplayer.platform.PlatformServices.homeWidget.refresh()
|
||||
com.zhushenwudi.lovelivemusicplayer.native.DesktopLyricOverlay.refreshAppIcon()
|
||||
}
|
||||
}
|
||||
override suspend fun currentCharacterId(): String? = withContext(Dispatchers.IO) {
|
||||
val active = aliases.filter(::enabled).singleOrNull() ?: return@withContext null
|
||||
if (active == "Default") AppIconCatalog.DEFAULT else
|
||||
active.removeSuffix("_light").removeSuffix("_dark").replace('_', '-')
|
||||
}
|
||||
override suspend fun apply(characterId: String, systemDark: Boolean): Unit = withContext(Dispatchers.IO) {
|
||||
require(AppIconCatalog.isValid(characterId))
|
||||
val target = if (characterId == AppIconCatalog.DEFAULT) "Default" else
|
||||
characterId.replace('-', '_') + if (systemDark) "_dark" else "_light"
|
||||
val changes = aliases.filter { enabled(it) != (it == target) }
|
||||
if (changes.isEmpty()) return@withContext
|
||||
val previous = changes.associateWith { pm.getComponentEnabledSetting(component(it)) }
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
pm.setComponentEnabledSettings(changes.map {
|
||||
PackageManager.ComponentEnabledSetting(component(it),
|
||||
if (it == target) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||
PackageManager.DONT_KILL_APP)
|
||||
})
|
||||
} else {
|
||||
// Keep at least one launcher entry enabled throughout the transition.
|
||||
pm.setComponentEnabledSetting(component(target), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP)
|
||||
changes.filter { it != target }.forEach {
|
||||
pm.setComponentEnabledSetting(component(it), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
|
||||
}
|
||||
}
|
||||
check(aliases.filter(::enabled) == listOf(target)) { "Launcher icon change did not complete" }
|
||||
} catch (error: Exception) {
|
||||
try {
|
||||
// Re-enable the previous launchers before disabling the failed target.
|
||||
previous.entries.sortedBy { if (it.value == PackageManager.COMPONENT_ENABLED_STATE_ENABLED || (it.value == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT && it.key == "Default")) 0 else 1 }
|
||||
.forEach { (alias, state) -> pm.setComponentEnabledSetting(component(alias), state, PackageManager.DONT_KILL_APP) }
|
||||
} catch (rollback: Exception) { error.addSuppressed(rollback) }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,10 +57,14 @@ 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) {
|
||||
if (coverChanged) {
|
||||
preImagePath = requestedCover
|
||||
coverRevision++
|
||||
}
|
||||
lastContent = content
|
||||
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
|
||||
.edit(commit = true) {
|
||||
@@ -62,16 +73,22 @@ class AndroidHomeWidgetController(
|
||||
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) coverPath?.let(::processCover)
|
||||
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,23 +105,46 @@ 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) {
|
||||
} finally {
|
||||
// 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 {
|
||||
originBitmap?.recycle()
|
||||
coverBitmap?.recycle()
|
||||
coverFile?.delete()
|
||||
if (!published) synchronized(this@AndroidHomeWidgetController) {
|
||||
if (revision == coverRevision) preImagePath = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,9 @@ class AndroidShareBridge(
|
||||
putString(QQShare.SHARE_TO_QQ_TARGET_URL, url)
|
||||
putString(QQShare.SHARE_TO_QQ_APP_NAME, "LLMP")
|
||||
val preview = imageUrl?.takeIf { it.startsWith("http") } ?: Const.SHARE_DEFAULT_LOGO
|
||||
putString(QQShare.SHARE_TO_QQ_IMAGE_URL, preview)
|
||||
val localIcon = if (preview == Const.SHARE_DEFAULT_LOGO) AndroidAppIconArtwork.file(appContext) else null
|
||||
if (localIcon != null) putString(QQShare.SHARE_TO_QQ_IMAGE_LOCAL_URL, localIcon.path)
|
||||
else putString(QQShare.SHARE_TO_QQ_IMAGE_URL, preview)
|
||||
}
|
||||
runCatching {
|
||||
sdk.shareToQQ(activity, params, listener)
|
||||
|
||||
@@ -14,6 +14,7 @@ fun installAndroidPlatformServices(context: Context) {
|
||||
UmengBridge.attachContext(appContext)
|
||||
|
||||
PlatformServices.install(
|
||||
appIcon = AndroidAppIconController(appContext),
|
||||
desktopLyric = AndroidDesktopLyricController(appContext),
|
||||
homeWidget = AndroidHomeWidgetController(appContext),
|
||||
deepLink = AndroidDeepLinkHandler(),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.zhushenwudi.lovelivemusicplayer.native.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.pm.ActivityInfo
|
||||
|
||||
private const val LARGE_SCREEN_MIN_SMALLEST_WIDTH_DP = 600
|
||||
|
||||
/** 手机固定竖屏;平板和展开后达到大屏阈值的设备遵循系统旋转设置。 */
|
||||
fun Activity.applyScreenOrientationPolicy() {
|
||||
val isLargeScreen = resources.configuration.smallestScreenWidthDp >=
|
||||
LARGE_SCREEN_MIN_SMALLEST_WIDTH_DP
|
||||
val targetOrientation = if (isLargeScreen) {
|
||||
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
} else {
|
||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
}
|
||||
if (requestedOrientation != targetOrientation) {
|
||||
requestedOrientation = targetOrientation
|
||||
}
|
||||
}
|
||||
|
||||
/** 扫码组件使用同一设备分类,避免手机进入扫码页后重新开放旋转。 */
|
||||
fun Activity.isLargeScreenDevice(): Boolean =
|
||||
resources.configuration.smallestScreenWidthDp >= LARGE_SCREEN_MIN_SMALLEST_WIDTH_DP
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 48 KiB |