function adapt

This commit is contained in:
2026-08-30 19:36:25 +08:00
parent bea5ead33d
commit 560f3cbac3
450 changed files with 21193 additions and 8334 deletions
+109 -8
View File
@@ -1,4 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
plugins {
alias(libs.plugins.androidApplication)
@@ -9,31 +10,108 @@ plugins {
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
freeCompilerArgs.add("-Xexpect-actual-classes")
}
}
val appVersionCode = 20001
val appVersionName = "2.0.0.01"
val taskNames = gradle.startParameter.taskNames
val isApkRelease = taskNames.any { it.contains("assembleRelease", ignoreCase = true) }
val isAabRelease = taskNames.any { it.contains("bundleRelease", ignoreCase = true) }
val keystoreProperties = Properties()
if (isApkRelease || isAabRelease) {
val keystorePropertiesFile = file("key.properties")
if (!keystorePropertiesFile.exists()) {
error("release 模式需要在 androidApp 目录下创建 key.properties(可参考 key.properties.example)")
}
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
}
android {
namespace = "top.zhushenwudi.llmp"
namespace = "com.zhushenwudi.lovelivemusicplayer"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
applicationId = "top.zhushenwudi.llmp"
applicationId = "com.zhushenwudi.lovelivemusicplayer"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "0.2.0-phaseb"
versionCode = appVersionCode
versionName = appVersionName
}
splits {
abi {
isEnable = isApkRelease
reset()
include("armeabi-v7a", "arm64-v8a")
isUniversalApk = false
}
}
signingConfigs {
if (isApkRelease || isAabRelease) {
create("release") {
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("storePassword")
storePassword = keystoreProperties.getProperty("keyPassword")
val storePath = if (isAabRelease) {
keystoreProperties.getProperty("aabStoreFile")
} else {
keystoreProperties.getProperty("apkStoreFile")
}
storeFile = file(storePath)
}
}
}
buildTypes {
getByName("debug") {
isMinifyEnabled = false
isDebuggable = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
getByName("release") {
isMinifyEnabled = true
isShrinkResources = true
if (isApkRelease || isAabRelease) {
signingConfig = signingConfigs.getByName("release")
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// ABI 分包开启时不能再写 ndk.abiFilters,否则 AGP 会报冲突。
if (!isApkRelease) {
ndk {
//noinspection ChromeOsAbiSupport
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
}
}
}
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
dex {
useLegacyPackaging = true
}
jniLibs {
useLegacyPackaging = true
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
lint {
abortOnError = false
checkReleaseBuilds = false
disable += "InvalidPackage"
}
compileOptions {
@@ -42,6 +120,27 @@ android {
}
}
base.archivesName.set("LLMP_v$appVersionName")
afterEvaluate {
tasks.findByName("assembleRelease")?.doLast {
val apkRoot = layout.buildDirectory.dir("outputs/apk/release").get().asFile
if (!apkRoot.exists()) return@doLast
apkRoot.walkTopDown().maxDepth(2).filter { it.isFile && it.extension == "apk" }.forEach { apk ->
val shortAbi = when {
"arm64-v8a" in apk.name -> "v8a"
"armeabi-v7a" in apk.name -> "v7a"
else -> null
} ?: return@forEach
val target = apk.resolveSibling("LLMP_v${appVersionName}_${shortAbi}.apk")
if (apk.canonicalFile != target.canonicalFile) {
apk.copyTo(target, overwrite = true)
apk.delete()
}
}
}
}
dependencies {
implementation(projects.composeApp)
implementation(projects.shared)
@@ -57,10 +156,12 @@ dependencies {
implementation(libs.zxing.android.embedded)
implementation(libs.androidx.lifecycle.service)
implementation(libs.ktor.client.okhttp)
implementation(files("libs/open_sdk_3.5.19_r9483ffc7_lite.jar"))
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
+7
View File
@@ -0,0 +1,7 @@
# 复制为 androidApp/key.properties 后填写。assembleRelease / bundleRelease 会读这个文件。
# 字段名和赋值方式和原项目 android/app/build.gradle 保持一致(含 key/store 密码对调)。
keyAlias=
storePassword=
keyPassword=
apkStoreFile=signature/apk.jks
aabStoreFile=signature/aab.jks
Binary file not shown.
+39
View File
@@ -0,0 +1,39 @@
-keepattributes *Annotation*,InnerClasses,Signature,Exceptions,SourceFile,LineNumberTable
-keep class com.zhushenwudi.lovelivemusicplayer.** { *; }
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Service
-keep class com.tencent.** { *; }
-dontwarn com.tencent.**
-keep class com.umeng.** { *; }
-keep class org.repackage.** { *; }
-keepclassmembers class * {
public <init> (org.json.JSONObject);
}
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
-keep class androidx.media3.** { *; }
-keep class androidx.startup.** { *; }
-keep class androidx.work.** { *; }
-keep class androidx.room.** { *; }
-keep class * extends androidx.room.RoomDatabase
-keep class * extends androidx.room.RoomDatabase_Impl
-keep class * implements androidx.room.RoomDatabase
-keep class androidx.work.impl.WorkDatabase { *; }
-keep class androidx.work.impl.WorkDatabase_Impl { *; }
-keep class * extends androidx.work.impl.WorkDatabase
-keepnames class * extends androidx.room.RoomDatabase
-keepclassmembers class * extends androidx.room.RoomDatabase {
public static ** getInstance(...);
}
-keep class kotlinx.** { *; }
-keep class io.ktor.** { *; }
-dontwarn org.slf4j.**
-dontwarn okhttp3.internal.Version
-dontwarn java.lang.management.**
-dontwarn io.ktor.util.debug.**
Binary file not shown.
Binary file not shown.
+40 -2
View File
@@ -16,9 +16,16 @@
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
<queries>
<package android:name="com.tencent.mobileqq" />
<package android:name="com.tencent.tim" />
<package android:name="com.tencent.minihd.qq" />
</queries>
<application
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="LLMP KMP"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
@@ -29,7 +36,8 @@
android:exported="true"
android:launchMode="singleTop"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustResize"
android:enableOnBackInvokedCallback="false">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -59,9 +67,39 @@
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
<activity
android:name="com.tencent.tauth.AuthActivity"
android:launchMode="singleTask"
android:noHistory="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="tencent102039234" />
</intent-filter>
</activity>
<activity
android:name="com.tencent.connect.common.AssistActivity"
android:configChanges="orientation|keyboardHidden"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:exported="true" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".player.PlaybackService"
android:exported="true"
android:permission="android.permission.BIND_MEDIA_BROWSER_SERVICE"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
@@ -0,0 +1,166 @@
package com.zhushenwudi.lovelivemusicplayer
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
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 com.zhushenwudi.lovelivemusicplayer.db.DriverFactory
import com.zhushenwudi.lovelivemusicplayer.db.createDatabase
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.platform.AndroidUiHost
import com.zhushenwudi.lovelivemusicplayer.platform.PlatformServices
import com.zhushenwudi.lovelivemusicplayer.platform.initAndroidFileSystem
import com.zhushenwudi.lovelivemusicplayer.player.PlaybackService
import com.zhushenwudi.lovelivemusicplayer.player.PlayerController
import com.zhushenwudi.lovelivemusicplayer.settings.createAppSettings
class MainActivity : ComponentActivity() {
private var player: PlayerController? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
AndroidUiHost.activity = this
initAndroidFileSystem(applicationContext)
if (!AppContainer.isInitialized) {
val settings = createAppSettings(applicationContext)
val database = createDatabase(DriverFactory(applicationContext))
player = PlayerController(applicationContext)
AppContainer.init(
settings = settings,
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
}
PlatformServices.analytics.initIfAgreed(
getSharedPreferences("my_prefs", MODE_PRIVATE).getBoolean("key_agreed", false),
)
getSharedPreferences(AppUtils.SP_NAME, MODE_PRIVATE).edit(commit = true) {
putBoolean("isShutdown", false)
}
handleIncomingIntent(intent)
requestNotifyPermission()
startPlaybackService()
setContent { App() }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
AndroidShareBridge.handleIntentResult(intent)
handleIncomingIntent(intent)
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
AndroidShareBridge.handleActivityResult(requestCode, resultCode, data)
if (requestCode == AndroidDesktopLyricController.REQUEST_CODE_OVERLAY_PERMISSION) {
AndroidDesktopLyricController.onOverlayPermissionResult(Settings.canDrawOverlays(this))
}
}
override fun onResume() {
super.onResume()
AndroidUiHost.activity = this
AppUtils.getSchemeData(intent) { url ->
PlatformServices.deepLink.handle(url)
}
AppContainer.setAppInBackground(false)
}
override fun onPause() {
AppContainer.setAppInBackground(true)
super.onPause()
}
override fun onDestroy() {
if (AndroidUiHost.activity === this) {
AndroidUiHost.activity = null
}
if (isFinishing) {
val stillPlaying = player?.state?.value?.isPlaying == true
if (!stillPlaying) {
getSharedPreferences(AppUtils.SP_NAME, MODE_PRIVATE).edit(commit = true) {
putString("curJpLrc", "")
putString("nextJpLrc", "")
putBoolean("isPlaying", false)
putBoolean("isShutdown", true)
}
PlatformServices.homeWidget.setShutdown(true)
}
PlatformServices.desktopLyric.hide()
}
super.onDestroy()
}
private fun handleIncomingIntent(intent: Intent?) {
val data = intent?.data?.toString() ?: return
when {
data.startsWith(AppUtils.HOME_WIDGET_URI_PREFIX) -> {
handleWidgetUri(data)
intent.data = null
}
data.startsWith("llmp://") || data.startsWith("llmp:") -> {
PlatformServices.deepLink.handle(data)
intent.data = null
}
}
}
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)
}
}
}
private fun requestNotifyPermission() {
if (Build.VERSION.SDK_INT >= 33) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
100,
)
}
}
}
private fun startPlaybackService() {
PlaybackService.start(this)
}
}
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.native
package com.zhushenwudi.lovelivemusicplayer.native
import android.annotation.SuppressLint
import android.content.Context
@@ -18,21 +18,23 @@ import android.widget.LinearLayout
import android.widget.RelativeLayout
import android.widget.TextView
import android.widget.Toast
import java.lang.ref.WeakReference
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.zhushenwudi.llmp.MainActivity
import top.zhushenwudi.llmp.R
import com.zhushenwudi.lovelivemusicplayer.AppContainer
import com.zhushenwudi.lovelivemusicplayer.i18n.I18n
import com.zhushenwudi.lovelivemusicplayer.MainActivity
import com.zhushenwudi.lovelivemusicplayer.R
import java.lang.ref.WeakReference
import kotlin.math.abs
import kotlin.time.Duration.Companion.seconds
/**
* Floating desktop-lyric overlay.
* Hosted by [top.zhushenwudi.llmp.player.PlaybackService] so it shares the Media3 FGS notification.
* 桌面悬浮歌词叠加层。
* 由 [com.zhushenwudi.lovelivemusicplayer.player.PlaybackService] 承载,共享 Media3 FGS 通知。
*/
class DesktopLyricOverlay(
private val context: Context,
@@ -40,18 +42,20 @@ class DesktopLyricOverlay(
private val onRequestHide: () -> Unit,
) {
companion object {
private const val MSG_HINT = "正在打开应用..."
private val mainHandler = Handler(Looper.getMainLooper())
private var activeRef: WeakReference<DesktopLyricOverlay>? = null
@Volatile private var cachedLine1: String? = null
@Volatile private var cachedLine2: String? = null
@Volatile private var cachedCurrentLine: Int = -1
@Volatile
private var cachedLine1: String? = null
@Volatile
private var cachedLine2: String? = null
@Volatile
private var cachedCurrentLine: Int = -1
fun updateLyric(lyricLine1: String?, lyricLine2: String?, currentLine: Int) {
cachedLine1 = lyricLine1
cachedLine2 = lyricLine2
if (lyricLine1 != null) cachedLine1 = lyricLine1
if (lyricLine2 != null) cachedLine2 = lyricLine2
cachedCurrentLine = currentLine
val overlay = activeRef?.get() ?: return
if (Looper.myLooper() == Looper.getMainLooper()) {
@@ -87,6 +91,7 @@ class DesktopLyricOverlay(
fun attach() {
if (root != null) return
createView()
if (root == null) return
bindView()
activeRef = WeakReference(this)
startChromeTimer()
@@ -134,7 +139,12 @@ class DesktopLyricOverlay(
}
windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
root = LayoutInflater.from(context).inflate(R.layout.view_lyric, null) as RelativeLayout
windowManager?.addView(root, wmParams)
val added = runCatching { windowManager?.addView(root, wmParams) }
if (added.isFailure) {
android.util.Log.e("LLMP-Lyric", "add overlay failed", added.exceptionOrNull())
root = null
windowManager = null
}
}
@SuppressLint("ClickableViewAccessibility")
@@ -178,6 +188,7 @@ class DesktopLyricOverlay(
sx = mRawX
sy = mRawY
}
MotionEvent.ACTION_MOVE -> {
isMove = true
val params = wmParams ?: return@setOnTouchListener true
@@ -187,6 +198,7 @@ class DesktopLyricOverlay(
sx = mRawX
sy = mRawY
}
MotionEvent.ACTION_UP -> {
val elapsed = System.currentTimeMillis() - mLastTime
val mStopX = event.rawX.toInt()
@@ -206,11 +218,11 @@ class DesktopLyricOverlay(
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
Toast.makeText(context, MSG_HINT, Toast.LENGTH_LONG).show()
Toast.makeText(context, I18n.t("opening_app"), Toast.LENGTH_LONG).show()
onRequestHide()
}
ivTranslate?.setOnClickListener {
NativeEvents.lyricTypeClick()
AppContainer.cycleDesktopLyricType()
}
}
@@ -218,13 +230,13 @@ class DesktopLyricOverlay(
val lyricLine1 = cachedLine1
val lyricLine2 = cachedLine2
val currentLine = cachedCurrentLine
tvLyricLine1?.text = lyricLine1.orEmpty()
tvLyricLine2?.text = lyricLine2.orEmpty()
lyricLine1?.let { tvLyricLine1?.text = it }
lyricLine2?.let { tvLyricLine2?.text = it }
tvLyricLine1?.setTextColor(if (currentLine == 2) Color.LTGRAY else Color.WHITE)
tvLyricLine2?.setTextColor(if (currentLine == 1) Color.LTGRAY else Color.WHITE)
if (currentLine == -1) {
tvNoLyric?.text = lyricLine1 ?: "暂无歌词"
tvNoLyric?.text = lyricLine1 ?: I18n.t("no_lyrics")
tvLyricLine1?.visibility = View.GONE
tvLyricLine2?.visibility = View.GONE
tvNoLyric?.visibility = View.VISIBLE
@@ -1,9 +1,9 @@
package top.zhushenwudi.llmp.native
package com.zhushenwudi.lovelivemusicplayer.native
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import top.zhushenwudi.llmp.platform.DeepLinkPayload
import com.zhushenwudi.lovelivemusicplayer.platform.DeepLinkPayload
object NativeEvents {
private val _widgetEvents = MutableSharedFlow<String>(extraBufferCapacity = 8)
@@ -1,10 +1,11 @@
package top.zhushenwudi.llmp.native
package com.zhushenwudi.lovelivemusicplayer.native
import android.os.Bundle
import androidx.activity.ComponentActivity
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import top.zhushenwudi.llmp.platform.AndroidUiHost
import com.zhushenwudi.lovelivemusicplayer.i18n.I18n
import com.zhushenwudi.lovelivemusicplayer.platform.AndroidUiHost
class QrScanActivity : ComponentActivity() {
private val barcodeLauncher = registerForActivityResult(ScanContract()) { result ->
@@ -23,7 +24,7 @@ class QrScanActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
val options = ScanOptions().apply {
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
setPrompt("扫描二维码")
setPrompt(I18n.t("scan_qr_prompt"))
setBeepEnabled(false)
setOrientationLocked(false)
setCaptureActivity(com.journeyapps.barcodescanner.CaptureActivity::class.java)
@@ -1,7 +1,7 @@
package top.zhushenwudi.llmp.native
package com.zhushenwudi.lovelivemusicplayer.native
import android.content.Context
import top.zhushenwudi.llmp.platform.AnalyticsHook
import com.zhushenwudi.lovelivemusicplayer.platform.AnalyticsHook
object UmengBridge : AnalyticsHook {
private const val UMENG_KEY = "634bd9c688ccdf4b7e4ac67b"
@@ -0,0 +1,203 @@
package com.zhushenwudi.lovelivemusicplayer.native
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.os.Bundle
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.addCallback
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.zhushenwudi.lovelivemusicplayer.i18n.tr
import com.zhushenwudi.lovelivemusicplayer.platform.WebViewAdBlock
import java.io.ByteArrayInputStream
class WebViewActivity : ComponentActivity() {
private var webView: WebView? = null
private val canGoBack = mutableStateOf(false)
private val canGoForward = mutableStateOf(false)
private val pageProgress = mutableIntStateOf(0)
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val url = intent.getStringExtra(EXTRA_URL).orEmpty()
val title = intent.getStringExtra(EXTRA_TITLE)
title?.let { setTitle(it) }
onBackPressedDispatcher.addCallback(this) {
val view = webView
if (view?.canGoBack() == true) {
view.goBack()
} else {
finish()
}
}
setContent {
val backEnabled by canGoBack
val forwardEnabled by canGoForward
val progress by pageProgress
MaterialTheme {
Column(Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
webViewClient = AdBlockWebViewClient(::refreshNavigationState)
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
pageProgress.intValue = newProgress
}
}
webView = this
if (url.isNotBlank()) loadUrl(url)
refreshNavigationState()
}
},
update = { view ->
webView = view
refreshNavigationState()
},
)
if (progress in 1..99) {
LinearProgressIndicator(
progress = { progress / 100f },
modifier = Modifier
.fillMaxWidth()
.align(Alignment.TopCenter),
)
}
}
Surface(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding(),
tonalElevation = 3.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = { webView?.goBack() },
enabled = backEnabled,
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = tr("backward"),
)
}
IconButton(
onClick = { webView?.goForward() },
enabled = forwardEnabled,
) {
Icon(
Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = tr("forward"),
)
}
IconButton(onClick = { webView?.reload() }) {
Icon(Icons.Default.Refresh, contentDescription = tr("refresh"))
}
}
}
}
}
}
}
private fun refreshNavigationState() {
canGoBack.value = webView?.canGoBack() == true
canGoForward.value = webView?.canGoForward() == true
}
private class AdBlockWebViewClient(
private val onNavigationChanged: () -> Unit,
) : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean = false
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?,
): WebResourceResponse? {
val host = request?.url?.host
if (!host.isNullOrBlank() && WebViewAdBlock.isBlockedHost(host)) {
return WebResourceResponse(
"text/plain",
"utf-8",
ByteArrayInputStream(ByteArray(0)),
)
}
return super.shouldInterceptRequest(view, request)
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
onNavigationChanged()
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
if (view != null && !url.isNullOrBlank()) {
view.evaluateJavascript(WebViewAdBlock.injectionScript(url), null)
}
onNavigationChanged()
}
override fun doUpdateVisitedHistory(
view: WebView?,
url: String?,
isReload: Boolean,
) {
super.doUpdateVisitedHistory(view, url, isReload)
onNavigationChanged()
}
}
companion object {
const val EXTRA_URL = "url"
const val EXTRA_TITLE = "title"
}
}
@@ -1,426 +1,455 @@
package top.zhushenwudi.llmp.native.home_widget
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.action.Action
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import top.zhushenwudi.llmp.MainActivity
import top.zhushenwudi.llmp.R
import top.zhushenwudi.llmp.native.util.AppUtils
import top.zhushenwudi.llmp.native.util.ImageUtil
import java.io.File
abstract class HomeWidgetContent : GlanceAppWidget() {
open var size: DpSize = AppUtils.SMALL_SQUARE
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 lyricLine1: String = ""
private var lyricLine2: String = ""
private var currentLine: Int = -1
private var musicColor = Color.White
private var coverPath: String? = null
private var line1Color = Color.White
private var line2Color = Color.White
private fun widgetAction(context: Context, uri: String): Action {
val intent = Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = Uri.parse(uri)
flags = Intent.FLAG_ACTIVITY_NEW_TASK 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", "") ?: ""
val tempLine1 = sp.getString("lyricLine1", null)
val tempLine2 = sp.getString("lyricLine2", null)
currentLine = sp.getInt("currentLine", -1)
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.getOrNull(0)?.toFloatOrNull() ?: 1f,
green = bgColorArr.getOrNull(1)?.toFloatOrNull() ?: 1f,
blue = bgColorArr.getOrNull(2)?.toFloatOrNull() ?: 1f,
)
}
if (isShutdown) {
isPlaying = false
}
tempLine1?.let { lyricLine1 = it }
tempLine2?.let { lyricLine2 = it }
if (currentLine == -1 || currentLine == 1) {
line1Color = if (isWhite) Color.Black else Color.White
line2Color = Color.LightGray
} else {
line2Color = if (isWhite) Color.Black else Color.White
line1Color = Color.LightGray
}
RenderAppWidget()
}
@Composable
fun RenderAppWidget() {
Box(
modifier = GlanceModifier
.width(size.width)
.height(size.height),
) {
if (isWhite) {
GradientRectangle(color = musicColor)
} else {
Box(
modifier = GlanceModifier
.background(Color(red = 49, green = 49, blue = 49))
.width(size.width)
.height(size.height)
.cornerRadius(radius),
) {}
}
Box(
contentAlignment = Alignment.CenterEnd,
modifier = GlanceModifier
.width(size.width)
.height(size.height)
.cornerRadius(radius),
) {
Box(
contentAlignment = Alignment.Center,
modifier = GlanceModifier
.size(size.height)
.cornerRadius(radius),
) {
RenderCD(isLargePauseWidget = false)
RenderCover(isLargePauseWidget = false)
RenderPlayController(false)
}
}
Column(
modifier = GlanceModifier
.width(size.width)
.height(size.height)
.padding(radius),
) {
Box(
modifier = GlanceModifier.defaultWeight(),
) {
Column {
RenderLogo(size = 22.dp, paddingInDp = (-6).dp)
if (size.width == AppUtils.HORIZONTAL_RECTANGLE.width) {
RenderLyric(isLargePauseWidget = false)
}
}
}
RenderTextArr(stateFontSize = 9.sp, infoFontSize = 11.sp)
}
RenderFavorite(imageSize = 20.dp, paddingInDp = radius)
}
}
open fun calcCdOffsetX(): Dp = cdSize / 2 - 10.dp
open fun calcCdOffsetY(): Dp = cdSize / 2 - 10.dp
@SuppressLint("RestrictedApi")
@Composable
open fun RenderTextArr(
stateFontSize: TextUnit,
infoFontSize: TextUnit,
) {
val playTextArr = playText.split(",")
var playStateText = playTextArr.getOrElse(1) { "" }
if (!isShutdown && isPlaying) {
playStateText = playTextArr.getOrElse(0) { playStateText }
}
Text(
text = playStateText,
style = TextStyle(
fontSize = stateFontSize,
fontWeight = FontWeight.Bold,
color = ColorProvider(Color.Gray),
),
)
Text(
text = songName,
style = TextStyle(
fontSize = infoFontSize,
fontWeight = FontWeight.Bold,
color = ColorProvider(if (isWhite) Color.Black else Color.White),
),
)
Text(
text = songArtist,
style = TextStyle(
fontSize = infoFontSize,
fontWeight = FontWeight.Bold,
color = ColorProvider(Color.Gray),
),
)
}
@Composable
open fun RenderLogo(size: Dp, paddingInDp: Dp) {
Image(
provider = ImageProvider(R.drawable.logo),
contentDescription = "logo",
modifier = GlanceModifier
.size(size)
.padding(start = paddingInDp),
)
}
@SuppressLint("RestrictedApi")
@Composable
open fun RenderLyric(isLargePauseWidget: Boolean) {
val basicWidth = size.width
val width = if (isLargePauseWidget) basicWidth - 150.dp else basicWidth - 60.dp
Text(
text = lyricLine1,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = ColorProvider(line1Color),
),
maxLines = 1,
modifier = GlanceModifier.width(width),
)
Text(
text = lyricLine2,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = ColorProvider(line2Color),
),
maxLines = 1,
modifier = GlanceModifier.width(width),
)
}
@Composable
open fun RenderCD(isLargePauseWidget: Boolean) {
val context = LocalContext.current
val cdDrawable = if (isWhite) R.drawable.cd_white else R.drawable.cd_black
val offsetX = calcCdOffsetX()
val offsetY = calcCdOffsetY()
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(context, "homeWidgetExample://toggle_play"),
),
)
}
}
@Composable
open fun RenderCover(isLargePauseWidget: Boolean) {
val coverSize = cdSize * 0.4f
val coverAndCdDiffSize = (cdSize - coverSize) / 2
val offsetX = calcCdOffsetX()
val offsetY = calcCdOffsetY()
val startPadding =
if (isLargePauseWidget) coverAndCdDiffSize else offsetX + coverAndCdDiffSize
val radio = if (isLargePauseWidget) 0.95f else 1f
coverPath?.let {
if (File(it).exists()) {
Image(
provider = ImageProvider(BitmapFactory.decodeFile(it)),
contentDescription = "cover",
modifier = GlanceModifier
.size(size.height * radio)
.padding(
start = startPadding,
top = -offsetY + coverAndCdDiffSize,
bottom = offsetY + coverAndCdDiffSize,
end = -offsetX + coverAndCdDiffSize,
),
)
}
}
}
@SuppressLint("RestrictedApi")
@Composable
open fun RenderPlayController(isLargePauseWidget: Boolean) {
val resId = if (isPlaying) R.drawable.vector_pause else R.drawable.vector_play
if (isLargePauseWidget) {
Image(
provider = ImageProvider(resId),
contentDescription = "playButton",
colorFilter = ColorFilter.tint(colorProvider = ColorProvider(Color.White)),
modifier = GlanceModifier.size(20.dp),
)
} else {
val padding =
if (size.width == AppUtils.HORIZONTAL_RECTANGLE.width) 26.dp else 23.dp
Box(
contentAlignment = Alignment.TopEnd,
modifier = GlanceModifier
.fillMaxSize()
.padding(top = padding, end = padding),
) {
Image(
provider = ImageProvider(resId),
contentDescription = "playButton",
colorFilter = ColorFilter.tint(colorProvider = ColorProvider(Color.White)),
modifier = GlanceModifier.size(15.dp),
)
}
}
}
@Composable
open fun RenderFavorite(imageSize: Dp, paddingInDp: Dp) {
val context = LocalContext.current
Box(
contentAlignment = Alignment.BottomEnd,
modifier = GlanceModifier
.width(size.width)
.height(size.height)
.padding(paddingInDp),
) {
val loveIcon =
if (isFavorite) R.drawable.widget_fav_click else R.drawable.widget_fav_unclick
if (isShutdown) {
Image(
provider = ImageProvider(loveIcon),
contentDescription = "love",
modifier = GlanceModifier.size(imageSize),
)
} else {
Image(
provider = ImageProvider(loveIcon),
contentDescription = "love",
modifier = GlanceModifier
.size(imageSize)
.clickable(
rippleOverride = R.drawable.click_ripple,
onClick = widgetAction(context, "homeWidgetExample://toggle_love"),
),
)
}
}
}
@Composable
fun GradientRectangle(color: Color) {
val bitmap = ImageUtil.createGradientImage(size.width.value, size.height.value, color)
Box(
modifier = GlanceModifier
.width(size.width)
.height(size.height)
.cornerRadius(radius),
) {
Image(
provider = ImageProvider(bitmap),
contentDescription = "bg",
modifier = GlanceModifier
.width(size.width)
.height(size.height),
)
}
}
override suspend fun provideGlance(context: Context, id: GlanceId) {
val preferences = context.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
provideContent {
GlanceContent(preferences = preferences)
}
}
}
package com.zhushenwudi.lovelivemusicplayer.native.home_widget
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
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.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import com.zhushenwudi.lovelivemusicplayer.R
import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
import com.zhushenwudi.lovelivemusicplayer.native.util.ImageUtil
import java.io.File
abstract class HomeWidgetContent : GlanceAppWidget() {
open var size: DpSize = AppUtils.SMALL_SQUARE
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 {
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
}
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()
}
@Composable
fun RenderAppWidget() {
Box(
modifier = GlanceModifier
.width(size.width)
.height(size.height)
) {
if (isWhite) {
GradientRectangle(color = musicColor)
} else {
Box(
modifier = GlanceModifier
.background(Color(red = 49, green = 49, blue = 49))
.width(size.width)
.height(size.height)
.cornerRadius(radius)
) {}
}
Box(
contentAlignment = Alignment.CenterEnd,
modifier = GlanceModifier
.width(size.width)
.height(size.height)
.cornerRadius(radius)
) {
Box(
contentAlignment = Alignment.Center,
modifier = GlanceModifier
.size(size.height)
.cornerRadius(radius)
) {
RenderCD(isLargePauseWidget = false)
RenderCover(isLargePauseWidget = false)
RenderPlayController(false)
}
}
Column(
modifier = GlanceModifier
.width(size.width)
.height(size.height)
.padding(radius)
) {
Box(
modifier = GlanceModifier.defaultWeight()
) {
RenderLogo(size = 22.dp, paddingInDp = (-6).dp)
}
RenderTextArr(stateFontSize = 9.sp, infoFontSize = 11.sp)
}
RenderFavorite(imageSize = 20.dp, paddingInDp = radius)
}
}
open fun calcCdOffsetX(): Dp = cdSize / 2 - 10.dp
open fun calcCdOffsetY(): Dp = cdSize / 2 - 10.dp
@SuppressLint("RestrictedApi")
@Composable
open fun RenderTextArr(
stateFontSize: TextUnit,
infoFontSize: TextUnit
) {
val textWidth = if (size.width == AppUtils.HORIZONTAL_RECTANGLE.width) {
size.width - 135.dp
} else {
size.width - 75.dp
}
val playTextArr = playText.split(",")
var playStateText = playTextArr[1]
if (!isShutdown && isPlaying) {
playStateText = playTextArr[0]
}
Text(
text = playStateText,
style = TextStyle(
fontSize = stateFontSize,
fontWeight = FontWeight.Bold,
color = ColorProvider(Color.Gray)
),
maxLines = 1,
modifier = GlanceModifier.width(textWidth),
)
Text(
text = songName,
style = TextStyle(
fontSize = infoFontSize,
fontWeight = FontWeight.Bold,
color = ColorProvider(if (isWhite) Color.Black else Color.White)
),
maxLines = 1,
modifier = GlanceModifier.width(textWidth),
)
Text(
text = songArtist,
style = TextStyle(
fontSize = infoFontSize,
fontWeight = FontWeight.Bold,
color = ColorProvider(Color.Gray)
),
maxLines = 1,
modifier = GlanceModifier.width(textWidth),
)
}
@Composable
open fun RenderLogo(size: Dp, paddingInDp: Dp) {
Image(
provider = ImageProvider(R.drawable.logo),
contentDescription = "logo",
modifier = GlanceModifier
.size(size)
.padding(start = paddingInDp)
)
}
@SuppressLint("RestrictedApi")
@Composable
private fun RenderTransportButton(
drawable: Int,
description: String,
uri: String,
imageSize: Dp,
) {
val base = GlanceModifier.size(imageSize)
val modifier = if (isShutdown) {
base
} else {
base.clickable(
rippleOverride = R.drawable.click_ripple,
onClick = widgetAction(LocalContext.current, uri),
)
}
Box(
contentAlignment = Alignment.Center,
modifier = modifier,
) {
Image(
provider = ImageProvider(drawable),
contentDescription = description,
colorFilter = ColorFilter.tint(ColorProvider(Color.White)),
modifier = GlanceModifier.size(imageSize * TRANSPORT_ICON_SCALE),
)
}
}
@Composable
open fun RenderCD(isLargePauseWidget: Boolean) {
val cdDrawable = if (isWhite) R.drawable.cd_white else R.drawable.cd_black
val offsetX = calcCdOffsetX()
val offsetY = calcCdOffsetY()
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"))
)
}
}
@Composable
open fun RenderCover(isLargePauseWidget: Boolean) {
val coverSize = cdSize * 0.4f
val coverAndCdDiffSize = (cdSize - coverSize) / 2
val offsetX = calcCdOffsetX()
val offsetY = calcCdOffsetY()
val startPadding =
if (isLargePauseWidget) coverAndCdDiffSize else offsetX + coverAndCdDiffSize
val radio = if (isLargePauseWidget) 0.95f else 1f
coverPath?.let {
if (File(it).exists()) {
Image(
provider = ImageProvider(BitmapFactory.decodeFile(it)),
contentDescription = "cover",
modifier = GlanceModifier
.size(size.height * radio)
.padding(
start = startPadding,
top = -offsetY + coverAndCdDiffSize,
bottom = offsetY + coverAndCdDiffSize,
end = -offsetX + coverAndCdDiffSize
)
)
}
}
}
@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",
)
if (isLargePauseWidget) {
val modifier = if (isShutdown) {
GlanceModifier.size(20.dp)
} else {
GlanceModifier
.size(20.dp)
.clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction)
}
Image(
provider = ImageProvider(resId),
contentDescription = "playButton",
colorFilter = ColorFilter.tint(colorProvider = ColorProvider(Color.White)),
modifier = modifier,
)
} 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)
}
Box(
contentAlignment = Alignment.TopEnd,
modifier = GlanceModifier
.fillMaxSize()
.padding(top = padding, end = padding)
) {
Image(
provider = ImageProvider(resId),
contentDescription = "playButton",
colorFilter = ColorFilter.tint(colorProvider = ColorProvider(Color.White)),
modifier = playModifier,
)
}
}
}
@Composable
open fun RenderFavorite(imageSize: Dp, paddingInDp: Dp) {
val isLarge = size.width == AppUtils.HORIZONTAL_RECTANGLE.width
Box(
contentAlignment = Alignment.BottomEnd,
modifier = GlanceModifier
.width(size.width)
.height(size.height)
.padding(paddingInDp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (isLarge) {
RenderTransportButton(
drawable = R.drawable.player_play_prev,
description = "previous",
uri = "${AppUtils.HOME_WIDGET_URI_PREFIX}previous",
imageSize = imageSize,
)
Spacer(GlanceModifier.width(8.dp))
}
RenderTransportButton(
drawable = R.drawable.player_play_next,
description = "next",
uri = "${AppUtils.HOME_WIDGET_URI_PREFIX}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",
),
)
}
Image(
provider = ImageProvider(loveIcon),
contentDescription = "love",
modifier = favoriteModifier,
)
}
}
}
@Composable
fun GradientRectangle(color: Color) {
val bitmap = ImageUtil.createGradientImage(size.width.value, size.height.value, color)
Box(
modifier = GlanceModifier
.width(size.width)
.height(size.height)
.cornerRadius(radius)
) {
Image(
provider = ImageProvider(bitmap),
contentDescription = "bg",
modifier = GlanceModifier
.width(size.width)
.height(size.height)
)
}
}
override suspend fun provideGlance(context: Context, id: GlanceId) {
val preferences = context.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
provideContent { GlanceContent(preferences = preferences) }
}
private companion object {
const val TRANSPORT_ICON_SCALE = 0.58f
}
}
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.native.home_widget.large_home_widget
package com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget
import androidx.glance.appwidget.SizeMode
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.native.home_widget.large_home_widget
package com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
@@ -1,60 +1,59 @@
package top.zhushenwudi.llmp.native.home_widget.large_home_widget
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import top.zhushenwudi.llmp.native.home_widget.HomeWidgetContent
import top.zhushenwudi.llmp.native.util.AppUtils
@SuppressLint("CommitPrefEdits", "DefaultLocale")
abstract class LargeHomeWidgetContent(
override var size: DpSize = AppUtils.HORIZONTAL_RECTANGLE,
override var radius: Dp = 12.dp,
override var cdSize: Dp = 120.dp,
) : HomeWidgetContent() {
private fun isLargePauseWidget(): Boolean = !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())
}
@Composable
override fun RenderLyric(isLargePauseWidget: Boolean) {
super.RenderLyric(isLargePauseWidget = isLargePauseWidget())
}
@Composable
override fun RenderCover(isLargePauseWidget: Boolean) {
super.RenderCover(isLargePauseWidget = isLargePauseWidget())
}
@Composable
override fun RenderPlayController(isLargePauseWidget: Boolean) {
super.RenderPlayController(isLargePauseWidget = isLargePauseWidget())
}
@Composable
override fun RenderTextArr(stateFontSize: TextUnit, infoFontSize: TextUnit) {
super.RenderTextArr(stateFontSize = 12.sp, infoFontSize = 13.sp)
}
@Composable
override fun RenderFavorite(imageSize: Dp, paddingInDp: Dp) {
super.RenderFavorite(imageSize = 28.dp, paddingInDp = radius / 2)
}
override fun calcCdOffsetX(): Dp = if (isPlaying) (cdSize / 2 - 10.dp) else 0.dp
override fun calcCdOffsetY(): Dp = if (isPlaying) (cdSize / 2 - 10.dp) else 0.dp
}
package com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
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.util.AppUtils
@SuppressLint("CommitPrefEdits", "DefaultLocale")
abstract class LargeHomeWidgetContent(
override var size: DpSize = AppUtils.HORIZONTAL_RECTANGLE,
override var radius: Dp = 12.dp,
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())
}
@Composable
override fun RenderCover(isLargePauseWidget: Boolean) {
super.RenderCover(isLargePauseWidget = isLargePauseWidget())
}
@Composable
override fun RenderPlayController(isLargePauseWidget: Boolean) {
super.RenderPlayController(isLargePauseWidget = isLargePauseWidget())
}
@Composable
override fun RenderTextArr(stateFontSize: TextUnit, infoFontSize: TextUnit) {
super.RenderTextArr(stateFontSize = 12.sp, infoFontSize = 13.sp)
}
@Composable
override fun RenderFavorite(imageSize: Dp, paddingInDp: Dp) {
super.RenderFavorite(imageSize = 28.dp, paddingInDp = radius / 2)
}
override fun calcCdOffsetX(): Dp =
if (isPlaying) cdSize / 2 + 5.dp else 12.dp
override fun calcCdOffsetY(): Dp =
if (isPlaying) cdSize / 2 + 5.dp else 12.dp
}
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.native.home_widget.large_home_widget
package com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget
import androidx.glance.appwidget.SizeMode
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.native.home_widget.large_home_widget
package com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.native.home_widget.small_home_widget
package com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget
import androidx.glance.appwidget.SizeMode
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.native.home_widget.small_home_widget
package com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
@@ -0,0 +1,9 @@
package com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetContent
abstract class SmallHomeWidgetContent(
override var radius: Dp = 6.dp
) : HomeWidgetContent()
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.native.home_widget.small_home_widget
package com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget
import androidx.glance.appwidget.SizeMode
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.native.home_widget.small_home_widget
package com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
@@ -1,9 +1,9 @@
package top.zhushenwudi.llmp.native.platform
package com.zhushenwudi.lovelivemusicplayer.native.platform
import top.zhushenwudi.llmp.native.NativeEvents
import top.zhushenwudi.llmp.platform.DeepLinkHandler
import top.zhushenwudi.llmp.platform.DeepLinkParser
import top.zhushenwudi.llmp.platform.DeepLinkPayload
import com.zhushenwudi.lovelivemusicplayer.native.NativeEvents
import com.zhushenwudi.lovelivemusicplayer.platform.DeepLinkHandler
import com.zhushenwudi.lovelivemusicplayer.platform.DeepLinkParser
import com.zhushenwudi.lovelivemusicplayer.platform.DeepLinkPayload
class AndroidDeepLinkHandler(
private val onDeepLink: ((DeepLinkPayload) -> Unit)? = null,
@@ -0,0 +1,85 @@
package com.zhushenwudi.lovelivemusicplayer.native.platform
import android.content.Intent
import android.os.Build
import android.provider.Settings
import android.widget.Toast
import androidx.core.net.toUri
import com.zhushenwudi.lovelivemusicplayer.i18n.I18n
import com.zhushenwudi.lovelivemusicplayer.platform.AndroidUiHost
import com.zhushenwudi.lovelivemusicplayer.platform.DesktopLyricController
import com.zhushenwudi.lovelivemusicplayer.player.PlaybackService
/**
* 桌面歌词控制器 — 对齐 Flutter [DesktopLyricPlugin] + [MainActivity]。
* 叠加层由 [PlaybackService] 承载(共享 Media3 FGS)。
*/
class AndroidDesktopLyricController(
context: android.content.Context,
) : DesktopLyricController {
private val appContext = context.applicationContext
private var playing: Boolean = false
override var enabled: Boolean = false
override fun pipAutoOpen(enable: Boolean): Boolean {
if (enable) {
val ctx = AndroidUiHost.activity ?: appContext
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(ctx)) {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
"package:${appContext.packageName}".toUri(),
)
val act = AndroidUiHost.activity
if (act != null) {
@Suppress("DEPRECATION")
act.startActivityForResult(intent, REQUEST_CODE_OVERLAY_PERMISSION)
} else {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
appContext.startActivity(intent)
}
return false
}
}
enabled = enable
if (!enable) {
PlaybackService.hideDesktopLyric(appContext)
}
return true
}
override fun show() {
if (!enabled) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(appContext)) {
return
}
PlaybackService.showDesktopLyric(appContext)
}
override fun hide() {
PlaybackService.hideDesktopLyric(appContext)
}
override fun updateLines(line1: String?, line2: String?, currentLine: Int) {
PlaybackService.updateDesktopLyric(line1, line2, currentLine)
}
override fun setPlaying(playing: Boolean) {
this.playing = playing
}
override fun isOverlayShowing(): Boolean = PlaybackService.isDesktopLyricShowing()
fun isPlaying(): Boolean = playing
companion object {
const val REQUEST_CODE_OVERLAY_PERMISSION = 0x10
fun onOverlayPermissionResult(granted: Boolean) {
if (!granted) {
val act = AndroidUiHost.activity ?: return
Toast.makeText(act, I18n.t("no_permission"), Toast.LENGTH_LONG).show()
}
}
}
}
@@ -0,0 +1,129 @@
package com.zhushenwudi.lovelivemusicplayer.native.platform
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
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.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
import com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget.WhiteSmallHomeWidgetReceiver
import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils
import com.zhushenwudi.lovelivemusicplayer.native.util.ImageUtil
import com.zhushenwudi.lovelivemusicplayer.platform.HomeWidgetController
import java.io.File
class AndroidHomeWidgetController(
context: Context,
) : HomeWidgetController {
private val appContext = context.applicationContext
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var preImagePath: String? = null
private var lastContent: WidgetContent? = null
private val receiverClasses = listOf(
WhiteSmallHomeWidgetReceiver::class.java,
BlackSmallHomeWidgetReceiver::class.java,
WhiteLargeHomeWidgetReceiver::class.java,
BlackLargeHomeWidgetReceiver::class.java,
)
override fun update(
songName: String?,
artist: String?,
isPlaying: Boolean,
favorite: Boolean,
coverPath: String?,
playText: String,
) {
val content = WidgetContent(
songName = songName.orEmpty(),
artist = artist.orEmpty(),
isPlaying = isPlaying,
favorite = favorite,
playText = playText,
)
val contentChanged = content != lastContent
val coverChanged = !coverPath.isNullOrBlank() && coverPath != preImagePath
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) coverPath?.let(::processCover)
}
override fun setShutdown(shutdown: Boolean) {
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
.edit(commit = true) {
putBoolean("isShutdown", shutdown)
if (shutdown) {
putBoolean("isPlaying", false)
}
}
refresh()
}
override fun refresh() {
receiverClasses.forEach { receiverClass ->
sendWidgetUpdate(receiverClass)
}
}
private fun processCover(coverPath: String) {
if (preImagePath == coverPath || !File(coverPath).exists()) return
preImagePath = coverPath
scope.launch {
try {
val originBitmap = BitmapFactory.decodeFile(coverPath)
val coverBitmap = ImageUtil.squareAndCircularBitmap(originBitmap, 180)
val handledCoverPath = ImageUtil.saveBitmapToFile(appContext, coverBitmap)
ImageUtil.savePathToSp(appContext, handledCoverPath)
val color = ImageUtil.parsePhotoDomainColor(coverBitmap)
ImageUtil.saveColorToSp(appContext, color)
originBitmap.recycle()
coverBitmap.recycle()
} catch (_: Exception) {
} finally {
refresh()
}
}
}
private fun sendWidgetUpdate(receiverClass: Class<*>) {
val ids = AppWidgetManager.getInstance(appContext)
.getAppWidgetIds(ComponentName(appContext, receiverClass))
if (ids.isEmpty()) return
val intent = Intent(appContext, receiverClass).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
}
appContext.sendBroadcast(intent)
}
private data class WidgetContent(
val songName: String,
val artist: String,
val isPlaying: Boolean,
val favorite: Boolean,
val playText: String,
)
}
@@ -0,0 +1,145 @@
package com.zhushenwudi.lovelivemusicplayer.native.platform
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import com.tencent.connect.share.QQShare
import com.tencent.tauth.IUiListener
import com.tencent.tauth.Tencent
import com.tencent.tauth.UiError
import com.zhushenwudi.lovelivemusicplayer.Const
import com.zhushenwudi.lovelivemusicplayer.i18n.I18n
import com.zhushenwudi.lovelivemusicplayer.platform.AndroidUiHost
import com.zhushenwudi.lovelivemusicplayer.platform.ShareBridge
class AndroidShareBridge(
context: Context,
) : ShareBridge {
private val appContext = context.applicationContext
override fun shareText(text: String, title: String?) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, text)
title?.let { putExtra(Intent.EXTRA_TITLE, it) }
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
appContext.startActivity(
Intent.createChooser(intent, title ?: I18n.t("share_chooser")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
override fun shareUrl(url: String, title: String?) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
title?.let { putExtra(Intent.EXTRA_TITLE, it) }
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
appContext.startActivity(
Intent.createChooser(intent, title ?: I18n.t("share_chooser")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
override fun shareToQq(
title: String,
text: String,
url: String,
imageUrl: String?,
imagePath: String?,
) {
val activity = AndroidUiHost.activity
if (activity == null) {
toast(I18n.t("share_error_n", I18n.t("no_activity")))
return
}
val sdk = tencent(appContext)
if (!sdk.isQQInstalled(appContext)) {
toast(I18n.t("no_qq"))
return
}
val params = Bundle().apply {
putInt(QQShare.SHARE_TO_QQ_KEY_TYPE, QQShare.SHARE_TO_QQ_TYPE_DEFAULT)
putString(QQShare.SHARE_TO_QQ_TITLE, title)
putString(QQShare.SHARE_TO_QQ_SUMMARY, text)
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)
}
runCatching {
sdk.shareToQQ(activity, params, listener)
}.onFailure { e ->
toast(I18n.t("share_error_n", e.message ?: e.javaClass.simpleName))
}
}
companion object {
val listener: IUiListener = QqShareUiListener()
@Volatile
private var instance: Tencent? = null
fun tencent(context: Context): Tencent {
Tencent.setIsPermissionGranted(true, Build.MODEL)
instance?.let { return it }
val created = Tencent.createInstance(
Const.QQ_APP_ID,
context.applicationContext,
"${context.packageName}.fileprovider",
)
instance = created
return created
}
fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
Tencent.onActivityResultData(requestCode, resultCode, data, listener)
}
fun handleIntentResult(intent: Intent?) {
if (intent == null) return
Tencent.handleResultData(intent, listener)
}
fun toast(message: String) {
val ctx = instanceContext
if (ctx == null) return
Handler(Looper.getMainLooper()).post {
Toast.makeText(ctx, "\u202A$message\u202C", Toast.LENGTH_SHORT).show()
}
}
@Volatile
private var instanceContext: Context? = null
}
init {
instanceContext = appContext
}
}
private class QqShareUiListener : IUiListener {
override fun onComplete(response: Any?) {
AndroidShareBridge.toast(I18n.t("share_success"))
}
override fun onError(e: UiError?) {
val code = e?.errorCode ?: -1
val detail = listOfNotNull(e?.errorMessage, e?.errorDetail)
.map { it.trim() }
.filter { it.isNotEmpty() }
.joinToString(" ")
val suffix = if (detail.isEmpty()) "$code" else "$code $detail"
AndroidShareBridge.toast(I18n.t("share_error_n", suffix))
}
override fun onCancel() {
AndroidShareBridge.toast(I18n.t("share_cancel"))
}
override fun onWarning(code: Int) = Unit
}
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.native.platform
package com.zhushenwudi.lovelivemusicplayer.native.platform
import android.content.BroadcastReceiver
import android.content.Context
@@ -8,8 +8,8 @@ import android.os.Build
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import top.zhushenwudi.llmp.platform.FileSystem
import top.zhushenwudi.llmp.platform.UsbMountMonitor
import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem
import com.zhushenwudi.lovelivemusicplayer.platform.UsbMountMonitor
enum class UsbMountEvent {
MOUNTED,
@@ -22,12 +22,14 @@ class AndroidUsbMountMonitor(
) : UsbMountMonitor {
private val appContext = context.applicationContext
private var receiver: BroadcastReceiver? = null
private var onChanged: (() -> Unit)? = null
private val _events = MutableSharedFlow<UsbMountEvent>(extraBufferCapacity = 8)
val events: SharedFlow<UsbMountEvent> = _events.asSharedFlow()
var onMount: (() -> Unit)? = null
var onEject: (() -> Unit)? = null
override fun setOnChanged(listener: (() -> Unit)?) {
onChanged = listener
}
override fun start() {
if (receiver != null) return
@@ -39,19 +41,16 @@ class AndroidUsbMountMonitor(
receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
Intent.ACTION_MEDIA_MOUNTED -> {
_events.tryEmit(UsbMountEvent.MOUNTED)
onMount?.invoke()
}
Intent.ACTION_MEDIA_EJECT -> {
_events.tryEmit(UsbMountEvent.EJECTED)
onEject?.invoke()
}
Intent.ACTION_MEDIA_MOUNTED -> _events.tryEmit(UsbMountEvent.MOUNTED)
Intent.ACTION_MEDIA_EJECT -> _events.tryEmit(UsbMountEvent.EJECTED)
else -> return
}
onChanged?.invoke()
}
}
// 系统 MEDIA_* 广播来自其他进程,必须 EXPORTED,否则 Android 13+ 收不到。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
appContext.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
appContext.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
appContext.registerReceiver(receiver, filter)
@@ -1,10 +1,11 @@
package top.zhushenwudi.llmp.native.platform
package com.zhushenwudi.lovelivemusicplayer.native.platform
import android.content.Context
import top.zhushenwudi.llmp.platform.AndroidMediaSessionBridge
import top.zhushenwudi.llmp.platform.PlatformServices
import top.zhushenwudi.llmp.platform.createFileSystem
import top.zhushenwudi.llmp.native.UmengBridge
import com.zhushenwudi.lovelivemusicplayer.AppContainer
import com.zhushenwudi.lovelivemusicplayer.native.UmengBridge
import com.zhushenwudi.lovelivemusicplayer.platform.AndroidMediaSessionBridge
import com.zhushenwudi.lovelivemusicplayer.platform.PlatformServices
import com.zhushenwudi.lovelivemusicplayer.platform.createFileSystem
fun installAndroidPlatformServices(context: Context) {
val appContext = context.applicationContext
@@ -23,4 +24,5 @@ fun installAndroidPlatformServices(context: Context) {
)
(PlatformServices.usbMount as? AndroidUsbMountMonitor)?.start()
AppContainer.bindUsbMountListener()
}
@@ -1,14 +1,16 @@
package top.zhushenwudi.llmp.native.util
package com.zhushenwudi.lovelivemusicplayer.native.util
import android.app.ActivityManager
import android.content.Context
import android.content.Intent
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import top.zhushenwudi.llmp.native.UmengBridge
import com.zhushenwudi.lovelivemusicplayer.native.UmengBridge
object AppUtils {
private const val PREFIX_URL = "llmp://"
const val HOME_WIDGET_SCHEME = "homeWidgetExample"
const val HOME_WIDGET_URI_PREFIX = "$HOME_WIDGET_SCHEME://"
const val SAVE_FILE_NAME = "sharedImage.png"
const val SP_NAME = "HomeWidgetPreferences"
@@ -40,6 +42,7 @@ object AppUtils {
return false
}
@Suppress("DEPRECATION")
private fun getRunningService(mContext: Context): ArrayList<ActivityManager.RunningServiceInfo> {
val myManager =
mContext.applicationContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.native.util
package com.zhushenwudi.lovelivemusicplayer.native.util
import android.annotation.SuppressLint
import android.content.Context
@@ -62,7 +62,7 @@ object ImageUtil {
val circularBitmap = createBitmap(bitmap.width, bitmap.height)
val paint = Paint()
paint.isAntiAlias = true
paint.setShader(BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP))
paint.shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
val canvas = Canvas(circularBitmap)
canvas.drawCircle(bitmap.width / 2f, bitmap.height / 2f, bitmap.width / 2f, paint)
return circularBitmap
@@ -121,7 +121,7 @@ object ImageUtil {
null,
Shader.TileMode.CLAMP,
)
paint.setShader(gradient)
paint.shader = gradient
canvas.drawRect(rect, paint)
return bitmap
}
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.player
package com.zhushenwudi.lovelivemusicplayer.player
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -20,15 +20,15 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import top.zhushenwudi.llmp.MainActivity
import top.zhushenwudi.llmp.R
import top.zhushenwudi.llmp.native.DesktopLyricOverlay
import com.zhushenwudi.lovelivemusicplayer.MainActivity
import com.zhushenwudi.lovelivemusicplayer.i18n.I18n
import com.zhushenwudi.lovelivemusicplayer.R
import com.zhushenwudi.lovelivemusicplayer.native.DesktopLyricOverlay
/**
* Media3 [MediaSessionService] hosting the shared ExoPlayer + optional desktop-lyric overlay.
* Media3 [androidx.media3.session.MediaSessionService],承载共享 ExoPlayer + 可选桌面歌词叠加层。
*
* One FGS notification (Media3 media style) covers playback; floating lyrics no longer post a
* second "歌词服务" notification.
* 一个 FGS 通知(Media3 媒体样式)覆盖播放;悬浮歌词不再发第二条「歌词服务」通知。
*/
class PlaybackService : MediaSessionService() {
private var mediaSession: MediaSession? = null
@@ -58,7 +58,7 @@ class PlaybackService : MediaSessionService() {
mediaSession = session
addSession(session)
// Satisfy startForegroundService() until Media3 posts the real media notification.
// 在 Media3 发布真实媒体通知前满足 startForegroundService()。
val notification = bootstrapNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
@@ -138,7 +138,7 @@ class PlaybackService : MediaSessionService() {
private fun bootstrapNotification() =
NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText("准备播放")
.setContentText(I18n.t("ready_to_play"))
.setSmallIcon(R.drawable.logo)
.setContentIntent(sessionActivityIntent())
.setOngoing(true)
@@ -160,8 +160,8 @@ class PlaybackService : MediaSessionService() {
companion object {
const val CHANNEL_ID = "llmp_playback"
const val NOTIF_ID = 1001
const val ACTION_SHOW_DESKTOP_LYRIC = "top.zhushenwudi.llmp.SHOW_DESKTOP_LYRIC"
const val ACTION_HIDE_DESKTOP_LYRIC = "top.zhushenwudi.llmp.HIDE_DESKTOP_LYRIC"
const val ACTION_SHOW_DESKTOP_LYRIC = "com.zhushenwudi.lovelivemusicplayer.SHOW_DESKTOP_LYRIC"
const val ACTION_HIDE_DESKTOP_LYRIC = "com.zhushenwudi.lovelivemusicplayer.HIDE_DESKTOP_LYRIC"
@Volatile
private var instance: PlaybackService? = null
@@ -171,7 +171,7 @@ class PlaybackService : MediaSessionService() {
ContextCompat.startForegroundService(context, intent)
}
/** Show floating lyrics; reuses this service's Media3 FGS notification. */
/** 显示悬浮歌词;复用本服务的 Media3 FGS 通知。 */
fun showDesktopLyric(context: Context) {
val existing = instance
if (existing != null) {
@@ -184,14 +184,14 @@ class PlaybackService : MediaSessionService() {
ContextCompat.startForegroundService(context, intent)
}
/** Hide floating lyrics without stopping media playback / FGS. */
/** 隐藏悬浮歌词,不停止媒体播放/FGS。 */
fun hideDesktopLyric(context: Context) {
val existing = instance
if (existing != null) {
existing.hideDesktopLyricInternal()
return
}
// Service not running — nothing to hide.
// 服务未运行,无需隐藏。
}
fun updateDesktopLyric(line1: String?, line2: String?, currentLine: Int) {
@@ -1,205 +0,0 @@
package top.zhushenwudi.llmp
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.lifecycle.lifecycleScope
import java.io.File
import kotlinx.coroutines.launch
import top.zhushenwudi.llmp.db.DriverFactory
import top.zhushenwudi.llmp.db.createDatabase
import top.zhushenwudi.llmp.native.NativeEvents
import top.zhushenwudi.llmp.native.platform.installAndroidPlatformServices
import top.zhushenwudi.llmp.native.util.AppUtils
import top.zhushenwudi.llmp.platform.AndroidUiHost
import top.zhushenwudi.llmp.platform.PlatformServices
import top.zhushenwudi.llmp.platform.initAndroidFileSystem
import top.zhushenwudi.llmp.player.PlaybackService
import top.zhushenwudi.llmp.player.PlayerController
import top.zhushenwudi.llmp.settings.createAppSettings
class MainActivity : ComponentActivity() {
private var player: PlayerController? = null
private val pickImageLauncher = registerForActivityResult(
ActivityResultContracts.GetContent(),
) { uri ->
if (uri == null) {
AndroidUiHost.imagePickHandler?.invoke(null)
return@registerForActivityResult
}
val path = runCatching {
val dir = File(filesDir, "bg")
if (!dir.exists()) dir.mkdirs()
val out = File(dir, "${System.currentTimeMillis()}.jpg")
contentResolver.openInputStream(uri)?.use { input ->
out.outputStream().use { output -> input.copyTo(output) }
}
out.absolutePath
}.getOrNull()
AndroidUiHost.imagePickHandler?.invoke(path)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
AndroidUiHost.activity = this
AndroidUiHost.launchImagePicker = { pickImageLauncher.launch("image/*") }
initAndroidFileSystem(applicationContext)
if (!AppContainer.isInitialized) {
val settings = createAppSettings(applicationContext)
val database = createDatabase(DriverFactory(applicationContext))
player = PlayerController(applicationContext)
AppContainer.init(
settings = settings,
database = database,
player = player!!,
)
AppContainer.musicTransfer.setPlatform("android")
} else {
player = AppContainer.player
}
installAndroidPlatformServices(applicationContext)
PlatformServices.desktopLyric.enabled = AppContainer.settings.enableDesktopLyric
PlatformServices.analytics.initIfAgreed(
getSharedPreferences("my_prefs", MODE_PRIVATE).getBoolean("key_agreed", false),
)
getSharedPreferences(AppUtils.SP_NAME, MODE_PRIVATE).edit(commit = true) {
putBoolean("isShutdown", false)
}
handleIncomingIntent(intent)
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
moveTaskToBack(true)
}
},
)
requestNotifyPermission()
startPlaybackService()
lifecycleScope.launch {
NativeEvents.widgetEvents.collect { uri ->
when {
uri.endsWith("toggle_play") -> player?.toggle()
uri.endsWith("toggle_love") -> {
val id = player?.state?.value?.current?.musicId ?: return@collect
val favorite = AppContainer.toggleLove(id)
val current = player?.state?.value?.current
PlatformServices.homeWidget.update(
songName = current?.musicName,
artist = current?.artist,
isPlaying = player?.state?.value?.isPlaying == true,
favorite = favorite,
coverPath = current?.let {
AppContainer.pathResolver.absoluteCoverPath(it)
},
)
}
}
}
}
setContent { App() }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIncomingIntent(intent)
}
override fun onResume() {
super.onResume()
AndroidUiHost.activity = this
AppUtils.getSchemeData(intent) { url ->
PlatformServices.deepLink.handle(url)
}
// Foreground: hide floating lyric (PlaybackService keeps media FGS).
if (PlatformServices.desktopLyric.enabled) {
PlatformServices.desktopLyric.hide()
}
}
override fun onPause() {
super.onPause()
val lyric = PlatformServices.desktopLyric
val playing = player?.state?.value?.isPlaying == true
if (lyric.enabled && playing) {
if (!Settings.canDrawOverlays(this)) {
// Permission missing — skip start; Settings toggle requests it.
} else {
lyric.show()
}
}
}
override fun onDestroy() {
if (AndroidUiHost.activity === this) {
AndroidUiHost.activity = null
AndroidUiHost.launchImagePicker = null
}
if (isFinishing) {
getSharedPreferences(AppUtils.SP_NAME, MODE_PRIVATE).edit(commit = true) {
putString("curJpLrc", "")
putString("nextJpLrc", "")
putBoolean("isPlaying", false)
putBoolean("isShutdown", true)
}
PlatformServices.homeWidget.setShutdown(true)
PlatformServices.desktopLyric.hide()
}
super.onDestroy()
}
private fun handleIncomingIntent(intent: Intent?) {
val data = intent?.data?.toString() ?: return
when {
data.startsWith("homeWidgetExample://") -> {
NativeEvents.emitWidgetEvent(data)
intent.data = null
}
data.startsWith("llmp://") || data.startsWith("llmp:") -> {
PlatformServices.deepLink.handle(data)
intent.data = null
}
}
}
private fun requestNotifyPermission() {
if (Build.VERSION.SDK_INT >= 33) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
100,
)
}
}
}
private fun startPlaybackService() {
PlaybackService.start(this)
}
}
@@ -1,44 +0,0 @@
package top.zhushenwudi.llmp.native
import android.annotation.SuppressLint
import android.os.Bundle
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
class WebViewActivity : ComponentActivity() {
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val url = intent.getStringExtra(EXTRA_URL).orEmpty()
val title = intent.getStringExtra(EXTRA_TITLE)
title?.let { setTitle(it) }
setContent {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
webViewClient = WebViewClient()
if (url.startsWith("file:///android_asset/")) {
loadUrl(url)
} else if (url.isNotBlank()) {
loadUrl(url)
}
}
},
)
}
}
companion object {
const val EXTRA_URL = "url"
const val EXTRA_TITLE = "title"
}
}
@@ -1,9 +0,0 @@
package top.zhushenwudi.llmp.native.home_widget.small_home_widget
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import top.zhushenwudi.llmp.native.home_widget.HomeWidgetContent
abstract class SmallHomeWidgetContent(
override var radius: Dp = 6.dp,
) : HomeWidgetContent()
@@ -1,53 +0,0 @@
package top.zhushenwudi.llmp.native.platform
import android.content.Context
import android.content.Intent
import android.provider.Settings
import top.zhushenwudi.llmp.platform.DesktopLyricController
import top.zhushenwudi.llmp.player.PlaybackService
import androidx.core.net.toUri
/**
* Desktop lyric controller — overlay is hosted by [PlaybackService] (shared Media3 FGS).
*/
class AndroidDesktopLyricController(
private val context: Context,
) : DesktopLyricController {
private val appContext = context.applicationContext
override var enabled: Boolean = false
private var playing: Boolean = false
override fun show() {
enabled = true
PlaybackService.showDesktopLyric(appContext)
}
override fun hide() {
// Keep [enabled] as caller decides (settings off vs app resume).
PlaybackService.hideDesktopLyric(appContext)
}
override fun updateLines(line1: String?, line2: String?, currentLine: Int) {
PlaybackService.updateDesktopLyric(line1, line2, currentLine)
}
override fun setPlaying(playing: Boolean) {
this.playing = playing
}
fun isPlaying(): Boolean = playing
fun isOverlayShowing(): Boolean = PlaybackService.isDesktopLyricShowing()
fun requestOverlayPermission(context: Context): Intent? {
return if (!Settings.canDrawOverlays(context)) {
Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
"package:${context.packageName}".toUri(),
)
} else {
null
}
}
}
@@ -1,112 +0,0 @@
package top.zhushenwudi.llmp.native.platform
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import androidx.core.content.edit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import top.zhushenwudi.llmp.native.home_widget.large_home_widget.BlackLargeHomeWidgetReceiver
import top.zhushenwudi.llmp.native.home_widget.large_home_widget.WhiteLargeHomeWidgetReceiver
import top.zhushenwudi.llmp.native.home_widget.small_home_widget.BlackSmallHomeWidgetReceiver
import top.zhushenwudi.llmp.native.home_widget.small_home_widget.WhiteSmallHomeWidgetReceiver
import top.zhushenwudi.llmp.native.util.AppUtils
import top.zhushenwudi.llmp.native.util.ImageUtil
import top.zhushenwudi.llmp.platform.HomeWidgetController
import java.io.File
class AndroidHomeWidgetController(
context: Context,
) : HomeWidgetController {
private val appContext = context.applicationContext
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var preImagePath: String? = null
private val receiverClasses = listOf(
WhiteSmallHomeWidgetReceiver::class.java,
BlackSmallHomeWidgetReceiver::class.java,
WhiteLargeHomeWidgetReceiver::class.java,
BlackLargeHomeWidgetReceiver::class.java,
)
override fun update(
songName: String?,
artist: String?,
isPlaying: Boolean,
favorite: Boolean,
lyricLine1: String?,
lyricLine2: String?,
currentLine: Int,
coverPath: String?,
playText: String,
) {
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE).edit(commit = true) {
putString("songName", songName ?: "")
putString("songArtist", artist ?: "")
putBoolean("songFavorite", favorite)
putBoolean("isPlaying", isPlaying)
putString("playText", playText)
putString("lyricLine1", lyricLine1 ?: "")
putString("lyricLine2", lyricLine2 ?: "")
putInt("currentLine", currentLine)
}
coverPath?.let { processCover(it) } ?: refresh()
}
override fun setShutdown(shutdown: Boolean) {
appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE).edit(commit = true) {
putBoolean("isShutdown", shutdown)
if (shutdown) {
putBoolean("isPlaying", false)
}
}
refresh()
}
override fun refresh() {
receiverClasses.forEach { receiverClass ->
sendWidgetUpdate(receiverClass)
}
}
private fun processCover(coverPath: String) {
if (preImagePath == coverPath) {
refresh()
return
}
if (!File(coverPath).exists()) {
refresh()
return
}
preImagePath = coverPath
scope.launch {
try {
val originBitmap = BitmapFactory.decodeFile(coverPath)
val coverBitmap = ImageUtil.squareAndCircularBitmap(originBitmap, 180)
val handledCoverPath = ImageUtil.saveBitmapToFile(appContext, coverBitmap)
ImageUtil.savePathToSp(appContext, handledCoverPath)
val color = ImageUtil.parsePhotoDomainColor(coverBitmap)
ImageUtil.saveColorToSp(appContext, color)
originBitmap.recycle()
coverBitmap.recycle()
} catch (_: Exception) {
} finally {
refresh()
}
}
}
private fun sendWidgetUpdate(receiverClass: Class<*>) {
val intent = Intent(appContext, receiverClass).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
val ids = AppWidgetManager.getInstance(appContext)
.getAppWidgetIds(ComponentName(appContext, receiverClass))
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
}
appContext.sendBroadcast(intent)
}
}
@@ -1,35 +0,0 @@
package top.zhushenwudi.llmp.native.platform
import android.content.Context
import android.content.Intent
import top.zhushenwudi.llmp.platform.ShareBridge
class AndroidShareBridge(
private val context: Context,
) : ShareBridge {
private val appContext = context.applicationContext
override fun shareText(text: String, title: String?) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, text)
title?.let { putExtra(Intent.EXTRA_TITLE, it) }
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
appContext.startActivity(
Intent.createChooser(intent, title ?: "分享").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
override fun shareUrl(url: String, title: String?) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
title?.let { putExtra(Intent.EXTRA_TITLE, it) }
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
appContext.startActivity(
Intent.createChooser(intent, title ?: "分享").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
}
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FF6B9D"
android:pathData="M0,0h108v108h-108z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M34,28h12v52h-12zM54,28c14,0 26,10 26,26s-12,26 -26,26v-12c8,0 14,-6 14,-14s-6,-14 -14,-14z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="14dp"
android:viewportWidth="16"
android:viewportHeight="14">
<path
android:fillColor="#FFFFFF"
android:pathData="m13.7695312,0h0.7421876c0.6040624,0 1.09375,0.4896875 1.09375,1.09375v11.5820312c0,0.6040625 -0.4896876,1.09375 -1.09375,1.09375h-0.7421876c-0.6040624,0 -1.09375,-0.4896875 -1.09375,-1.09375v-11.5820312c0,-0.6040625 0.4896876,-1.09375 1.09375,-1.09375zM10.4748828,7.542793L2.2267578,13.468574c-0.6307422,0.453145 -1.5094141,0.30918 -1.9625781,-0.321563C0.0924,12.907906 0,12.620916 0,12.326504V1.4075C0,0.630859 0.62959,0.00125 1.40625,0.00125c0.277721,0 0.549225,0.082232 0.780273,0.236328l8.257071,5.507012c0.502539,0.335156 0.638223,1.014258 0.303066,1.516796c-0.072944,0.109373 -0.165009,0.2047 -0.271777,0.281407z" />
</vector>
@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="14dp"
android:viewportWidth="16"
android:viewportHeight="14">
<group
android:pivotX="8"
android:pivotY="7"
android:scaleX="-1">
<path
android:fillColor="#FFFFFF"
android:pathData="m13.7695312,0h0.7421876c0.6040624,0 1.09375,0.4896875 1.09375,1.09375v11.5820312c0,0.6040625 -0.4896876,1.09375 -1.09375,1.09375h-0.7421876c-0.6040624,0 -1.09375,-0.4896875 -1.09375,-1.09375v-11.5820312c0,-0.6040625 0.4896876,-1.09375 1.09375,-1.09375zM10.4748828,7.542793L2.2267578,13.468574c-0.6307422,0.453145 -1.5094141,0.30918 -1.9625781,-0.321563C0.0924,12.907906 0,12.620916 0,12.326504V1.4075C0,0.630859 0.62959,0.00125 1.40625,0.00125c0.277721,0 0.549225,0.082232 0.780273,0.236328l8.257071,5.507012c0.502539,0.335156 0.638223,1.014258 0.303066,1.516796c-0.072944,0.109373 -0.165009,0.2047 -0.271777,0.281407z" />
</group>
</vector>
@@ -1,10 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#EEEEEE">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>
@@ -55,7 +55,8 @@
android:layout_alignParentStart="true"
android:layout_marginTop="5dp"
android:layout_marginStart="25dp"
android:background="@drawable/ic_close" />
android:background="@drawable/ic_close"
android:contentDescription="@string/lyric_close" />
<ImageView
android:id="@+id/ic_pip"
@@ -64,7 +65,8 @@
android:layout_alignParentEnd="true"
android:layout_marginTop="5dp"
android:layout_marginEnd="25dp"
android:background="@drawable/ic_pip" />
android:background="@drawable/ic_pip"
android:contentDescription="@string/lyric_pip" />
<ImageView
android:id="@+id/ic_icon"
@@ -75,7 +77,8 @@
android:layout_alignBottom="@id/ll_lyric"
android:layout_marginBottom="5dp"
android:layout_marginEnd="25dp"
android:background="@drawable/logo" />
android:background="@drawable/logo"
android:contentDescription="@null" />
<ImageView
android:id="@+id/ic_translate"
@@ -85,5 +88,6 @@
android:layout_alignBottom="@id/ll_lyric"
android:layout_marginBottom="5dp"
android:layout_marginEnd="25dp"
android:background="@drawable/ic_translate" />
android:background="@drawable/ic_translate"
android:contentDescription="@string/lyric_translate" />
</RelativeLayout>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">LLMP KMP</string>
<string name="playback_channel_name">Wird abgespielt</string>
<string name="lyric_close">Desktop-Lyrics schließen</string>
<string name="lyric_pip">Bild-in-Bild</string>
<string name="lyric_translate">Übersetzte Lyrics</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">LLMP KMP</string>
<string name="playback_channel_name">Now Playing</string>
<string name="lyric_close">Close desktop lyrics</string>
<string name="lyric_pip">Picture in Picture</string>
<string name="lyric_translate">Translated lyrics</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">LLMP KMP</string>
<string name="playback_channel_name">再生中</string>
<string name="lyric_close">デスクトップ歌詞を閉じる</string>
<string name="lyric_pip">ピクチャインピクチャ</string>
<string name="lyric_translate">翻訳歌詞</string>
</resources>
@@ -2,4 +2,7 @@
<resources>
<string name="app_name">LLMP KMP</string>
<string name="playback_channel_name">正在播放</string>
<string name="lyric_close">关闭桌面歌词</string>
<string name="lyric_pip">画中画</string>
<string name="lyric_translate">翻译歌词</string>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="325dp"
android:minHeight="100dp"
android:targetCellWidth="5"
android:targetCellHeight="2"
android:previewImage="@mipmap/ic_widget_black_large"
android:updatePeriodMillis="10000"
android:resizeMode="none"
android:widgetCategory="home_screen" />
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="170dp"
android:minHeight="100dp"
android:targetCellWidth="3"
android:targetCellHeight="2"
android:previewImage="@mipmap/ic_widget_black_small"
android:updatePeriodMillis="10000"
android:resizeMode="none"
android:widgetCategory="home_screen" />
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="325dp"
android:minHeight="100dp"
android:targetCellWidth="5"
android:targetCellHeight="2"
android:previewImage="@mipmap/ic_widget_white_large"
android:updatePeriodMillis="10000"
android:resizeMode="none"
android:widgetCategory="home_screen" />
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="170dp"
android:minHeight="100dp"
android:targetCellWidth="3"
android:targetCellHeight="2"
android:previewImage="@mipmap/ic_widget_white_small"
android:updatePeriodMillis="10000"
android:resizeMode="none"
android:widgetCategory="home_screen" />
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="cache" path="." />
<files-path name="files" path="." />
<external-files-path name="ext" path="." />
</paths>