initial commit
68
androidApp/build.gradle.kts
Normal file
@@ -0,0 +1,68 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "top.zhushenwudi.llmp"
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "top.zhushenwudi.llmp"
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
versionName = "0.2.0-phaseb"
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.composeApp)
|
||||
implementation(projects.shared)
|
||||
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.session)
|
||||
implementation(libs.androidx.media)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.glance.appwidget)
|
||||
implementation(libs.androidx.palette)
|
||||
implementation(libs.zxing.android.embedded)
|
||||
implementation(libs.androidx.lifecycle.service)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
}
|
||||
132
androidApp/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,132 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="LLMP KMP"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:largeHeap="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<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="llmp" />
|
||||
</intent-filter>
|
||||
<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="homeWidgetExample" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".native.QrScanActivity"
|
||||
android:exported="false"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
|
||||
|
||||
<activity
|
||||
android:name=".native.WebViewActivity"
|
||||
android:exported="false"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
|
||||
|
||||
<service
|
||||
android:name=".player.PlaybackService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name="androidx.media3.session.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".native.home_widget.small_home_widget.WhiteSmallHomeWidgetReceiver"
|
||||
android:exported="true"
|
||||
android:label="LLMP White Small Widget"
|
||||
android:permission="android.permission.BIND_APPWIDGET">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/white_small_home_widget" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".native.home_widget.small_home_widget.BlackSmallHomeWidgetReceiver"
|
||||
android:exported="true"
|
||||
android:label="LLMP Black Small Widget"
|
||||
android:permission="android.permission.BIND_APPWIDGET">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/black_small_home_widget" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".native.home_widget.large_home_widget.WhiteLargeHomeWidgetReceiver"
|
||||
android:exported="true"
|
||||
android:label="LLMP White Large Widget"
|
||||
android:permission="android.permission.BIND_APPWIDGET">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/white_large_home_widget" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".native.home_widget.large_home_widget.BlackLargeHomeWidgetReceiver"
|
||||
android:exported="true"
|
||||
android:label="LLMP Black Large Widget"
|
||||
android:permission="android.permission.BIND_APPWIDGET">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/black_large_home_widget" />
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
42
androidApp/src/main/assets/tachie/index.css
Normal file
@@ -0,0 +1,42 @@
|
||||
* {
|
||||
-webkit-touch-callout:none;
|
||||
-webkit-user-select:none;
|
||||
-khtml-user-select:none;
|
||||
-moz-user-select:none;
|
||||
-ms-user-select:none;
|
||||
user-select:none;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.top {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: -45px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: -20px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sakana {
|
||||
width:50px;
|
||||
height: 130px;
|
||||
margin-top: 5px;
|
||||
position: relative;
|
||||
}
|
||||
135
androidApp/src/main/assets/tachie/index.html
Normal file
@@ -0,0 +1,135 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<link rel="stylesheet" href="sakana.css"/>
|
||||
<link rel="stylesheet" href="index.css"/>
|
||||
<script>
|
||||
function getParams() {
|
||||
let url = location.search
|
||||
let obj = {};
|
||||
if (url.indexOf('?') !== -1) {
|
||||
let str = url.substring(1)
|
||||
let arr = str.split('&');
|
||||
arr.map(item => {
|
||||
obj[item.split('=')[0]] = item.split('=')[1]
|
||||
})
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
function initWidget() {
|
||||
const params = getParams()
|
||||
const isBonus = params.isBonus === 'true'
|
||||
isBonus ? renderBonusWidget(params) : renderNormalWidget(params)
|
||||
}
|
||||
|
||||
function renderNormalWidget(params) {
|
||||
const roleList = []
|
||||
|
||||
fetch('tachie.json')
|
||||
.then(resp => resp.json())
|
||||
.then(json => {
|
||||
const configList = json[params.group]
|
||||
for (let i = 0; i < params.bin.length; i++) {
|
||||
if (params.bin.charAt(i) === "1") {
|
||||
roleList.push(configList[i])
|
||||
}
|
||||
}
|
||||
|
||||
for (let position = 0; position < 12; position++) {
|
||||
if (position > roleList.length - 1) {
|
||||
continue
|
||||
}
|
||||
const chisato = SakanaWidget.getCharacter('chisato')
|
||||
let angle = Math.floor(Math.random() * 10)
|
||||
chisato.initialState = {
|
||||
...chisato.initialState,
|
||||
i: 0.002,
|
||||
d: 1,
|
||||
r: angle,
|
||||
y: 15
|
||||
}
|
||||
chisato.image = `data:image/png;base64,${roleList[position].photo}`
|
||||
SakanaWidget.registerCharacter('chisato' + position, chisato)
|
||||
let canMove = params.canMove === "true"
|
||||
|
||||
new SakanaWidget({
|
||||
character: 'chisato' + position,
|
||||
autoFit: true,
|
||||
controls: false,
|
||||
stroke: {
|
||||
color: roleList[position].color,
|
||||
heightPercent: 0.6
|
||||
},
|
||||
showRod: canMove
|
||||
}).mount("#widget" + position, canMove)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function renderBonusWidget(params) {
|
||||
let roleList = []
|
||||
|
||||
fetch('tachie.json')
|
||||
.then(resp => resp.json())
|
||||
.then(json => {
|
||||
roleList = [...json["bonus"]]
|
||||
|
||||
for (let position = 0; position < 12; position++) {
|
||||
if (position > roleList.length - 1) {
|
||||
continue
|
||||
}
|
||||
const chisato = SakanaWidget.getCharacter('chisato')
|
||||
let inertia = (Math.floor(Math.random() * 11) + 35) / 10000
|
||||
let angle = position % 2 === 0 ? -7 : 7
|
||||
chisato.initialState = {
|
||||
...chisato.initialState,
|
||||
i: inertia,
|
||||
d: 1,
|
||||
r: angle,
|
||||
y: 10
|
||||
}
|
||||
chisato.image = `data:image/png;base64,${roleList[position].photo}`
|
||||
SakanaWidget.registerCharacter('chisato' + position, chisato)
|
||||
let canMove = params.canMove === "true"
|
||||
|
||||
new SakanaWidget({
|
||||
character: 'chisato' + position,
|
||||
autoFit: true,
|
||||
controls: false,
|
||||
stroke: {
|
||||
color: roleList[position].color,
|
||||
heightPercent: 0.6
|
||||
},
|
||||
showRod: false
|
||||
}).mount("#widget" + position, canMove)
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script async onload="initWidget()" src="sakana.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div class="sakana" id="widget10"></div>
|
||||
<div class="sakana" id="widget5"></div>
|
||||
<div class="sakana" id="widget3"></div>
|
||||
<div class="sakana" id="widget2"></div>
|
||||
<div class="sakana" id="widget7"></div>
|
||||
<div class="sakana" id="widget8"></div>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<div class="sakana" id="widget9"></div>
|
||||
<div class="sakana" id="widget4"></div>
|
||||
<div class="sakana" id="widget1"></div>
|
||||
<div class="sakana" id="widget0"></div>
|
||||
<div class="sakana" id="widget6"></div>
|
||||
<div class="sakana" id="widget11"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1
androidApp/src/main/assets/tachie/sakana.css
Normal file
@@ -0,0 +1 @@
|
||||
.sakana-widget *,.sakana-widget *::before,.sakana-widget *::after{box-sizing:border-box}.sakana-widget-wrapper{pointer-events:none;position:relative;width:100%;height:100%}.sakana-widget-app{pointer-events:none;position:relative}.sakana-widget-canvas{z-index:10;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%)}.sakana-widget-main{z-index:20;pointer-events:none;position:absolute;display:flex;flex-direction:column;justify-content:space-between;align-items:center}.sakana-widget-img{z-index:40;cursor:move;pointer-events:auto;position:relative;background:no-repeat 50% 50%;background-size:cover}.sakana-widget-ctrl{z-index:30;cursor:pointer;pointer-events:auto;position:relative;height:24px;width:112px;display:flex;border-radius:6px;background-color:#ddd;box-shadow:0 8px 24px rgba(0,0,0,.1)}.sakana-widget-ctrl-item{height:24px;width:28px;display:flex;justify-content:center;align-items:center;color:#555;background-color:rgba(0,0,0,0)}.sakana-widget-ctrl-item:hover{color:#555;background-color:rgba(255,255,255,.25)}.sakana-widget-icon{height:18px;width:18px}.sakana-widget-icon--rotate{animation:sakana-widget-spin 2s linear infinite}@keyframes sakana-widget-spin{100%{transform:rotate(360deg)}}
|
||||
4
androidApp/src/main/assets/tachie/sakana.js
Normal file
361
androidApp/src/main/assets/tachie/tachie.json
Normal file
BIN
androidApp/src/main/assets/test_tone.wav
Normal file
205
androidApp/src/main/kotlin/top/zhushenwudi/llmp/MainActivity.kt
Normal file
@@ -0,0 +1,205 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package top.zhushenwudi.llmp.native
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.graphics.PixelFormat
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.ImageView
|
||||
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 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.
|
||||
*/
|
||||
class DesktopLyricOverlay(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
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
|
||||
|
||||
fun updateLyric(lyricLine1: String?, lyricLine2: String?, currentLine: Int) {
|
||||
cachedLine1 = lyricLine1
|
||||
cachedLine2 = lyricLine2
|
||||
cachedCurrentLine = currentLine
|
||||
val overlay = activeRef?.get() ?: return
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
overlay.applyCachedToViews()
|
||||
} else {
|
||||
mainHandler.post { activeRef?.get()?.applyCachedToViews() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var wmParams: WindowManager.LayoutParams? = null
|
||||
private var windowManager: WindowManager? = null
|
||||
private var root: RelativeLayout? = null
|
||||
private var llLyric: LinearLayout? = null
|
||||
private var tvLyricLine1: TextView? = null
|
||||
private var tvLyricLine2: TextView? = null
|
||||
private var tvNoLyric: TextView? = null
|
||||
private var ivClose: ImageView? = null
|
||||
private var ivPip: ImageView? = null
|
||||
private var ivIcon: ImageView? = null
|
||||
private var ivTranslate: ImageView? = null
|
||||
|
||||
private var sx = 0f
|
||||
private var sy = 0f
|
||||
private var mStartX = 0
|
||||
private var mStartY = 0
|
||||
private var isMove = false
|
||||
private var mLastTime = 0L
|
||||
private var hideChromeJob: Job? = null
|
||||
|
||||
val isAttached: Boolean get() = root != null
|
||||
|
||||
fun attach() {
|
||||
if (root != null) return
|
||||
createView()
|
||||
bindView()
|
||||
activeRef = WeakReference(this)
|
||||
startChromeTimer()
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
hideChromeJob?.cancel()
|
||||
hideChromeJob = null
|
||||
if (activeRef?.get() === this) {
|
||||
activeRef = null
|
||||
}
|
||||
val view = root ?: return
|
||||
llLyric?.setOnClickListener(null)
|
||||
llLyric?.setOnTouchListener(null)
|
||||
ivClose?.setOnClickListener(null)
|
||||
ivPip?.setOnClickListener(null)
|
||||
ivTranslate?.setOnClickListener(null)
|
||||
runCatching { windowManager?.removeView(view) }
|
||||
windowManager = null
|
||||
root = null
|
||||
llLyric = null
|
||||
tvLyricLine1 = null
|
||||
tvLyricLine2 = null
|
||||
tvNoLyric = null
|
||||
ivClose = null
|
||||
ivPip = null
|
||||
ivIcon = null
|
||||
ivTranslate = null
|
||||
}
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
private fun createView() {
|
||||
wmParams = WindowManager.LayoutParams().apply {
|
||||
type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
WindowManager.LayoutParams.TYPE_TOAST
|
||||
}
|
||||
format = PixelFormat.RGBA_8888
|
||||
flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
|
||||
gravity = Gravity.CENTER_VERTICAL or Gravity.START
|
||||
width = WindowManager.LayoutParams.MATCH_PARENT
|
||||
height = WindowManager.LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
root = LayoutInflater.from(context).inflate(R.layout.view_lyric, null) as RelativeLayout
|
||||
windowManager?.addView(root, wmParams)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun bindView() {
|
||||
val rl = root ?: return
|
||||
llLyric = rl.findViewById(R.id.ll_lyric)
|
||||
tvLyricLine1 = rl.findViewById(R.id.tv_lyric_line1)
|
||||
tvLyricLine2 = rl.findViewById(R.id.tv_lyric_line2)
|
||||
tvNoLyric = rl.findViewById(R.id.tv_no_lyric)
|
||||
ivClose = rl.findViewById(R.id.ic_close)
|
||||
ivPip = rl.findViewById(R.id.ic_pip)
|
||||
ivIcon = rl.findViewById(R.id.ic_icon)
|
||||
ivTranslate = rl.findViewById(R.id.ic_translate)
|
||||
applyCachedToViews()
|
||||
|
||||
llLyric?.setOnClickListener {
|
||||
hideChromeJob?.cancel()
|
||||
if (ivClose?.visibility == View.VISIBLE) {
|
||||
ivClose?.visibility = View.GONE
|
||||
ivPip?.visibility = View.GONE
|
||||
ivTranslate?.visibility = View.GONE
|
||||
ivIcon?.visibility = View.VISIBLE
|
||||
} else {
|
||||
ivClose?.visibility = View.VISIBLE
|
||||
ivPip?.visibility = View.VISIBLE
|
||||
ivTranslate?.visibility = View.VISIBLE
|
||||
ivIcon?.visibility = View.GONE
|
||||
startChromeTimer()
|
||||
}
|
||||
}
|
||||
llLyric?.setOnTouchListener { view, event ->
|
||||
if (view.id != R.id.ll_lyric) return@setOnTouchListener true
|
||||
val mRawX = event.rawX
|
||||
val mRawY = event.rawY
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
isMove = false
|
||||
mLastTime = System.currentTimeMillis()
|
||||
mStartX = event.rawX.toInt()
|
||||
mStartY = event.rawY.toInt()
|
||||
sx = mRawX
|
||||
sy = mRawY
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
isMove = true
|
||||
val params = wmParams ?: return@setOnTouchListener true
|
||||
params.x = (params.x + (mRawX - sx)).toInt()
|
||||
params.y = (params.y + (mRawY - sy)).toInt()
|
||||
windowManager?.updateViewLayout(root, params)
|
||||
sx = mRawX
|
||||
sy = mRawY
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
val elapsed = System.currentTimeMillis() - mLastTime
|
||||
val mStopX = event.rawX.toInt()
|
||||
val mStopY = event.rawY.toInt()
|
||||
isMove = if (elapsed < 500) {
|
||||
abs(mStartX - mStopX) >= 10 || abs(mStartY - mStopY) >= 10
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
isMove
|
||||
}
|
||||
ivClose?.setOnClickListener { onRequestHide() }
|
||||
ivPip?.setOnClickListener {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
context.startActivity(intent)
|
||||
Toast.makeText(context, MSG_HINT, Toast.LENGTH_LONG).show()
|
||||
onRequestHide()
|
||||
}
|
||||
ivTranslate?.setOnClickListener {
|
||||
NativeEvents.lyricTypeClick()
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyCachedToViews() {
|
||||
val lyricLine1 = cachedLine1
|
||||
val lyricLine2 = cachedLine2
|
||||
val currentLine = cachedCurrentLine
|
||||
tvLyricLine1?.text = lyricLine1.orEmpty()
|
||||
tvLyricLine2?.text = lyricLine2.orEmpty()
|
||||
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 ?: "暂无歌词"
|
||||
tvLyricLine1?.visibility = View.GONE
|
||||
tvLyricLine2?.visibility = View.GONE
|
||||
tvNoLyric?.visibility = View.VISIBLE
|
||||
} else {
|
||||
tvLyricLine1?.visibility = View.VISIBLE
|
||||
tvLyricLine2?.visibility = View.VISIBLE
|
||||
tvNoLyric?.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun startChromeTimer() {
|
||||
hideChromeJob?.cancel()
|
||||
hideChromeJob = scope.launch(Dispatchers.Default) {
|
||||
delay(2.seconds)
|
||||
withContext(Dispatchers.Main) {
|
||||
llLyric?.performClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package top.zhushenwudi.llmp.native
|
||||
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import top.zhushenwudi.llmp.platform.DeepLinkPayload
|
||||
|
||||
object NativeEvents {
|
||||
private val _widgetEvents = MutableSharedFlow<String>(extraBufferCapacity = 8)
|
||||
val widgetEvents: SharedFlow<String> = _widgetEvents.asSharedFlow()
|
||||
|
||||
private val _lyricTypeEvents = MutableSharedFlow<Long>(extraBufferCapacity = 8)
|
||||
val lyricTypeEvents: SharedFlow<Long> = _lyricTypeEvents.asSharedFlow()
|
||||
|
||||
private val _deepLinkEvents = MutableSharedFlow<DeepLinkPayload>(extraBufferCapacity = 8)
|
||||
val deepLinkEvents: SharedFlow<DeepLinkPayload> = _deepLinkEvents.asSharedFlow()
|
||||
|
||||
fun emitWidgetEvent(uri: String) {
|
||||
_widgetEvents.tryEmit(uri)
|
||||
}
|
||||
|
||||
fun lyricTypeClick() {
|
||||
_lyricTypeEvents.tryEmit(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
fun emitDeepLink(payload: DeepLinkPayload) {
|
||||
_deepLinkEvents.tryEmit(payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package top.zhushenwudi.llmp.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
|
||||
|
||||
class QrScanActivity : ComponentActivity() {
|
||||
private val barcodeLauncher = registerForActivityResult(ScanContract()) { result ->
|
||||
val contents = result.contents
|
||||
if (contents != null) {
|
||||
AndroidUiHost.qrResultHandler?.invoke(contents)
|
||||
setResult(RESULT_OK, intent.putExtra(AndroidUiHost.EXTRA_SCAN_RESULT, contents))
|
||||
} else {
|
||||
setResult(RESULT_CANCELED)
|
||||
}
|
||||
AndroidUiHost.qrResultHandler = null
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val options = ScanOptions().apply {
|
||||
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||
setPrompt("扫描二维码")
|
||||
setBeepEnabled(false)
|
||||
setOrientationLocked(false)
|
||||
setCaptureActivity(com.journeyapps.barcodescanner.CaptureActivity::class.java)
|
||||
}
|
||||
barcodeLauncher.launch(options)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package top.zhushenwudi.llmp.native
|
||||
|
||||
import android.content.Context
|
||||
import top.zhushenwudi.llmp.platform.AnalyticsHook
|
||||
|
||||
object UmengBridge : AnalyticsHook {
|
||||
private const val UMENG_KEY = "634bd9c688ccdf4b7e4ac67b"
|
||||
private const val UMENG_TAG = "Umeng"
|
||||
|
||||
private var appContext: Context? = null
|
||||
private var initialized = false
|
||||
|
||||
fun attachContext(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
override fun initIfAgreed(agreed: Boolean) {
|
||||
if (!agreed || initialized) return
|
||||
val context = appContext ?: return
|
||||
try {
|
||||
val configure = Class.forName("com.umeng.commonsdk.UMConfigure")
|
||||
configure.getMethod(
|
||||
"preInit",
|
||||
Context::class.java,
|
||||
String::class.java,
|
||||
String::class.java,
|
||||
).invoke(null, context, UMENG_KEY, UMENG_TAG)
|
||||
configure.getMethod("setLogEnabled", Boolean::class.javaPrimitiveType)
|
||||
.invoke(null, true)
|
||||
configure.getMethod("setEncryptEnabled", Boolean::class.javaPrimitiveType)
|
||||
.invoke(null, true)
|
||||
try {
|
||||
Class.forName("com.umeng.umeng_common_sdk.UmengCommonSdkPlugin")
|
||||
.getMethod("setContext", Context::class.java)
|
||||
.invoke(null, context)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
initialized = true
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
override fun event(name: String, props: Map<String, String>) {
|
||||
if (!initialized) return
|
||||
try {
|
||||
val eventClass = Class.forName("com.umeng.analytics.MobclickAgent")
|
||||
if (props.isEmpty()) {
|
||||
eventClass.getMethod("onEvent", Context::class.java, String::class.java)
|
||||
.invoke(null, appContext, name)
|
||||
} else {
|
||||
val map = HashMap(props)
|
||||
eventClass.getMethod(
|
||||
"onEvent",
|
||||
Context::class.java,
|
||||
String::class.java,
|
||||
HashMap::class.java,
|
||||
).invoke(null, appContext, name, map)
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package top.zhushenwudi.llmp.native.home_widget.large_home_widget
|
||||
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
|
||||
class BlackLargeHomeWidget(override var isWhite: Boolean = false) : LargeHomeWidgetContent() {
|
||||
override val sizeMode: SizeMode = SizeMode.Single
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package top.zhushenwudi.llmp.native.home_widget.large_home_widget
|
||||
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
|
||||
class BlackLargeHomeWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = BlackLargeHomeWidget()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package top.zhushenwudi.llmp.native.home_widget.large_home_widget
|
||||
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
|
||||
class WhiteLargeHomeWidget(override var isWhite: Boolean = true) : LargeHomeWidgetContent() {
|
||||
override val sizeMode: SizeMode = SizeMode.Single
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package top.zhushenwudi.llmp.native.home_widget.large_home_widget
|
||||
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
|
||||
class WhiteLargeHomeWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = WhiteLargeHomeWidget()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package top.zhushenwudi.llmp.native.home_widget.small_home_widget
|
||||
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
|
||||
class BlackSmallHomeWidget(override var isWhite: Boolean = false) : SmallHomeWidgetContent() {
|
||||
override val sizeMode: SizeMode = SizeMode.Single
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package top.zhushenwudi.llmp.native.home_widget.small_home_widget
|
||||
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
|
||||
class BlackSmallHomeWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = BlackSmallHomeWidget()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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()
|
||||
@@ -0,0 +1,7 @@
|
||||
package top.zhushenwudi.llmp.native.home_widget.small_home_widget
|
||||
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
|
||||
class WhiteSmallHomeWidget(override var isWhite: Boolean = true) : SmallHomeWidgetContent() {
|
||||
override val sizeMode: SizeMode = SizeMode.Single
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package top.zhushenwudi.llmp.native.home_widget.small_home_widget
|
||||
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
|
||||
class WhiteSmallHomeWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = WhiteSmallHomeWidget()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package top.zhushenwudi.llmp.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
|
||||
|
||||
class AndroidDeepLinkHandler(
|
||||
private val onDeepLink: ((DeepLinkPayload) -> Unit)? = null,
|
||||
) : DeepLinkHandler {
|
||||
var lastPayload: DeepLinkPayload? = null
|
||||
private set
|
||||
|
||||
override fun parse(url: String): DeepLinkPayload? = DeepLinkParser.parse(url)
|
||||
|
||||
override fun handle(url: String) {
|
||||
val payload = parse(url) ?: return
|
||||
lastPayload = payload
|
||||
NativeEvents.emitDeepLink(payload)
|
||||
onDeepLink?.invoke(payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package top.zhushenwudi.llmp.native.platform
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
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
|
||||
|
||||
enum class UsbMountEvent {
|
||||
MOUNTED,
|
||||
EJECTED,
|
||||
}
|
||||
|
||||
class AndroidUsbMountMonitor(
|
||||
context: Context,
|
||||
private val fileSystem: FileSystem,
|
||||
) : UsbMountMonitor {
|
||||
private val appContext = context.applicationContext
|
||||
private var receiver: BroadcastReceiver? = null
|
||||
|
||||
private val _events = MutableSharedFlow<UsbMountEvent>(extraBufferCapacity = 8)
|
||||
val events: SharedFlow<UsbMountEvent> = _events.asSharedFlow()
|
||||
|
||||
var onMount: (() -> Unit)? = null
|
||||
var onEject: (() -> Unit)? = null
|
||||
|
||||
override fun start() {
|
||||
if (receiver != null) return
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_MEDIA_MOUNTED)
|
||||
addAction(Intent.ACTION_MEDIA_EJECT)
|
||||
addDataScheme("file")
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
appContext.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
appContext.registerReceiver(receiver, filter)
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
receiver?.let {
|
||||
appContext.unregisterReceiver(it)
|
||||
receiver = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun listMountedVolumes(): List<String> = fileSystem.listUsbRoots()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package top.zhushenwudi.llmp.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
|
||||
|
||||
fun installAndroidPlatformServices(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val fileSystem = createFileSystem()
|
||||
|
||||
UmengBridge.attachContext(appContext)
|
||||
|
||||
PlatformServices.install(
|
||||
desktopLyric = AndroidDesktopLyricController(appContext),
|
||||
homeWidget = AndroidHomeWidgetController(appContext),
|
||||
deepLink = AndroidDeepLinkHandler(),
|
||||
usbMount = AndroidUsbMountMonitor(appContext, fileSystem),
|
||||
share = AndroidShareBridge(appContext),
|
||||
mediaSession = AndroidMediaSessionBridge(),
|
||||
analytics = UmengBridge,
|
||||
)
|
||||
|
||||
(PlatformServices.usbMount as? AndroidUsbMountMonitor)?.start()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package top.zhushenwudi.llmp.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
|
||||
|
||||
object AppUtils {
|
||||
private const val PREFIX_URL = "llmp://"
|
||||
const val SAVE_FILE_NAME = "sharedImage.png"
|
||||
const val SP_NAME = "HomeWidgetPreferences"
|
||||
|
||||
val SMALL_SQUARE = DpSize(170.dp, 120.dp)
|
||||
val HORIZONTAL_RECTANGLE = DpSize(325.dp, 165.dp)
|
||||
|
||||
fun initUmeng(context: Context) {
|
||||
UmengBridge.attachContext(context)
|
||||
}
|
||||
|
||||
fun getSchemeData(intent: Intent, block: (url: String) -> Unit) {
|
||||
val data = intent.data
|
||||
if (data != null) {
|
||||
val url = data.toString()
|
||||
if (url.startsWith(PREFIX_URL)) {
|
||||
block.invoke(url)
|
||||
intent.data = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isServiceWorked(mContext: Context, className: String): Boolean {
|
||||
val runningService = getRunningService(mContext)
|
||||
for (i in runningService.indices) {
|
||||
if (runningService[i].service.className == className) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun getRunningService(mContext: Context): ArrayList<ActivityManager.RunningServiceInfo> {
|
||||
val myManager =
|
||||
mContext.applicationContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
return myManager.getRunningServices(30) as ArrayList<ActivityManager.RunningServiceInfo>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package top.zhushenwudi.llmp.native.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Bitmap.CompressFormat
|
||||
import android.graphics.BitmapShader
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RadialGradient
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Shader
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.scale
|
||||
import androidx.palette.graphics.Palette
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import kotlin.math.min
|
||||
|
||||
object ImageUtil {
|
||||
|
||||
fun squareAndCircularBitmap(bitmap: Bitmap, size: Int): Bitmap {
|
||||
val squareBitmap = getSquareBitmap(bitmap)
|
||||
val circularBitmap = getCircularBitmap(squareBitmap)
|
||||
return circularBitmap.scale(size, size, false)
|
||||
}
|
||||
|
||||
fun saveBitmapToFile(context: Context, bitmap: Bitmap): String? {
|
||||
var fos: FileOutputStream? = null
|
||||
try {
|
||||
val dir = context.filesDir
|
||||
val file = File(dir, AppUtils.SAVE_FILE_NAME)
|
||||
fos = FileOutputStream(file)
|
||||
bitmap.compress(CompressFormat.PNG, 100, fos)
|
||||
return file.absolutePath
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
return null
|
||||
} finally {
|
||||
fos?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSquareBitmap(bitmap: Bitmap): Bitmap {
|
||||
val width = bitmap.width
|
||||
val height = bitmap.height
|
||||
val size = min(width.toDouble(), height.toDouble()).toInt()
|
||||
return Bitmap.createBitmap(
|
||||
bitmap,
|
||||
(width - size) / 2,
|
||||
(height - size) / 2,
|
||||
size,
|
||||
size,
|
||||
)
|
||||
}
|
||||
|
||||
private fun getCircularBitmap(bitmap: Bitmap): Bitmap {
|
||||
val circularBitmap = createBitmap(bitmap.width, bitmap.height)
|
||||
val paint = Paint()
|
||||
paint.isAntiAlias = true
|
||||
paint.setShader(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
|
||||
}
|
||||
|
||||
fun savePathToSp(context: Context, path: String?) {
|
||||
val sharedPreferences = context.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
|
||||
sharedPreferences.edit(commit = true) {
|
||||
putString("shareImage", path)
|
||||
}
|
||||
}
|
||||
|
||||
fun parsePhotoDomainColor(imgBitmap: Bitmap): Color {
|
||||
val palette = Palette.from(imgBitmap).generate()
|
||||
val dominantColor = palette.getDominantColor(0x000000)
|
||||
return Color(
|
||||
red = android.graphics.Color.red(dominantColor) / 255f,
|
||||
green = android.graphics.Color.green(dominantColor) / 255f,
|
||||
blue = android.graphics.Color.blue(dominantColor) / 255f,
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
fun saveColorToSp(context: Context, bgColor: Color) {
|
||||
val sharedPreferences = context.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE)
|
||||
sharedPreferences.edit(commit = true) {
|
||||
val strBgColor = String.format("%.2f", bgColor.red)
|
||||
.plus(",")
|
||||
.plus(String.format("%.2f", bgColor.green))
|
||||
.plus(",")
|
||||
.plus(String.format("%.2f", bgColor.blue))
|
||||
putString("bgColor", strBgColor)
|
||||
}
|
||||
}
|
||||
|
||||
fun createGradientImage(width: Float, height: Float, color: Color): Bitmap {
|
||||
val bitmap = createBitmap(width.toInt(), height.toInt())
|
||||
val canvas = Canvas(bitmap)
|
||||
val paint = Paint()
|
||||
val rect = RectF(0f, 0f, width, height)
|
||||
val radio = if (width == AppUtils.SMALL_SQUARE.width.value) 1f else 0.85f
|
||||
val gradient = RadialGradient(
|
||||
rect.centerX(),
|
||||
rect.centerY(),
|
||||
rect.width().coerceAtLeast(rect.height()) * radio,
|
||||
intArrayOf(
|
||||
Color(red = 229, green = 233, blue = 235, alpha = 250).toArgb(),
|
||||
Color(red = 219, green = 233, blue = 255, alpha = 250).toArgb(),
|
||||
Color(
|
||||
red = color.red,
|
||||
green = color.green,
|
||||
blue = color.blue,
|
||||
alpha = 0.4f,
|
||||
).toArgb(),
|
||||
),
|
||||
null,
|
||||
Shader.TileMode.CLAMP,
|
||||
)
|
||||
paint.setShader(gradient)
|
||||
canvas.drawRect(rect, paint)
|
||||
return bitmap
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package top.zhushenwudi.llmp.player
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
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
|
||||
|
||||
/**
|
||||
* Media3 [MediaSessionService] hosting the shared ExoPlayer + optional desktop-lyric overlay.
|
||||
*
|
||||
* One FGS notification (Media3 media style) covers playback; floating lyrics no longer post a
|
||||
* second "歌词服务" notification.
|
||||
*/
|
||||
class PlaybackService : MediaSessionService() {
|
||||
private var mediaSession: MediaSession? = null
|
||||
private var desktopLyric: DesktopLyricOverlay? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
ensureChannel()
|
||||
|
||||
setMediaNotificationProvider(
|
||||
DefaultMediaNotificationProvider.Builder(this)
|
||||
.setChannelId(CHANNEL_ID)
|
||||
.setChannelName(R.string.playback_channel_name)
|
||||
.setNotificationId(NOTIF_ID)
|
||||
.build()
|
||||
.apply { setSmallIcon(R.drawable.logo) },
|
||||
)
|
||||
|
||||
val player = AndroidPlayerHolder.getOrCreate(this)
|
||||
val session = MediaSession.Builder(this, player)
|
||||
.setSessionActivity(sessionActivityIntent())
|
||||
.build()
|
||||
mediaSession = session
|
||||
addSession(session)
|
||||
|
||||
// Satisfy startForegroundService() until Media3 posts the real media notification.
|
||||
val notification = bootstrapNotification()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
NOTIF_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIF_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_SHOW_DESKTOP_LYRIC -> showDesktopLyricInternal()
|
||||
ACTION_HIDE_DESKTOP_LYRIC -> hideDesktopLyricInternal()
|
||||
}
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =
|
||||
mediaSession
|
||||
|
||||
override fun onDestroy() {
|
||||
hideDesktopLyricInternal()
|
||||
serviceScope.cancel()
|
||||
mediaSession?.let { session ->
|
||||
removeSession(session)
|
||||
session.release()
|
||||
}
|
||||
mediaSession = null
|
||||
if (instance === this) instance = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
val player = mediaSession?.player
|
||||
if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDesktopLyricInternal() {
|
||||
val run = Runnable {
|
||||
if (desktopLyric == null) {
|
||||
desktopLyric = DesktopLyricOverlay(
|
||||
context = this,
|
||||
scope = serviceScope,
|
||||
onRequestHide = { hideDesktopLyricInternal() },
|
||||
)
|
||||
}
|
||||
desktopLyric?.attach()
|
||||
}
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) run.run() else mainHandler.post(run)
|
||||
}
|
||||
|
||||
private fun hideDesktopLyricInternal() {
|
||||
val run = Runnable {
|
||||
desktopLyric?.detach()
|
||||
desktopLyric = null
|
||||
}
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) run.run() else mainHandler.post(run)
|
||||
}
|
||||
|
||||
private fun ensureChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val nm = getSystemService(NotificationManager::class.java) ?: return
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.playback_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { setShowBadge(false) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun bootstrapNotification() =
|
||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText("准备播放")
|
||||
.setSmallIcon(R.drawable.logo)
|
||||
.setContentIntent(sessionActivityIntent())
|
||||
.setOngoing(true)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.build()
|
||||
|
||||
private fun sessionActivityIntent(): PendingIntent {
|
||||
val launch = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
launch,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@Volatile
|
||||
private var instance: PlaybackService? = null
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, PlaybackService::class.java)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
||||
/** Show floating lyrics; reuses this service's Media3 FGS notification. */
|
||||
fun showDesktopLyric(context: Context) {
|
||||
val existing = instance
|
||||
if (existing != null) {
|
||||
existing.showDesktopLyricInternal()
|
||||
return
|
||||
}
|
||||
val intent = Intent(context, PlaybackService::class.java).apply {
|
||||
action = ACTION_SHOW_DESKTOP_LYRIC
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
||||
/** Hide floating lyrics without stopping media playback / 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) {
|
||||
DesktopLyricOverlay.updateLyric(line1, line2, currentLine)
|
||||
}
|
||||
|
||||
fun isDesktopLyricShowing(): Boolean = instance?.desktopLyric?.isAttached == true
|
||||
}
|
||||
}
|
||||
BIN
androidApp/src/main/res/drawable/background.png
Normal file
|
After Width: | Height: | Size: 724 KiB |
6
androidApp/src/main/res/drawable/bg_lyric.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@android:color/black" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
||||
BIN
androidApp/src/main/res/drawable/cd_black.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
androidApp/src/main/res/drawable/cd_white.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
9
androidApp/src/main/res/drawable/click_ripple.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="#40FFFFFF">
|
||||
<item android:id="@android:id/mask">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#FFFFFFFF"/>
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
BIN
androidApp/src/main/res/drawable/foreground.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
13
androidApp/src/main/res/drawable/ic_close.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M11.9997 10.5865L16.9495 5.63672L18.3637 7.05093L13.4139 12.0007L18.3637
|
||||
16.9504L16.9495 18.3646L11.9997 13.4149L7.04996 18.3646L5.63574 16.9504L10.5855
|
||||
12.0007L5.63574 7.05093L7.04996 5.63672L11.9997 10.5865Z" />
|
||||
</vector>
|
||||
13
androidApp/src/main/res/drawable/ic_launcher.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?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>
|
||||
11
androidApp/src/main/res/drawable/ic_notification_play.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Status-bar / notification small icon: white silhouette on transparent. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="m5,4 l0.007,-0.164c0.053,-0.651 0.422,-1.238 0.993,-1.568 0.619,-0.357 1.381,-0.357 2,0 0.024,0.014 0.047,0.028 0.07,0.043l-0,-0 0.034,0.022c0.016,0.01 0.031,0.021 0.047,0.032l12.019,8.015c0.498,0.366 0.824,0.956 0.83,1.62 0.006,0.682 -0.318,1.289 -0.827,1.651l-0.073,0.048 -11.998,7.969c-0.32,0.222 -0.702,0.346 -1.102,0.331 -1.1,-0.04 -2,-0.9 -2,-2z"
|
||||
android:fillColor="#FFFFFF" />
|
||||
</vector>
|
||||
14
androidApp/src/main/res/drawable/ic_pip.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M21 3C21.5523 3 22 3.44772 22 4V11H20V5H4V19H10V21H3C2.44772 21 2 20.5523 2
|
||||
20V4C2 3.44772 2.44772 3 3 3H21ZM21 13C21.5523 13 22 13.4477 22 14V20C22 20.5523
|
||||
21.5523 21 21 21H13C12.4477 21 12 20.5523 12 20V14C12 13.4477 12.4477 13 13
|
||||
13H21Z" />
|
||||
</vector>
|
||||
15
androidApp/src/main/res/drawable/ic_translate.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="#ffffff"
|
||||
android:pathData="M5 15V17C5 18.0544 5.81588 18.9182 6.85074 18.9945L7 19H10V21H7C4.79086 21 3
|
||||
19.2091 3 17V15H5ZM18 10L22.4 21H20.245L19.044 18H14.954L13.755 21H11.601L16
|
||||
10H18ZM17 12.8852L15.753 16H18.245L17 12.8852ZM8
|
||||
2V4H12V11H8V14H6V11H2V4H6V2H8ZM17 3C19.2091 3 21 4.79086 21 7V9H19V7C19 5.89543
|
||||
18.1046 5 17 5H14V3H17ZM6 6H4V9H6V6ZM10 6H8V9H10V6Z" />
|
||||
</vector>
|
||||
13
androidApp/src/main/res/drawable/launch_background.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<bitmap
|
||||
android:gravity="fill"
|
||||
android:src="@drawable/background" />
|
||||
</item>
|
||||
<item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@drawable/splash" />
|
||||
</item>
|
||||
</layer-list>
|
||||
BIN
androidApp/src/main/res/drawable/logo.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
androidApp/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 176 KiB |
24
androidApp/src/main/res/drawable/vector_pause.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillAlpha="0.01"
|
||||
android:fillColor="#fff"
|
||||
android:pathData="m0,0h24v24h-24z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="m7,5v14"
|
||||
android:strokeWidth="3"
|
||||
android:strokeColor="#000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="m17,5v14"
|
||||
android:strokeWidth="3"
|
||||
android:strokeColor="#000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
9
androidApp/src/main/res/drawable/vector_play.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="m5,4 l0.007,-0.164c0.053,-0.651 0.422,-1.238 0.993,-1.568 0.619,-0.357 1.381,-0.357 2,0 0.024,0.014 0.047,0.028 0.07,0.043l-0,-0 0.034,0.022c0.016,0.01 0.031,0.021 0.047,0.032l12.019,8.015c0.498,0.366 0.824,0.956 0.83,1.62 0.006,0.682 -0.318,1.289 -0.827,1.651l-0.073,0.048 -11.998,7.969c-0.32,0.222 -0.702,0.346 -1.102,0.331 -1.1,-0.04 -2,-0.9 -2,-2z"
|
||||
android:fillColor="#000" />
|
||||
</vector>
|
||||
BIN
androidApp/src/main/res/drawable/widget_fav_click.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
androidApp/src/main/res/drawable/widget_fav_unclick.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
androidApp/src/main/res/font/noto_sans_jp_regular.ttf
Normal file
@@ -0,0 +1,10 @@
|
||||
<?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" />
|
||||
</FrameLayout>
|
||||
89
androidApp/src/main/res/layout/view_lyric.xml
Normal file
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/rl_lyric"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/ll_lyric"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="15dp"
|
||||
android:background="@drawable/bg_lyric"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_lyric_line1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="5dp"
|
||||
android:fontFamily="@font/noto_sans_jp_regular"
|
||||
android:minLines="2"
|
||||
android:maxLines="2"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_lyric_line2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="5dp"
|
||||
android:fontFamily="@font/noto_sans_jp_regular"
|
||||
android:minLines="2"
|
||||
android:maxLines="2"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="18sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_no_lyric"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignStart="@id/ll_lyric"
|
||||
android:layout_alignTop="@id/ll_lyric"
|
||||
android:layout_alignEnd="@id/ll_lyric"
|
||||
android:layout_alignBottom="@id/ll_lyric"
|
||||
android:gravity="center"
|
||||
android:fontFamily="@font/noto_sans_jp_regular"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/ic_close"
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="30dp"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_marginTop="5dp"
|
||||
android:layout_marginStart="25dp"
|
||||
android:background="@drawable/ic_close" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/ic_pip"
|
||||
android:layout_width="25dp"
|
||||
android:layout_height="25dp"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_marginTop="5dp"
|
||||
android:layout_marginEnd="25dp"
|
||||
android:background="@drawable/ic_pip" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/ic_icon"
|
||||
android:layout_width="25dp"
|
||||
android:layout_height="25dp"
|
||||
android:visibility="gone"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_alignBottom="@id/ll_lyric"
|
||||
android:layout_marginBottom="5dp"
|
||||
android:layout_marginEnd="25dp"
|
||||
android:background="@drawable/logo" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/ic_translate"
|
||||
android:layout_width="25dp"
|
||||
android:layout_height="25dp"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_alignBottom="@id/ll_lyric"
|
||||
android:layout_marginBottom="5dp"
|
||||
android:layout_marginEnd="25dp"
|
||||
android:background="@drawable/ic_translate" />
|
||||
</RelativeLayout>
|
||||
BIN
androidApp/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
androidApp/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
androidApp/src/main/res/mipmap-xxhdpi/ic_widget_black_large.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
androidApp/src/main/res/mipmap-xxhdpi/ic_widget_black_small.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
androidApp/src/main/res/mipmap-xxhdpi/ic_widget_white_large.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
androidApp/src/main/res/mipmap-xxhdpi/ic_widget_white_small.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
5
androidApp/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">LLMP KMP</string>
|
||||
<string name="playback_channel_name">正在播放</string>
|
||||
</resources>
|
||||
12
androidApp/src/main/res/xml/black_large_home_widget.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?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" />
|
||||
13
androidApp/src/main/res/xml/black_small_home_widget.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
小组件高度:70*n-30
|
||||
-->
|
||||
<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" />
|
||||
12
androidApp/src/main/res/xml/white_large_home_widget.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?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" />
|
||||
12
androidApp/src/main/res/xml/white_small_home_widget.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?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" />
|
||||