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.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewTreeObserver
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.edit
|
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.DriverFactory
|
||||||
import com.zhushenwudi.lovelivemusicplayer.db.createDatabase
|
import com.zhushenwudi.lovelivemusicplayer.db.createDatabase
|
||||||
|
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetCommand
|
||||||
|
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.executeHomeWidgetCommand
|
||||||
import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidShareBridge
|
import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidShareBridge
|
||||||
import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidDesktopLyricController
|
import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidDesktopLyricController
|
||||||
import com.zhushenwudi.lovelivemusicplayer.native.platform.installAndroidPlatformServices
|
import com.zhushenwudi.lovelivemusicplayer.native.platform.installAndroidPlatformServices
|
||||||
import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
|
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.AndroidUiHost
|
||||||
import com.zhushenwudi.lovelivemusicplayer.platform.PlatformServices
|
import com.zhushenwudi.lovelivemusicplayer.platform.PlatformServices
|
||||||
import com.zhushenwudi.lovelivemusicplayer.platform.initAndroidFileSystem
|
import com.zhushenwudi.lovelivemusicplayer.platform.initAndroidFileSystem
|
||||||
@@ -30,11 +37,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
applyScreenOrientationPolicy()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
AndroidUiHost.activity = this
|
AndroidUiHost.activity = this
|
||||||
initAndroidFileSystem(applicationContext)
|
initAndroidFileSystem(applicationContext)
|
||||||
|
|
||||||
if (!AppContainer.isInitialized) {
|
if (!AppContainer.isInitialized) {
|
||||||
|
// Install before init() starts observing/restoring playback state.
|
||||||
|
installAndroidPlatformServices(applicationContext)
|
||||||
val settings = createAppSettings(applicationContext)
|
val settings = createAppSettings(applicationContext)
|
||||||
val database = createDatabase(DriverFactory(applicationContext))
|
val database = createDatabase(DriverFactory(applicationContext))
|
||||||
player = PlayerController(applicationContext)
|
player = PlayerController(applicationContext)
|
||||||
@@ -43,12 +53,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
database = database,
|
database = database,
|
||||||
player = player!!,
|
player = player!!,
|
||||||
)
|
)
|
||||||
AppContainer.musicTransfer.setPlatform("android")
|
|
||||||
} else {
|
} else {
|
||||||
player = AppContainer.player
|
player = AppContainer.player
|
||||||
}
|
}
|
||||||
|
|
||||||
installAndroidPlatformServices(applicationContext)
|
|
||||||
val lyricEnabled = AppContainer.settings.enableDesktopLyric
|
val lyricEnabled = AppContainer.settings.enableDesktopLyric
|
||||||
if (!PlatformServices.desktopLyric.pipAutoOpen(lyricEnabled) && lyricEnabled) {
|
if (!PlatformServices.desktopLyric.pipAutoOpen(lyricEnabled) && lyricEnabled) {
|
||||||
AppContainer.settings.enableDesktopLyric = false
|
AppContainer.settings.enableDesktopLyric = false
|
||||||
@@ -57,18 +65,39 @@ class MainActivity : ComponentActivity() {
|
|||||||
getSharedPreferences("my_prefs", MODE_PRIVATE).getBoolean("key_agreed", false),
|
getSharedPreferences("my_prefs", MODE_PRIVATE).getBoolean("key_agreed", false),
|
||||||
)
|
)
|
||||||
|
|
||||||
getSharedPreferences(AppUtils.SP_NAME, MODE_PRIVATE).edit(commit = true) {
|
PlatformServices.homeWidget.setShutdown(false)
|
||||||
putBoolean("isShutdown", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
handleIncomingIntent(intent)
|
handleIncomingIntent(intent)
|
||||||
|
|
||||||
requestNotifyPermission()
|
requestNotifyPermission()
|
||||||
startPlaybackService()
|
startPlaybackService()
|
||||||
|
|
||||||
|
// 系统栏 inset 和窗口高度到位之前不画出第一帧,避免顶栏先铺进状态栏再缩回。
|
||||||
|
holdDrawUntilSystemBarsReady()
|
||||||
setContent { App() }
|
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) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
@@ -88,6 +117,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
AndroidUiHost.activity = this
|
AndroidUiHost.activity = this
|
||||||
|
com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidAppIconArtwork.applyTaskIcon(this)
|
||||||
AppUtils.getSchemeData(intent) { url ->
|
AppUtils.getSchemeData(intent) { url ->
|
||||||
PlatformServices.deepLink.handle(url)
|
PlatformServices.deepLink.handle(url)
|
||||||
}
|
}
|
||||||
@@ -135,15 +165,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleWidgetUri(uri: String) {
|
private fun handleWidgetUri(uri: String) {
|
||||||
when {
|
val command = HomeWidgetCommand.fromValue(uri.removePrefix(AppUtils.HOME_WIDGET_URI_PREFIX)) ?: return
|
||||||
uri.endsWith("toggle_play") -> player?.toggle()
|
executeHomeWidgetCommand(this, command)
|
||||||
uri.endsWith("next") -> player?.next()
|
|
||||||
uri.endsWith("previous") -> player?.previous()
|
|
||||||
uri.endsWith("toggle_love") -> {
|
|
||||||
val id = player?.state?.value?.current?.musicId ?: return
|
|
||||||
AppContainer.toggleLove(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestNotifyPermission() {
|
private fun requestNotifyPermission() {
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ class DesktopLyricOverlay(
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var cachedCurrentLine: Int = -1
|
private var cachedCurrentLine: Int = -1
|
||||||
|
|
||||||
|
fun refreshAppIcon() {
|
||||||
|
mainHandler.post { activeRef?.get()?.applyAppIcon() }
|
||||||
|
}
|
||||||
|
|
||||||
fun updateLyric(lyricLine1: String?, lyricLine2: String?, currentLine: Int) {
|
fun updateLyric(lyricLine1: String?, lyricLine2: String?, currentLine: Int) {
|
||||||
if (lyricLine1 != null) cachedLine1 = lyricLine1
|
if (lyricLine1 != null) cachedLine1 = lyricLine1
|
||||||
if (lyricLine2 != null) cachedLine2 = lyricLine2
|
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")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
private fun bindView() {
|
private fun bindView() {
|
||||||
val rl = root ?: return
|
val rl = root ?: return
|
||||||
@@ -157,6 +167,7 @@ class DesktopLyricOverlay(
|
|||||||
ivClose = rl.findViewById(R.id.ic_close)
|
ivClose = rl.findViewById(R.id.ic_close)
|
||||||
ivPip = rl.findViewById(R.id.ic_pip)
|
ivPip = rl.findViewById(R.id.ic_pip)
|
||||||
ivIcon = rl.findViewById(R.id.ic_icon)
|
ivIcon = rl.findViewById(R.id.ic_icon)
|
||||||
|
applyAppIcon()
|
||||||
ivTranslate = rl.findViewById(R.id.ic_translate)
|
ivTranslate = rl.findViewById(R.id.ic_translate)
|
||||||
applyCachedToViews()
|
applyCachedToViews()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.activity.ComponentActivity
|
|||||||
import com.journeyapps.barcodescanner.ScanContract
|
import com.journeyapps.barcodescanner.ScanContract
|
||||||
import com.journeyapps.barcodescanner.ScanOptions
|
import com.journeyapps.barcodescanner.ScanOptions
|
||||||
import com.zhushenwudi.lovelivemusicplayer.i18n.I18n
|
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
|
import com.zhushenwudi.lovelivemusicplayer.platform.AndroidUiHost
|
||||||
|
|
||||||
class QrScanActivity : ComponentActivity() {
|
class QrScanActivity : ComponentActivity() {
|
||||||
@@ -22,11 +24,12 @@ class QrScanActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
applyScreenOrientationPolicy()
|
||||||
val options = ScanOptions().apply {
|
val options = ScanOptions().apply {
|
||||||
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||||
setPrompt(I18n.t("scan_qr_prompt"))
|
setPrompt(I18n.t("scan_qr_prompt"))
|
||||||
setBeepEnabled(false)
|
setBeepEnabled(false)
|
||||||
setOrientationLocked(false)
|
setOrientationLocked(!isLargeScreenDevice())
|
||||||
setCaptureActivity(com.journeyapps.barcodescanner.CaptureActivity::class.java)
|
setCaptureActivity(com.journeyapps.barcodescanner.CaptureActivity::class.java)
|
||||||
}
|
}
|
||||||
barcodeLauncher.launch(options)
|
barcodeLauncher.launch(options)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import android.webkit.WebViewClient
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.addCallback
|
import androidx.activity.addCallback
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
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.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
import com.zhushenwudi.lovelivemusicplayer.i18n.tr
|
import com.zhushenwudi.lovelivemusicplayer.i18n.tr
|
||||||
|
import com.zhushenwudi.lovelivemusicplayer.native.util.applyScreenOrientationPolicy
|
||||||
import com.zhushenwudi.lovelivemusicplayer.platform.WebViewAdBlock
|
import com.zhushenwudi.lovelivemusicplayer.platform.WebViewAdBlock
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class WebViewActivity : ComponentActivity() {
|
class WebViewActivity : ComponentActivity() {
|
||||||
private var webView: WebView? = null
|
private var webView: WebView? = null
|
||||||
@@ -48,6 +57,8 @@ class WebViewActivity : ComponentActivity() {
|
|||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
applyScreenOrientationPolicy()
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
val url = intent.getStringExtra(EXTRA_URL).orEmpty()
|
val url = intent.getStringExtra(EXTRA_URL).orEmpty()
|
||||||
val title = intent.getStringExtra(EXTRA_TITLE)
|
val title = intent.getStringExtra(EXTRA_TITLE)
|
||||||
title?.let { setTitle(it) }
|
title?.let { setTitle(it) }
|
||||||
@@ -67,7 +78,22 @@ class WebViewActivity : ComponentActivity() {
|
|||||||
val progress by pageProgress
|
val progress by pageProgress
|
||||||
|
|
||||||
MaterialTheme {
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -162,8 +188,8 @@ class WebViewActivity : ComponentActivity() {
|
|||||||
view: WebView?,
|
view: WebView?,
|
||||||
request: WebResourceRequest?,
|
request: WebResourceRequest?,
|
||||||
): WebResourceResponse? {
|
): WebResourceResponse? {
|
||||||
val host = request?.url?.host
|
val target = request?.url?.toString().orEmpty()
|
||||||
if (!host.isNullOrBlank() && WebViewAdBlock.isBlockedHost(host)) {
|
if (WebViewAdBlock.isBlockedUrl(target)) {
|
||||||
return WebResourceResponse(
|
return WebResourceResponse(
|
||||||
"text/plain",
|
"text/plain",
|
||||||
"utf-8",
|
"utf-8",
|
||||||
@@ -180,8 +206,9 @@ class WebViewActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onPageFinished(view: WebView?, url: String?) {
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
super.onPageFinished(view, url)
|
super.onPageFinished(view, url)
|
||||||
if (view != null && !url.isNullOrBlank()) {
|
if (view != null) {
|
||||||
view.evaluateJavascript(WebViewAdBlock.injectionScript(url), null)
|
view.evaluateJavascript(WebViewAdBlock.cosmeticStyleScript(), null)
|
||||||
|
view.evaluateJavascript(WebViewAdBlock.domCleanupScript(), null)
|
||||||
}
|
}
|
||||||
onNavigationChanged()
|
onNavigationChanged()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import androidx.glance.LocalContext
|
|||||||
import androidx.glance.action.Action
|
import androidx.glance.action.Action
|
||||||
import androidx.glance.appwidget.action.actionStartActivity
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
import com.zhushenwudi.lovelivemusicplayer.MainActivity
|
import com.zhushenwudi.lovelivemusicplayer.MainActivity
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.DpSize
|
import androidx.compose.ui.unit.DpSize
|
||||||
@@ -51,70 +52,32 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
|||||||
open var radius: Dp = 6.dp
|
open var radius: Dp = 6.dp
|
||||||
open val cdSize = 80.dp
|
open val cdSize = 80.dp
|
||||||
abstract var isWhite: Boolean
|
abstract var isWhite: Boolean
|
||||||
var isPlaying = true
|
|
||||||
var isShutdown = false
|
|
||||||
private var isFavorite = false
|
|
||||||
private lateinit var sp: SharedPreferences
|
|
||||||
private lateinit var songName: String
|
|
||||||
private lateinit var songArtist: String
|
|
||||||
private lateinit var playText: String
|
|
||||||
|
|
||||||
private var musicColor = Color.White
|
private fun openAppAction(context: Context): Action {
|
||||||
private var coverPath: String? = null
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun widgetAction(context: Context, uri: String): Action {
|
|
||||||
val intent = Intent(context, MainActivity::class.java).apply {
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
action = Intent.ACTION_VIEW
|
action = Intent.ACTION_VIEW
|
||||||
data = uri.toUri()
|
data = "${AppUtils.HOME_WIDGET_URI_PREFIX}open".toUri()
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||||
|
Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
}
|
}
|
||||||
return actionStartActivity(intent)
|
return actionStartActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GlanceContent(preferences: SharedPreferences) {
|
private fun controlAction(state: HomeWidgetState, command: HomeWidgetCommand): Action {
|
||||||
sp = preferences
|
val context = LocalContext.current
|
||||||
songName = sp.getString("songName", "")!!
|
return homeWidgetControlAction(command, state.controlsEnabled) { openAppAction(context) }
|
||||||
songArtist = sp.getString("songArtist", "")!!
|
|
||||||
isFavorite = sp.getBoolean("songFavorite", false)
|
|
||||||
isPlaying = sp.getBoolean("isPlaying", false)
|
|
||||||
playText = sp.getString("playText", "")!!
|
|
||||||
isShutdown = sp.getBoolean("isShutdown", false)
|
|
||||||
coverPath = sp.getString("shareImage", null)
|
|
||||||
val strBgColor = sp.getString("bgColor", "")!!
|
|
||||||
|
|
||||||
coverPath?.let {
|
|
||||||
if (!File(it).exists()) {
|
|
||||||
coverPath = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strBgColor.contains(",")) {
|
|
||||||
val bgColorArr = strBgColor.split(",")
|
|
||||||
musicColor = Color(
|
|
||||||
red = bgColorArr[0].toDoubleOrNull()?.toInt() ?: 1,
|
|
||||||
green = bgColorArr[1].toDoubleOrNull()?.toInt() ?: 1,
|
|
||||||
blue = bgColorArr[2].toDoubleOrNull()?.toInt() ?: 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isShutdown) {
|
|
||||||
isPlaying = false
|
|
||||||
}
|
|
||||||
|
|
||||||
RenderAppWidget()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RenderAppWidget() {
|
fun RenderAppWidget(state: HomeWidgetState) {
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.width(size.width)
|
.width(size.width)
|
||||||
.height(size.height)
|
.height(size.height)
|
||||||
) {
|
) {
|
||||||
if (isWhite) {
|
if (isWhite) {
|
||||||
GradientRectangle(color = musicColor)
|
GradientRectangle(color = state.musicColor)
|
||||||
} else {
|
} else {
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
@@ -122,6 +85,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
|||||||
.width(size.width)
|
.width(size.width)
|
||||||
.height(size.height)
|
.height(size.height)
|
||||||
.cornerRadius(radius)
|
.cornerRadius(radius)
|
||||||
|
.clickable(openAppAction(LocalContext.current))
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,9 +102,9 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
|||||||
.size(size.height)
|
.size(size.height)
|
||||||
.cornerRadius(radius)
|
.cornerRadius(radius)
|
||||||
) {
|
) {
|
||||||
RenderCD(isLargePauseWidget = false)
|
RenderCD(state, isLargePauseWidget = false)
|
||||||
RenderCover(isLargePauseWidget = false)
|
RenderCover(state, isLargePauseWidget = false)
|
||||||
RenderPlayController(false)
|
RenderPlayController(state, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,20 +120,21 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
|||||||
RenderLogo(size = 22.dp, paddingInDp = (-6).dp)
|
RenderLogo(size = 22.dp, paddingInDp = (-6).dp)
|
||||||
}
|
}
|
||||||
|
|
||||||
RenderTextArr(stateFontSize = 9.sp, infoFontSize = 11.sp)
|
RenderTextArr(state, stateFontSize = 9.sp, infoFontSize = 11.sp)
|
||||||
}
|
}
|
||||||
|
|
||||||
RenderFavorite(imageSize = 20.dp, paddingInDp = radius)
|
RenderFavorite(state, imageSize = 20.dp, paddingInDp = radius)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun calcCdOffsetX(): Dp = cdSize / 2 - 10.dp
|
open fun calcCdOffsetX(state: HomeWidgetState): Dp = cdSize / 2 - 10.dp
|
||||||
|
|
||||||
open fun calcCdOffsetY(): Dp = cdSize / 2 - 10.dp
|
open fun calcCdOffsetY(state: HomeWidgetState): Dp = cdSize / 2 - 10.dp
|
||||||
|
|
||||||
@SuppressLint("RestrictedApi")
|
@SuppressLint("RestrictedApi")
|
||||||
@Composable
|
@Composable
|
||||||
open fun RenderTextArr(
|
open fun RenderTextArr(
|
||||||
|
state: HomeWidgetState,
|
||||||
stateFontSize: TextUnit,
|
stateFontSize: TextUnit,
|
||||||
infoFontSize: TextUnit
|
infoFontSize: TextUnit
|
||||||
) {
|
) {
|
||||||
@@ -178,13 +143,8 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
|||||||
} else {
|
} else {
|
||||||
size.width - 75.dp
|
size.width - 75.dp
|
||||||
}
|
}
|
||||||
val playTextArr = playText.split(",")
|
|
||||||
var playStateText = playTextArr[1]
|
|
||||||
if (!isShutdown && isPlaying) {
|
|
||||||
playStateText = playTextArr[0]
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = playStateText,
|
text = state.playStateText,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = stateFontSize,
|
fontSize = stateFontSize,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
@@ -195,7 +155,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = songName,
|
text = state.songName,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = infoFontSize,
|
fontSize = infoFontSize,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
@@ -206,7 +166,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = songArtist,
|
text = state.songArtist,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = infoFontSize,
|
fontSize = infoFontSize,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
@@ -219,32 +179,31 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
open fun RenderLogo(size: Dp, paddingInDp: Dp) {
|
open fun RenderLogo(size: Dp, paddingInDp: Dp) {
|
||||||
|
val bitmap = com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidAppIconArtwork.bitmap(LocalContext.current, dark = !isWhite)
|
||||||
Image(
|
Image(
|
||||||
provider = ImageProvider(R.drawable.logo),
|
provider = if (bitmap == null) ImageProvider(R.drawable.logo) else ImageProvider(bitmap),
|
||||||
contentDescription = "logo",
|
contentDescription = "logo",
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.size(size)
|
.size(size)
|
||||||
.padding(start = paddingInDp)
|
.padding(start = paddingInDp)
|
||||||
|
.clickable(openAppAction(LocalContext.current))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("RestrictedApi")
|
@SuppressLint("RestrictedApi")
|
||||||
@Composable
|
@Composable
|
||||||
private fun RenderTransportButton(
|
private fun RenderTransportButton(
|
||||||
|
state: HomeWidgetState,
|
||||||
drawable: Int,
|
drawable: Int,
|
||||||
description: String,
|
description: String,
|
||||||
uri: String,
|
command: HomeWidgetCommand,
|
||||||
imageSize: Dp,
|
imageSize: Dp,
|
||||||
) {
|
) {
|
||||||
val base = GlanceModifier.size(imageSize)
|
val modifier = GlanceModifier.size(imageSize)
|
||||||
val modifier = if (isShutdown) {
|
.clickable(
|
||||||
base
|
|
||||||
} else {
|
|
||||||
base.clickable(
|
|
||||||
rippleOverride = R.drawable.click_ripple,
|
rippleOverride = R.drawable.click_ripple,
|
||||||
onClick = widgetAction(LocalContext.current, uri),
|
onClick = controlAction(state, command),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -259,14 +218,13 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
open fun RenderCD(isLargePauseWidget: Boolean) {
|
open fun RenderCD(state: HomeWidgetState, isLargePauseWidget: Boolean) {
|
||||||
val cdDrawable = if (isWhite) R.drawable.cd_white else R.drawable.cd_black
|
val cdDrawable = if (isWhite) R.drawable.cd_white else R.drawable.cd_black
|
||||||
val offsetX = calcCdOffsetX()
|
val offsetX = calcCdOffsetX(state)
|
||||||
val offsetY = calcCdOffsetY()
|
val offsetY = calcCdOffsetY(state)
|
||||||
|
|
||||||
val startPadding = if (isLargePauseWidget) 0.dp else offsetX
|
val startPadding = if (isLargePauseWidget) 0.dp else offsetX
|
||||||
val radio = if (isLargePauseWidget) 0.9f else 1f
|
val radio = if (isLargePauseWidget) 0.9f else 1f
|
||||||
if (isShutdown) {
|
|
||||||
Image(
|
Image(
|
||||||
provider = ImageProvider(cdDrawable),
|
provider = ImageProvider(cdDrawable),
|
||||||
contentDescription = "cd",
|
contentDescription = "cd",
|
||||||
@@ -278,37 +236,27 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
|||||||
end = -offsetX,
|
end = -offsetX,
|
||||||
bottom = offsetY
|
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
|
@Composable
|
||||||
open fun RenderCover(isLargePauseWidget: Boolean) {
|
open fun RenderCover(state: HomeWidgetState, isLargePauseWidget: Boolean) {
|
||||||
val coverSize = cdSize * 0.4f
|
val coverSize = cdSize * 0.4f
|
||||||
val coverAndCdDiffSize = (cdSize - coverSize) / 2
|
val coverAndCdDiffSize = (cdSize - coverSize) / 2
|
||||||
val offsetX = calcCdOffsetX()
|
val offsetX = calcCdOffsetX(state)
|
||||||
val offsetY = calcCdOffsetY()
|
val offsetY = calcCdOffsetY(state)
|
||||||
val startPadding =
|
val startPadding =
|
||||||
if (isLargePauseWidget) coverAndCdDiffSize else offsetX + coverAndCdDiffSize
|
if (isLargePauseWidget) coverAndCdDiffSize else offsetX + coverAndCdDiffSize
|
||||||
val radio = if (isLargePauseWidget) 0.95f else 1f
|
val radio = if (isLargePauseWidget) 0.95f else 1f
|
||||||
coverPath?.let {
|
state.coverPath?.let {
|
||||||
if (File(it).exists()) {
|
if (File(it).exists()) {
|
||||||
|
val bitmap = BitmapFactory.decodeFile(it) ?: return
|
||||||
Image(
|
Image(
|
||||||
provider = ImageProvider(BitmapFactory.decodeFile(it)),
|
provider = ImageProvider(bitmap),
|
||||||
contentDescription = "cover",
|
contentDescription = "cover",
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.size(size.height * radio)
|
.size(size.height * radio)
|
||||||
@@ -325,21 +273,14 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
|||||||
|
|
||||||
@SuppressLint("RestrictedApi")
|
@SuppressLint("RestrictedApi")
|
||||||
@Composable
|
@Composable
|
||||||
open fun RenderPlayController(isLargePauseWidget: Boolean) {
|
open fun RenderPlayController(state: HomeWidgetState, isLargePauseWidget: Boolean) {
|
||||||
val resId = if (isPlaying) R.drawable.vector_pause else R.drawable.vector_play
|
val resId = if (state.isPlaying) R.drawable.vector_pause else R.drawable.vector_play
|
||||||
val toggleAction = widgetAction(
|
val toggleAction = controlAction(state, HomeWidgetCommand.TOGGLE_PLAY)
|
||||||
LocalContext.current,
|
|
||||||
"${AppUtils.HOME_WIDGET_URI_PREFIX}toggle_play",
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isLargePauseWidget) {
|
if (isLargePauseWidget) {
|
||||||
val modifier = if (isShutdown) {
|
val modifier = GlanceModifier
|
||||||
GlanceModifier.size(20.dp)
|
|
||||||
} else {
|
|
||||||
GlanceModifier
|
|
||||||
.size(20.dp)
|
.size(20.dp)
|
||||||
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
|
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
|
||||||
}
|
|
||||||
Image(
|
Image(
|
||||||
provider = ImageProvider(resId),
|
provider = ImageProvider(resId),
|
||||||
contentDescription = "playButton",
|
contentDescription = "playButton",
|
||||||
@@ -349,13 +290,9 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
|||||||
} else {
|
} else {
|
||||||
val padding =
|
val padding =
|
||||||
if (size.width == AppUtils.HORIZONTAL_RECTANGLE.width) 26.dp else 23.dp
|
if (size.width == AppUtils.HORIZONTAL_RECTANGLE.width) 26.dp else 23.dp
|
||||||
val playModifier = if (isShutdown) {
|
val playModifier = GlanceModifier
|
||||||
GlanceModifier.size(15.dp)
|
|
||||||
} else {
|
|
||||||
GlanceModifier
|
|
||||||
.size(15.dp)
|
.size(15.dp)
|
||||||
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
|
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
|
||||||
}
|
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.TopEnd,
|
contentAlignment = Alignment.TopEnd,
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
@@ -374,7 +311,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
open fun RenderFavorite(imageSize: Dp, paddingInDp: Dp) {
|
open fun RenderFavorite(state: HomeWidgetState, imageSize: Dp, paddingInDp: Dp) {
|
||||||
val isLarge = size.width == AppUtils.HORIZONTAL_RECTANGLE.width
|
val isLarge = size.width == AppUtils.HORIZONTAL_RECTANGLE.width
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.BottomEnd,
|
contentAlignment = Alignment.BottomEnd,
|
||||||
@@ -386,35 +323,30 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
|||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (isLarge) {
|
if (isLarge) {
|
||||||
RenderTransportButton(
|
RenderTransportButton(
|
||||||
|
state = state,
|
||||||
drawable = R.drawable.player_play_prev,
|
drawable = R.drawable.player_play_prev,
|
||||||
description = "previous",
|
description = "previous",
|
||||||
uri = "${AppUtils.HOME_WIDGET_URI_PREFIX}previous",
|
command = HomeWidgetCommand.PREVIOUS,
|
||||||
imageSize = imageSize,
|
imageSize = imageSize,
|
||||||
)
|
)
|
||||||
Spacer(GlanceModifier.width(8.dp))
|
Spacer(GlanceModifier.width(8.dp))
|
||||||
}
|
}
|
||||||
RenderTransportButton(
|
RenderTransportButton(
|
||||||
|
state = state,
|
||||||
drawable = R.drawable.player_play_next,
|
drawable = R.drawable.player_play_next,
|
||||||
description = "next",
|
description = "next",
|
||||||
uri = "${AppUtils.HOME_WIDGET_URI_PREFIX}next",
|
command = HomeWidgetCommand.NEXT,
|
||||||
imageSize = imageSize,
|
imageSize = imageSize,
|
||||||
)
|
)
|
||||||
Spacer(GlanceModifier.width(8.dp))
|
Spacer(GlanceModifier.width(8.dp))
|
||||||
val loveIcon =
|
val loveIcon =
|
||||||
if (isFavorite) R.drawable.widget_fav_click else R.drawable.widget_fav_unclick
|
if (state.isFavorite) R.drawable.widget_fav_click else R.drawable.widget_fav_unclick
|
||||||
val favoriteModifier = if (isShutdown) {
|
val favoriteModifier = GlanceModifier
|
||||||
GlanceModifier.size(imageSize)
|
|
||||||
} else {
|
|
||||||
GlanceModifier
|
|
||||||
.size(imageSize)
|
.size(imageSize)
|
||||||
.clickable(
|
.clickable(
|
||||||
rippleOverride = R.drawable.click_ripple,
|
rippleOverride = R.drawable.click_ripple,
|
||||||
onClick = widgetAction(
|
onClick = controlAction(state, HomeWidgetCommand.TOGGLE_FAVORITE),
|
||||||
LocalContext.current,
|
|
||||||
"${AppUtils.HOME_WIDGET_URI_PREFIX}toggle_love",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
Image(
|
Image(
|
||||||
provider = ImageProvider(loveIcon),
|
provider = ImageProvider(loveIcon),
|
||||||
contentDescription = "love",
|
contentDescription = "love",
|
||||||
@@ -433,6 +365,7 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
|||||||
.width(size.width)
|
.width(size.width)
|
||||||
.height(size.height)
|
.height(size.height)
|
||||||
.cornerRadius(radius)
|
.cornerRadius(radius)
|
||||||
|
.clickable(openAppAction(LocalContext.current))
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
provider = ImageProvider(bitmap),
|
provider = ImageProvider(bitmap),
|
||||||
@@ -446,7 +379,13 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
|
|||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val preferences = context.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
|
val preferences = context.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
|
||||||
provideContent { GlanceContent(preferences = preferences) }
|
val states = preferences.widgetStates()
|
||||||
|
val initialState = HomeWidgetState.fromPreferences(preferences.all)
|
||||||
|
provideContent {
|
||||||
|
val state by states.collectAsState(initialState)
|
||||||
|
val running by HomeWidgetRuntime.isRunning.collectAsState()
|
||||||
|
RenderAppWidget(state.withAppRunning(running))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.zhushenwudi.lovelivemusicplayer.native.home_widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.action.Action
|
||||||
|
import androidx.glance.action.ActionParameters
|
||||||
|
import androidx.glance.action.actionParametersOf
|
||||||
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
|
import androidx.glance.appwidget.action.actionRunCallback
|
||||||
|
import com.zhushenwudi.lovelivemusicplayer.AppContainer
|
||||||
|
import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidHomeWidgetController
|
||||||
|
import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
|
||||||
|
import com.zhushenwudi.lovelivemusicplayer.player.PlaybackService
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** Process-local availability: a persisted widget snapshot cannot prove the player is alive. */
|
||||||
|
internal object HomeWidgetRuntime {
|
||||||
|
private val running = MutableStateFlow(false)
|
||||||
|
val isRunning = running.asStateFlow()
|
||||||
|
|
||||||
|
fun setRunning(value: Boolean) {
|
||||||
|
running.value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum class HomeWidgetCommand(val value: String) {
|
||||||
|
TOGGLE_PLAY("toggle_play"),
|
||||||
|
PREVIOUS("previous"),
|
||||||
|
NEXT("next"),
|
||||||
|
TOGGLE_FAVORITE("toggle_love");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val key = ActionParameters.Key<String>("widget_command")
|
||||||
|
|
||||||
|
fun fromValue(value: String?): HomeWidgetCommand? = entries.firstOrNull { it.value == value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun homeWidgetControlAction(
|
||||||
|
command: HomeWidgetCommand,
|
||||||
|
controlsEnabled: Boolean,
|
||||||
|
openApp: () -> Action,
|
||||||
|
): Action = if (controlsEnabled) {
|
||||||
|
actionRunCallback<HomeWidgetPlaybackAction>(actionParametersOf(HomeWidgetCommand.key to command.value))
|
||||||
|
} else {
|
||||||
|
openApp()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Glance delivers this callback without bringing MainActivity to the foreground. */
|
||||||
|
class HomeWidgetPlaybackAction : ActionCallback {
|
||||||
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
|
val command = HomeWidgetCommand.fromValue(parameters[HomeWidgetCommand.key]) ?: return
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
val shutdown = context.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
|
||||||
|
.getBoolean("isShutdown", false)
|
||||||
|
// The process may have died since RemoteViews were published. Do not replay playback
|
||||||
|
// commands on a cold start; refresh the controls into their open-app state instead.
|
||||||
|
if (AppContainer.isInitialized && HomeWidgetRuntime.isRunning.value && !shutdown) {
|
||||||
|
executeHomeWidgetCommand(context, command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AndroidHomeWidgetController(context).refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both legacy activity intents and widget callbacks execute ExoPlayer commands on the main thread. */
|
||||||
|
internal fun executeHomeWidgetCommand(context: Context, command: HomeWidgetCommand) {
|
||||||
|
if (!AppContainer.isInitialized) return
|
||||||
|
val player = AppContainer.player
|
||||||
|
when (command) {
|
||||||
|
HomeWidgetCommand.TOGGLE_PLAY -> {
|
||||||
|
if (!player.state.value.isPlaying) PlaybackService.start(context)
|
||||||
|
player.toggle()
|
||||||
|
}
|
||||||
|
HomeWidgetCommand.PREVIOUS -> player.previous()
|
||||||
|
HomeWidgetCommand.NEXT -> player.next()
|
||||||
|
HomeWidgetCommand.TOGGLE_FAVORITE -> {
|
||||||
|
val id = player.state.value.current?.musicId ?: return
|
||||||
|
AppContainer.toggleLove(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.zhushenwudi.lovelivemusicplayer.native.home_widget
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.conflate
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
|
/** Immutable snapshots also invalidate an already running Glance composition. */
|
||||||
|
data class HomeWidgetState(
|
||||||
|
val songName: String = "",
|
||||||
|
val songArtist: String = "",
|
||||||
|
val isFavorite: Boolean = false,
|
||||||
|
val isPlaying: Boolean = false,
|
||||||
|
val isShutdown: Boolean = false,
|
||||||
|
val controlsEnabled: Boolean = false,
|
||||||
|
val playText: String = "Playing,Paused",
|
||||||
|
val coverPath: String? = null,
|
||||||
|
val backgroundColor: String = "",
|
||||||
|
) {
|
||||||
|
fun withAppRunning(running: Boolean): HomeWidgetState = copy(
|
||||||
|
controlsEnabled = running && !isShutdown,
|
||||||
|
isPlaying = isPlaying && running && !isShutdown,
|
||||||
|
)
|
||||||
|
|
||||||
|
val playStateText: String
|
||||||
|
get() {
|
||||||
|
val labels = playText.split(',', limit = 2)
|
||||||
|
val index = if (isPlaying) 0 else 1
|
||||||
|
return labels.getOrNull(index)?.takeIf { it.isNotBlank() }
|
||||||
|
?: if (isPlaying) "Playing" else "Paused"
|
||||||
|
}
|
||||||
|
|
||||||
|
val musicColor: Color
|
||||||
|
get() {
|
||||||
|
val components = backgroundColor.split(',').map { it.toFloatOrNull() }
|
||||||
|
if (components.size != 3 || components.any { it == null || !it.isFinite() }) {
|
||||||
|
return Color.White
|
||||||
|
}
|
||||||
|
return Color(
|
||||||
|
red = components[0]!!.coerceIn(0f, 1f),
|
||||||
|
green = components[1]!!.coerceIn(0f, 1f),
|
||||||
|
blue = components[2]!!.coerceIn(0f, 1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromPreferences(values: Map<String, *>): HomeWidgetState {
|
||||||
|
val shutdown = values["isShutdown"] == true
|
||||||
|
return HomeWidgetState(
|
||||||
|
songName = values["songName"] as? String ?: "",
|
||||||
|
songArtist = values["songArtist"] as? String ?: "",
|
||||||
|
isFavorite = values["songFavorite"] == true,
|
||||||
|
isPlaying = values["isPlaying"] == true && !shutdown,
|
||||||
|
isShutdown = shutdown,
|
||||||
|
playText = values["playText"] as? String ?: "Playing,Paused",
|
||||||
|
coverPath = values["shareImage"] as? String,
|
||||||
|
backgroundColor = values["bgColor"] as? String ?: "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SharedPreferences.widgetStates() = callbackFlow {
|
||||||
|
val listener = SharedPreferences.OnSharedPreferenceChangeListener { preferences, _ ->
|
||||||
|
trySend(HomeWidgetState.fromPreferences(preferences.all))
|
||||||
|
}
|
||||||
|
// Register first so a write during the initial read cannot be missed.
|
||||||
|
registerOnSharedPreferenceChangeListener(listener)
|
||||||
|
trySend(HomeWidgetState.fromPreferences(all))
|
||||||
|
awaitClose { unregisterOnSharedPreferenceChangeListener(listener) }
|
||||||
|
}.conflate().distinctUntilChanged()
|
||||||
@@ -8,6 +8,7 @@ import androidx.compose.ui.unit.TextUnit
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetContent
|
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetContent
|
||||||
|
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetState
|
||||||
import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
|
import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
|
||||||
|
|
||||||
@SuppressLint("CommitPrefEdits", "DefaultLocale")
|
@SuppressLint("CommitPrefEdits", "DefaultLocale")
|
||||||
@@ -17,43 +18,39 @@ abstract class LargeHomeWidgetContent(
|
|||||||
override var cdSize: Dp = 120.dp,
|
override var cdSize: Dp = 120.dp,
|
||||||
) : HomeWidgetContent() {
|
) : HomeWidgetContent() {
|
||||||
|
|
||||||
private fun isLargePauseWidget(): Boolean {
|
|
||||||
return !isPlaying || isShutdown
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun RenderLogo(size: Dp, paddingInDp: Dp) {
|
override fun RenderLogo(size: Dp, paddingInDp: Dp) {
|
||||||
super.RenderLogo(size = 30.dp, paddingInDp)
|
super.RenderLogo(size = 30.dp, paddingInDp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun RenderCD(isLargePauseWidget: Boolean) {
|
override fun RenderCD(state: HomeWidgetState, isLargePauseWidget: Boolean) {
|
||||||
super.RenderCD(isLargePauseWidget = isLargePauseWidget())
|
super.RenderCD(state, isLargePauseWidget = !state.isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun RenderCover(isLargePauseWidget: Boolean) {
|
override fun RenderCover(state: HomeWidgetState, isLargePauseWidget: Boolean) {
|
||||||
super.RenderCover(isLargePauseWidget = isLargePauseWidget())
|
super.RenderCover(state, isLargePauseWidget = !state.isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun RenderPlayController(isLargePauseWidget: Boolean) {
|
override fun RenderPlayController(state: HomeWidgetState, isLargePauseWidget: Boolean) {
|
||||||
super.RenderPlayController(isLargePauseWidget = isLargePauseWidget())
|
super.RenderPlayController(state, isLargePauseWidget = !state.isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun RenderTextArr(stateFontSize: TextUnit, infoFontSize: TextUnit) {
|
override fun RenderTextArr(state: HomeWidgetState, stateFontSize: TextUnit, infoFontSize: TextUnit) {
|
||||||
super.RenderTextArr(stateFontSize = 12.sp, infoFontSize = 13.sp)
|
super.RenderTextArr(state, stateFontSize = 12.sp, infoFontSize = 13.sp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun RenderFavorite(imageSize: Dp, paddingInDp: Dp) {
|
override fun RenderFavorite(state: HomeWidgetState, imageSize: Dp, paddingInDp: Dp) {
|
||||||
super.RenderFavorite(imageSize = 28.dp, paddingInDp = radius / 2)
|
super.RenderFavorite(state, imageSize = 28.dp, paddingInDp = radius / 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun calcCdOffsetX(): Dp =
|
override fun calcCdOffsetX(state: HomeWidgetState): Dp =
|
||||||
if (isPlaying) cdSize / 2 + 5.dp else 12.dp
|
if (state.isPlaying) cdSize / 2 + 5.dp else 12.dp
|
||||||
|
|
||||||
override fun calcCdOffsetY(): Dp =
|
override fun calcCdOffsetY(state: HomeWidgetState): Dp =
|
||||||
if (isPlaying) cdSize / 2 + 5.dp else 12.dp
|
if (state.isPlaying) cdSize / 2 + 5.dp else 12.dp
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.Log
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetRuntime
|
||||||
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget.BlackLargeHomeWidgetReceiver
|
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget.BlackLargeHomeWidgetReceiver
|
||||||
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget.WhiteLargeHomeWidgetReceiver
|
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget.WhiteLargeHomeWidgetReceiver
|
||||||
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget.BlackSmallHomeWidgetReceiver
|
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget.BlackSmallHomeWidgetReceiver
|
||||||
@@ -18,6 +21,8 @@ import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
|
|||||||
import com.zhushenwudi.lovelivemusicplayer.native.util.ImageUtil
|
import com.zhushenwudi.lovelivemusicplayer.native.util.ImageUtil
|
||||||
import com.zhushenwudi.lovelivemusicplayer.platform.HomeWidgetController
|
import com.zhushenwudi.lovelivemusicplayer.platform.HomeWidgetController
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
class AndroidHomeWidgetController(
|
class AndroidHomeWidgetController(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -25,6 +30,7 @@ class AndroidHomeWidgetController(
|
|||||||
private val appContext = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private var preImagePath: String? = null
|
private var preImagePath: String? = null
|
||||||
|
private var coverRevision = 0L
|
||||||
private var lastContent: WidgetContent? = null
|
private var lastContent: WidgetContent? = null
|
||||||
|
|
||||||
private val receiverClasses = listOf(
|
private val receiverClasses = listOf(
|
||||||
@@ -34,6 +40,7 @@ class AndroidHomeWidgetController(
|
|||||||
BlackLargeHomeWidgetReceiver::class.java,
|
BlackLargeHomeWidgetReceiver::class.java,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
override fun update(
|
override fun update(
|
||||||
songName: String?,
|
songName: String?,
|
||||||
artist: String?,
|
artist: String?,
|
||||||
@@ -50,10 +57,14 @@ class AndroidHomeWidgetController(
|
|||||||
playText = playText,
|
playText = playText,
|
||||||
)
|
)
|
||||||
val contentChanged = content != lastContent
|
val contentChanged = content != lastContent
|
||||||
val coverChanged = !coverPath.isNullOrBlank() && coverPath != preImagePath
|
val requestedCover = coverPath?.takeIf { it.isNotBlank() }
|
||||||
|
val coverChanged = requestedCover != preImagePath || (lastContent == null && requestedCover == null)
|
||||||
if (!contentChanged && !coverChanged) return
|
if (!contentChanged && !coverChanged) return
|
||||||
|
|
||||||
if (contentChanged) {
|
if (coverChanged) {
|
||||||
|
preImagePath = requestedCover
|
||||||
|
coverRevision++
|
||||||
|
}
|
||||||
lastContent = content
|
lastContent = content
|
||||||
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
|
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
|
||||||
.edit(commit = true) {
|
.edit(commit = true) {
|
||||||
@@ -62,16 +73,22 @@ class AndroidHomeWidgetController(
|
|||||||
putBoolean("songFavorite", content.favorite)
|
putBoolean("songFavorite", content.favorite)
|
||||||
putBoolean("isPlaying", content.isPlaying)
|
putBoolean("isPlaying", content.isPlaying)
|
||||||
putString("playText", content.playText)
|
putString("playText", content.playText)
|
||||||
|
if (coverChanged || content.songName.isBlank()) {
|
||||||
|
remove("shareImage")
|
||||||
|
remove("bgColor")
|
||||||
|
}
|
||||||
if (content.songName.isNotBlank() || content.isPlaying) {
|
if (content.songName.isNotBlank() || content.isPlaying) {
|
||||||
putBoolean("isShutdown", false)
|
putBoolean("isShutdown", false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refresh()
|
refresh()
|
||||||
}
|
if (coverChanged && requestedCover != null) processCover(requestedCover, coverRevision)
|
||||||
if (coverChanged) coverPath?.let(::processCover)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
override fun setShutdown(shutdown: Boolean) {
|
override fun setShutdown(shutdown: Boolean) {
|
||||||
|
HomeWidgetRuntime.setRunning(!shutdown)
|
||||||
|
lastContent = null
|
||||||
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
|
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
|
||||||
.edit(commit = true) {
|
.edit(commit = true) {
|
||||||
putBoolean("isShutdown", shutdown)
|
putBoolean("isShutdown", shutdown)
|
||||||
@@ -88,23 +105,46 @@ class AndroidHomeWidgetController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun processCover(coverPath: String) {
|
private fun processCover(coverPath: String, revision: Long) {
|
||||||
if (preImagePath == coverPath || !File(coverPath).exists()) return
|
|
||||||
preImagePath = coverPath
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
var originBitmap: Bitmap? = null
|
||||||
|
var coverBitmap: Bitmap? = null
|
||||||
|
var coverFile: File? = null
|
||||||
|
var published = false
|
||||||
try {
|
try {
|
||||||
val originBitmap = BitmapFactory.decodeFile(coverPath)
|
originBitmap = BitmapFactory.decodeFile(coverPath) ?: return@launch
|
||||||
val coverBitmap = ImageUtil.squareAndCircularBitmap(originBitmap, 180)
|
coverBitmap = ImageUtil.squareAndCircularBitmap(originBitmap, 180)
|
||||||
val handledCoverPath = ImageUtil.saveBitmapToFile(appContext, coverBitmap)
|
|
||||||
ImageUtil.savePathToSp(appContext, handledCoverPath)
|
|
||||||
val color = ImageUtil.parsePhotoDomainColor(coverBitmap)
|
val color = ImageUtil.parsePhotoDomainColor(coverBitmap)
|
||||||
ImageUtil.saveColorToSp(appContext, color)
|
// Publish only complete files; keep one cached bitmap per source, not per update.
|
||||||
originBitmap.recycle()
|
val stagedFile = File(appContext.cacheDir, "widget-cover-${UUID.randomUUID()}.tmp")
|
||||||
coverBitmap.recycle()
|
coverFile = stagedFile
|
||||||
} catch (_: Exception) {
|
stagedFile.outputStream().use { output ->
|
||||||
} finally {
|
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()
|
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_TARGET_URL, url)
|
||||||
putString(QQShare.SHARE_TO_QQ_APP_NAME, "LLMP")
|
putString(QQShare.SHARE_TO_QQ_APP_NAME, "LLMP")
|
||||||
val preview = imageUrl?.takeIf { it.startsWith("http") } ?: Const.SHARE_DEFAULT_LOGO
|
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 {
|
runCatching {
|
||||||
sdk.shareToQQ(activity, params, listener)
|
sdk.shareToQQ(activity, params, listener)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ fun installAndroidPlatformServices(context: Context) {
|
|||||||
UmengBridge.attachContext(appContext)
|
UmengBridge.attachContext(appContext)
|
||||||
|
|
||||||
PlatformServices.install(
|
PlatformServices.install(
|
||||||
|
appIcon = AndroidAppIconController(appContext),
|
||||||
desktopLyric = AndroidDesktopLyricController(appContext),
|
desktopLyric = AndroidDesktopLyricController(appContext),
|
||||||
homeWidget = AndroidHomeWidgetController(appContext),
|
homeWidget = AndroidHomeWidgetController(appContext),
|
||||||
deepLink = AndroidDeepLinkHandler(),
|
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
|
private var instance: PlaybackService? = null
|
||||||
|
|
||||||
fun start(context: Context) {
|
fun start(context: Context) {
|
||||||
|
if (instance != null) return
|
||||||
val intent = Intent(context, PlaybackService::class.java)
|
val intent = Intent(context, PlaybackService::class.java)
|
||||||
ContextCompat.startForegroundService(context, intent)
|
ContextCompat.startForegroundService(context, intent)
|
||||||
}
|
}
|
||||||
|
|||||||
|
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 |