Compare commits

..
14 Commits
1724 changed files with 9855 additions and 1948 deletions
File diff suppressed because it is too large Load Diff
@@ -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,56 +218,45 @@ abstract class HomeWidgetContent : GlanceAppWidget() {
}
@Composable
open fun RenderCD(isLargePauseWidget: Boolean) {
open fun RenderCD(state: HomeWidgetState, isLargePauseWidget: Boolean) {
val cdDrawable = if (isWhite) R.drawable.cd_white else R.drawable.cd_black
val offsetX = calcCdOffsetX()
val offsetY = calcCdOffsetY()
val offsetX = calcCdOffsetX(state)
val offsetY = calcCdOffsetY(state)
val startPadding = if (isLargePauseWidget) 0.dp else offsetX
val radio = if (isLargePauseWidget) 0.9f else 1f
if (isShutdown) {
Image(
provider = ImageProvider(cdDrawable),
contentDescription = "cd",
modifier = GlanceModifier
.size(size.height * radio)
.padding(
start = startPadding,
top = -offsetY,
end = -offsetX,
bottom = offsetY
)
)
} else {
Image(
provider = ImageProvider(cdDrawable),
contentDescription = "cd",
modifier = GlanceModifier
.size(size.height * radio)
.padding(
start = startPadding,
top = -offsetY,
end = -offsetX,
bottom = offsetY
)
.clickable(rippleOverride = R.drawable.click_ripple, onClick = widgetAction(LocalContext.current, "${AppUtils.HOME_WIDGET_URI_PREFIX}toggle_play"))
)
}
Image(
provider = ImageProvider(cdDrawable),
contentDescription = "cd",
modifier = GlanceModifier
.size(size.height * radio)
.padding(
start = startPadding,
top = -offsetY,
end = -offsetX,
bottom = offsetY
)
.clickable(
rippleOverride = R.drawable.click_ripple,
onClick = controlAction(state, HomeWidgetCommand.TOGGLE_PLAY),
)
)
}
@Composable
open fun RenderCover(isLargePauseWidget: Boolean) {
open fun RenderCover(state: HomeWidgetState, isLargePauseWidget: Boolean) {
val coverSize = cdSize * 0.4f
val coverAndCdDiffSize = (cdSize - coverSize) / 2
val offsetX = calcCdOffsetX()
val offsetY = calcCdOffsetY()
val offsetX = calcCdOffsetX(state)
val offsetY = calcCdOffsetY(state)
val startPadding =
if (isLargePauseWidget) coverAndCdDiffSize else offsetX + coverAndCdDiffSize
val radio = if (isLargePauseWidget) 0.95f else 1f
coverPath?.let {
state.coverPath?.let {
if (File(it).exists()) {
val bitmap = BitmapFactory.decodeFile(it) ?: return
Image(
provider = ImageProvider(BitmapFactory.decodeFile(it)),
provider = ImageProvider(bitmap),
contentDescription = "cover",
modifier = GlanceModifier
.size(size.height * radio)
@@ -325,21 +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
.size(20.dp)
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
}
val modifier = GlanceModifier
.size(20.dp)
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
Image(
provider = ImageProvider(resId),
contentDescription = "playButton",
@@ -349,13 +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
.size(15.dp)
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
}
val playModifier = GlanceModifier
.size(15.dp)
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
Box(
contentAlignment = Alignment.TopEnd,
modifier = GlanceModifier
@@ -374,7 +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
.size(imageSize)
.clickable(
rippleOverride = R.drawable.click_ripple,
onClick = widgetAction(
LocalContext.current,
"${AppUtils.HOME_WIDGET_URI_PREFIX}toggle_love",
),
)
}
if (state.isFavorite) R.drawable.widget_fav_click else R.drawable.widget_fav_unclick
val favoriteModifier = GlanceModifier
.size(imageSize)
.clickable(
rippleOverride = R.drawable.click_ripple,
onClick = controlAction(state, HomeWidgetCommand.TOGGLE_FAVORITE),
)
Image(
provider = ImageProvider(loveIcon),
contentDescription = "love",
@@ -433,6 +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,28 +57,38 @@ class AndroidHomeWidgetController(
playText = playText,
)
val contentChanged = content != lastContent
val coverChanged = !coverPath.isNullOrBlank() && coverPath != preImagePath
val requestedCover = coverPath?.takeIf { it.isNotBlank() }
val coverChanged = requestedCover != preImagePath || (lastContent == null && requestedCover == null)
if (!contentChanged && !coverChanged) return
if (contentChanged) {
lastContent = content
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
.edit(commit = true) {
putString("songName", content.songName)
putString("songArtist", content.artist)
putBoolean("songFavorite", content.favorite)
putBoolean("isPlaying", content.isPlaying)
putString("playText", content.playText)
if (content.songName.isNotBlank() || content.isPlaying) {
putBoolean("isShutdown", false)
}
}
refresh()
if (coverChanged) {
preImagePath = requestedCover
coverRevision++
}
if (coverChanged) coverPath?.let(::processCover)
lastContent = content
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
.edit(commit = true) {
putString("songName", content.songName)
putString("songArtist", content.artist)
putBoolean("songFavorite", content.favorite)
putBoolean("isPlaying", content.isPlaying)
putString("playText", content.playText)
if (coverChanged || content.songName.isBlank()) {
remove("shareImage")
remove("bgColor")
}
if (content.songName.isNotBlank() || content.isPlaying) {
putBoolean("isShutdown", false)
}
}
refresh()
if (coverChanged && requestedCover != null) processCover(requestedCover, coverRevision)
}
@Synchronized
override fun setShutdown(shutdown: Boolean) {
HomeWidgetRuntime.setRunning(!shutdown)
lastContent = null
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
.edit(commit = true) {
putBoolean("isShutdown", shutdown)
@@ -88,22 +105,45 @@ class AndroidHomeWidgetController(
}
}
private fun processCover(coverPath: String) {
if (preImagePath == coverPath || !File(coverPath).exists()) return
preImagePath = coverPath
private fun processCover(coverPath: String, revision: Long) {
scope.launch {
var originBitmap: Bitmap? = null
var coverBitmap: Bitmap? = null
var coverFile: File? = null
var published = false
try {
val originBitmap = BitmapFactory.decodeFile(coverPath)
val coverBitmap = ImageUtil.squareAndCircularBitmap(originBitmap, 180)
val handledCoverPath = ImageUtil.saveBitmapToFile(appContext, coverBitmap)
ImageUtil.savePathToSp(appContext, handledCoverPath)
originBitmap = BitmapFactory.decodeFile(coverPath) ?: return@launch
coverBitmap = ImageUtil.squareAndCircularBitmap(originBitmap, 180)
val color = ImageUtil.parsePhotoDomainColor(coverBitmap)
ImageUtil.saveColorToSp(appContext, color)
originBitmap.recycle()
coverBitmap.recycle()
} catch (_: Exception) {
// Publish only complete files; keep one cached bitmap per source, not per update.
val stagedFile = File(appContext.cacheDir, "widget-cover-${UUID.randomUUID()}.tmp")
coverFile = stagedFile
stagedFile.outputStream().use { output ->
check(coverBitmap.compress(Bitmap.CompressFormat.PNG, 100, output))
}
synchronized(this@AndroidHomeWidgetController) {
// A slow decode for a previous track must not replace the current artwork.
if (revision != coverRevision) return@launch
val cacheKey = UUID.nameUUIDFromBytes(coverPath.toByteArray(Charsets.UTF_8))
val cachedFile = File(appContext.cacheDir, "widget-cover-$cacheKey.png")
if (!cachedFile.exists()) check(stagedFile.renameTo(cachedFile))
val preferences = appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
preferences.edit(commit = true) {
putString("shareImage", cachedFile.absolutePath)
putString("bgColor", String.format(Locale.US, "%.4f,%.4f,%.4f", color.red, color.green, color.blue))
}
published = true
refresh()
}
} catch (error: Exception) {
Log.w("LLMP-Widget", "Unable to prepare widget cover", error)
} finally {
refresh()
originBitmap?.recycle()
coverBitmap?.recycle()
coverFile?.delete()
if (!published) synchronized(this@AndroidHomeWidgetController) {
if (revision == coverRevision) preImagePath = null
}
}
}
}
@@ -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)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Some files were not shown because too many files have changed in this diff Show More