initial commit

This commit is contained in:
2026-08-02 20:00:21 +08:00
commit bea5ead33d
295 changed files with 20101 additions and 0 deletions

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
*.iml
.kotlin
.gradle
**/build/
xcuserdata
local.properties
.idea
.DS_Store
captures
.externalNativeBuild
.cxx
*.apk
*.aab
*.ipa
*.dSYM.zip
*.dSYM
kotlin-js-store
iosApp/Pods
iosApp/*.xcworkspace
!iosApp/*.xcodeproj/project.pbxproj

79
README.md Normal file
View File

@@ -0,0 +1,79 @@
# LoveLive Music Player — KMP + Compose Multiplatform
与 Flutter 仓库 `LoveLiveMusicPlayerMobile` **并列**。UI 使用 Compose MultiplatformAndroid / iOS 一致PC 仍为 Electron本仓库提供协议客户端与共享模型。
包名:`top.zhushenwudi.llmp`
## Maven / Nexus
`settings.gradle.kts` 已对齐 Flutter `android/nexus_repos.gradle`
- 探测 `http://192.168.123.100:18081/service/rest/v1/status`2s
- 可达 → `maven-public` / `google` / `mob` / `gradle`(插件) / `jitpack`
- 不可达 → 公网 `google()` / `mavenCentral()` / `gradlePluginPortal()`
- 强制:`-PuseNexus=true|false`
## 模块
| 模块 | 职责 |
|------|------|
| `:shared` | 协议、模型、SQLDelight、OSS、歌词、传歌/同步、Player、Settings、FileSystem、PlatformServices |
| `:composeApp` | CMP UI + Android 原生Glance Widget、LyricService、扫码、WebView |
| `iosApp/` | Xcode 脚手架CarPlay / PiP / WidgetKit需 macOS 签名链接 |
## 构建Windows
```bash
./gradlew.bat :shared:testDebugUnitTest
./gradlew.bat :composeApp:assembleDebug
./gradlew.bat :composeApp:installDebug
```
Debug APK`composeApp/build/outputs/apk/debug/composeApp-debug.apk`
## 原生功能矩阵
| 功能 | Android | iOS | 状态 |
|------|---------|-----|------|
| 播放引擎 | ExoPlayer单实例 + MediaSession | AVPlayer + MPNowPlaying / RemoteCommand | **done** |
| `FileSystem.writeBytes` | 完整 | NSData 原子写入 | **done** |
| 桌面歌词 | `LyricService` 悬浮窗 + 设置开关 | PiP`iosApp/pip/*`Swift | **done**iOS 需 Mac 链 framework |
| Home Widget | Glance 四样式 + `HomeWidgetPreferences` | WidgetKit White/Black`iosApp/WidgetExtension` | **done**iOS 需 Xcode target |
| CarPlay | 目录模型 `CarPlayCatalog`common | `CarPlaySceneDelegate` Tab Music/Album/Mine | **sources done**;真机需 Apple **carplay-audio** entitlement |
| 深链 `llmp://` | Manifest + `DeepLinkHandler` | SceneDelegate + App Group | **done** |
| USB 挂载监听 | Broadcast + `UsbMountMonitor` | N/ANoOp | **done**Android |
| 系统分享 | `ACTION_SEND` | UIActivity / Notification → Swift | **done**(最低系统分享) |
| QR 扫码 | ZXing embedded `QrScanActivity` | IP 粘贴(相机可在 Swift 补) | **done**Android 真扫) |
| 萌娘百科 / 立绘 | `WebViewActivity` + assets/tachie | `openURL` / Safari | **done** |
| 友盟 | `UmengBridge` 反射key `634bd9c688ccdf4b7e4ac67b` | 需在 Swift 初始化 iOS key | **done**Android 反射SDK 可选) |
| PlatformServices | MainActivity `installAndroidPlatformServices` | `installIosPlatformServices` | **done**(非 NoOp 默认) |
### 仍需 macOS / Apple 开发者完成的项
1. **Xcode 工程**:创建 App + Widget Extension链接 `ComposeApp.framework``./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64`
2. **签名 / App Group**`group.top.zhushenwudi.llmp`(已从 Flutter `group.com.zhushenwudi.lovelivemusicplayer` 迁移;见 `iosApp/README.md`
3. **CarPlay entitlement**`com.apple.developer.carplay-audio` + `playable-content` 需 Apple 审批后真机可用
4. **PiP 资源**`black.mp4`Flutter 仓库若缺失需自行放入 `iosApp/iosApp/pip/`
5. **Swift ↔ Kotlin 播放回调**Widget/CarPlay 的 NotificationCenter 事件需在 Mac 上接到 `AppContainer.player`
## 已实现业务(相对骨架)
1. OSS 元数据入库、曲库 6 Tab、播放队列、滚动歌词、Love/歌单/History
2. WiFi 传歌 / 数据同步、驾驶模式、SD/USB、设置、每日推送
3. 桌面歌词 / Widget / 扫码 / WebView / 分享 / 深链 / 友盟桥
4. `FeatureModuleIndex` 模块清单(日志页)
## 对照
| 主题 | Flutter | 本仓库 |
|------|---------|--------|
| Nexus | `android/nexus_repos.gradle` | `settings.gradle.kts` |
| 常量 | `lib/global/const.dart` | `Const.kt` |
| 入库 | `global_db.dart` | `DataImportService` |
| 协议 | Docs `03` | `protocol/*` + `transfer/*` |
| 歌词 | `global_lyric.dart` | `lyric/*` + `native/LyricService` |
| Widget SP | `HomeWidgetPreferences` | 同名键 |
| CarPlay | `lib/pages/carplay/*` | `CarPlayCatalog` + Swift Scene |
| UI | GetX | `composeApp` CMP |
文档:`E:\LoveLiveMusicPlayer-Docs\`

View 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)
}

View 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>

View 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;
}

View 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>

View 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)}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

View 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)
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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) {
}
}
}

View File

@@ -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"
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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),
)
}
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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>
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View 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>

View 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" />

View 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" />

View 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" />

View 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" />

9
build.gradle.kts Normal file
View File

@@ -0,0 +1,9 @@
plugins {
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.kotlinSerialization) apply false
alias(libs.plugins.sqldelight) apply false
}

View File

@@ -0,0 +1,74 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
android {
namespace = "top.zhushenwudi.llmp.composeapp"
compileSdk = libs.versions.android.compileSdk.get().toInt()
minSdk = libs.versions.android.minSdk.get().toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
androidResources {
enable = true
}
}
listOf(
iosArm64(),
iosSimulatorArm64(),
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
sourceSets {
androidMain.dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.palette)
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
commonMain.dependencies {
implementation(projects.shared)
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
implementation(libs.compose.ui)
implementation(libs.compose.components.resources)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.navigation.compose)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.client.core)
implementation(libs.coil.compose)
implementation(libs.coil.svg)
implementation(libs.coil.network.ktor3)
implementation(libs.haze)
}
}
}
compose {
resources {
packageOfResClass = "top.zhushenwudi.llmp.generated.resources"
}
}
dependencies {
androidRuntimeClasspath(libs.compose.ui.tooling)
}

View File

@@ -0,0 +1,9 @@
package top.zhushenwudi.llmp.platform
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
@Composable
actual fun DetailBackHandler(enabled: Boolean, onBack: () -> Unit) {
BackHandler(enabled = enabled, onBack = onBack)
}

View File

@@ -0,0 +1,29 @@
package top.zhushenwudi.llmp.ui.player
import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.Color
import androidx.palette.graphics.Palette
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
/**
* Flutter color_thief getColorFromImage → Color.fromARGB(150, r, g, b).
* Android Palette dominant is closest available substitute.
*/
actual suspend fun extractDominantColor(coverPath: String?): Color? =
withContext(Dispatchers.Default) {
if (coverPath.isNullOrBlank()) return@withContext null
val file = File(coverPath)
if (!file.isFile) return@withContext null
val bitmap = BitmapFactory.decodeFile(file.absolutePath) ?: return@withContext null
try {
val dominant = Palette.from(bitmap).generate().getDominantColor(0xFFFFAE00.toInt())
val r = android.graphics.Color.red(dominant)
val g = android.graphics.Color.green(dominant)
val b = android.graphics.Color.blue(dominant)
Color(red = r / 255f, green = g / 255f, blue = b / 255f, alpha = FlutterPaletteAlpha)
} finally {
bitmap.recycle()
}
}

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
<path fill="none" d="M0 0h24v24H0z" />
<path
d="M20 3v14a4 4 0 1 1-2-3.465V5H9v12a4 4 0 1 1-2-3.465V3h13zM5 19a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm11 0a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"
fill="rgba(102,102,102,1)" />
</svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@@ -0,0 +1,14 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd">
<path d="m0 0h24v24h-24z" />
<g stroke="#666" stroke-linejoin="round" stroke-width="2">
<path
d="m12 22.5c-5.7989925 0-10.5-4.7010075-10.5-10.5s4.7010075-10.5 10.5-10.5 10.5 4.7010075 10.5 10.5"
stroke-linecap="round"/>
<path
d="m9.9 12v-3.637305l3.15 1.8186525 3.15 1.8186525-3.15 1.8186525-3.15 1.8186525z" />
<path d="m18.85167 16.2v5.25" stroke-linecap="round" />
<path d="m21.45 18.798593h-5.25" stroke-linecap="round" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 691 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
<path fill="none" d="M0 0h24v24H0z" />
<path d="M2 18h10v2H2v-2zm0-7h20v2H2v-2zm0-7h20v2H2V4zm16 14v-3h2v3h3v2h-3v3h-2v-3h-3v-2h3z"
fill="rgba(102,102,102,1)" />
</svg>

After

Width:  |  Height:  |  Size: 268 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
<path fill="none" d="M0 0h24v24H0z" />
<path
d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636z"
fill="rgba(153,153,153,1)" />
</svg>

After

Width:  |  Height:  |  Size: 322 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
<path fill="none" d="M0 0h24v24H0z" />
<path
d="M17 6h5v2h-2v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V8H2V6h5V3a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v3zm1 2H6v12h12V8zm-9 3h2v6H9v-6zm4 0h2v6h-2v-6zM9 4v2h6V4H9z"
fill="rgba(90,90,90,1)" />
</svg>

After

Width:  |  Height:  |  Size: 337 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
<path fill="none" d="M0 0h24v24H0z" />
<path
d="M16.757 3l-2 2H5v14h14V9.243l2-2V20a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h12.757zm3.728-.9L21.9 3.516l-9.192 9.192-1.412.003-.002-1.417L20.485 2.1z"
fill="rgba(90,90,90,1)" />
</svg>

After

Width:  |  Height:  |  Size: 346 B

View File

@@ -0,0 +1,9 @@
<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd" stroke="#666" stroke-linecap="round" stroke-linejoin="round">
<path
d="m6.15789474 2.84210526v-1.10526315c0-.40694685.32988421-.73684211.7368421-.73684211h7.36842106c.4069579 0 .7368421.32989526.7368421.73684211v8.10526315c0 .40695794-.3298842.73684214-.7368421.73684214h-1.4736842" />
<rect height="8.842105" rx="1" width="8.842105" x="1" y="5.789474" />
<path d="m5.421053 8.368421v3.684211" />
<path d="m3.578947 10.210526h3.684211" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 621 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
<path fill="none" d="M0 0h24v24H0z" />
<path
d="M12 20a8 8 0 1 0 0-16 8 8 0 0 0 0 16zm0 2C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-8a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm0 2a4 4 0 1 1 0-8 4 4 0 0 1 0 8z"
fill="rgba(102,102,102,1)" />
</svg>

After

Width:  |  Height:  |  Size: 368 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
<path fill="none" d="M0 0h24v24H0z" />
<path
d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM11 7h2v2h-2V7zm0 4h2v6h-2v-6z"
fill="rgba(102,102,102,1)" />
</svg>

After

Width:  |  Height:  |  Size: 332 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
<path fill="none" d="M0 0h24v24H0z" />
<path
d="M4.828 21l-.02.02-.021-.02H2.992A.993.993 0 0 1 2 20.007V3.993A1 1 0 0 1 2.992 3h18.016c.548 0 .992.445.992.993v16.014a1 1 0 0 1-.992.993H4.828zM20 15V5H4v14L14 9l6 6zm0 2.828l-6-6L6.828 19H20v-1.172zM8 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"
fill="rgba(102,102,102,1)" />
</svg>

After

Width:  |  Height:  |  Size: 428 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
<path fill="none" d="M0 0h24v24H0z" />
<path
d="M5 5v3h14V5H5zM4 3h16a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm2 9h6a1 1 0 0 1 1 1v3h1v6h-4v-6h1v-2H5a1 1 0 0 1-1-1v-2h2v1zm11.732 1.732l1.768-1.768 1.768 1.768a2.5 2.5 0 1 1-3.536 0z"
fill="rgba(102,102,102,1)"/>
</svg>

After

Width:  |  Height:  |  Size: 398 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"
fill="currentColor">
<path
d="M9 1V3H15V1H17V3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H7V1H9ZM20 11H4V19H20V11ZM8 13V15H6V13H8ZM13 13V15H11V13H13ZM18 13V15H16V13H18ZM7 5H4V9H20V5H17V7H15V5H9V7H7V5Z"
fill="rgba(102,102,102,1)" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
<path fill="none" d="M0 0h24v24H0z" />
<path
d="M19 20H5V21C5 21.5523 4.55228 22 4 22H3C2.44772 22 2 21.5523 2 21V12L4.51334 5.29775C4.80607 4.51715 5.55231 4 6.386 4H17.614C18.4477 4 19.1939 4.51715 19.4867 5.29775L22 12V21C22 21.5523 21.5523 22 21 22H20C19.4477 22 19 21.5523 19 21V20ZM4.136 12H19.864L17.614 6H6.386L4.136 12ZM6.5 17C7.32843 17 8 16.3284 8 15.5C8 14.6716 7.32843 14 6.5 14C5.67157 14 5 14.6716 5 15.5C5 16.3284 5.67157 17 6.5 17ZM17.5 17C18.3284 17 19 16.3284 19 15.5C19 14.6716 18.3284 14 17.5 14C16.6716 14 16 14.6716 16 15.5C16 16.3284 16.6716 17 17.5 17Z"
fill="rgba(102,102,102,1)" />
</svg>

After

Width:  |  Height:  |  Size: 723 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path fill="none" d="M0 0h24v24H0z" />
<path
d="M12 2c5.522 0 10 3.978 10 8.889a5.558 5.558 0 0 1-5.556 5.555h-1.966c-.922 0-1.667.745-1.667 1.667 0 .422.167.811.422 1.1.267.3.434.689.434 1.122C13.667 21.256 12.9 22 12 22 6.478 22 2 17.522 2 12S6.478 2 12 2zm-1.189 16.111a3.664 3.664 0 0 1 3.667-3.667h1.966A3.558 3.558 0 0 0 20 10.89C20 7.139 16.468 4 12 4a8 8 0 0 0-.676 15.972 3.648 3.648 0 0 1-.513-1.86zM7.5 12a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm9 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zM12 9a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"
fill="rgba(102,102,102,1)" />
</svg>

After

Width:  |  Height:  |  Size: 682 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
<path fill="none" d="M0 0h24v24H0z" />
<path
d="M13 13v5.585l1.828-1.828 1.415 1.415L12 22.414l-4.243-4.242 1.415-1.415L11 18.585V13h2zM12 2a7.001 7.001 0 0 1 6.954 6.194 5.5 5.5 0 0 1-.953 10.784v-2.014a3.5 3.5 0 1 0-1.112-6.91 5 5 0 1 0-9.777 0 3.5 3.5 0 0 0-1.292 6.88l.18.03v2.014a5.5 5.5 0 0 1-.954-10.784A7 7 0 0 1 12 2z"
fill="rgba(102,102,102,1)" />
</svg>

After

Width:  |  Height:  |  Size: 472 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path fill="none" d="M0 0h24v24H0z" />
<path
d="M16.05 12.05L21 17l-4.95 4.95-1.414-1.414 2.536-2.537L4 18v-2h13.172l-2.536-2.536 1.414-1.414zm-8.1-10l1.414 1.414L6.828 6 20 6v2H6.828l2.536 2.536L7.95 11.95 3 7l4.95-4.95z"
fill="rgba(102,102,102,1)" />
</svg>

After

Width:  |  Height:  |  Size: 367 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
<path fill="none" d="M0 0h24v24H0z" />
<path
d="M10 7a7 7 0 0 0 12 4.9v.1c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2h.1A6.979 6.979 0 0 0 10 7zm-6 5a8 8 0 0 0 15.062 3.762A9 9 0 0 1 8.238 4.938 7.999 7.999 0 0 0 4 12z"
fill="rgba(102,102,102,1)" />
</svg>

After

Width:  |  Height:  |  Size: 370 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
<path fill="none" d="M0 0h24v24H0z" />
<path
d="M13 19.9a5.002 5.002 0 0 0 4-4.9v-3a4.98 4.98 0 0 0-.415-2h-9.17A4.98 4.98 0 0 0 7 12v3a5.002 5.002 0 0 0 4 4.9V14h2v5.9zm-7.464-2.21A6.979 6.979 0 0 1 5 15H2v-2h3v-1c0-.643.087-1.265.249-1.856L3.036 8.866l1-1.732L6.056 8.3a7.01 7.01 0 0 1 .199-.3h11.49c.069.098.135.199.199.3l2.02-1.166 1 1.732-2.213 1.278c.162.59.249 1.213.249 1.856v1h3v2h-3c0 .953-.19 1.862-.536 2.69l2.5 1.444-1 1.732-2.526-1.458A6.986 6.986 0 0 1 12 22a6.986 6.986 0 0 1-5.438-2.592l-2.526 1.458-1-1.732 2.5-1.443zM8 6a4 4 0 1 1 8 0H8z"
fill="rgba(102,102,102,1)" />
</svg>

After

Width:  |  Height:  |  Size: 702 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"
fill="currentColor">
<path
d="M20.4668 8.69379L20.7134 8.12811C21.1529 7.11947 21.9445 6.31641 22.9323 5.87708L23.6919 5.53922C24.1027 5.35653 24.1027 4.75881 23.6919 4.57612L22.9748 4.25714C21.9616 3.80651 21.1558 2.97373 20.7238 1.93083L20.4706 1.31953C20.2942 0.893489 19.7058 0.893489 19.5293 1.31953L19.2761 1.93083C18.8442 2.97373 18.0384 3.80651 17.0252 4.25714L16.308 4.57612C15.8973 4.75881 15.8973 5.35653 16.308 5.53922L17.0677 5.87708C18.0555 6.31641 18.8471 7.11947 19.2866 8.12811L19.5331 8.69379C19.7136 9.10792 20.2864 9.10792 20.4668 8.69379ZM2 4C2 3.44772 2.44772 3 3 3H14V5H4V19H20V11H22V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4ZM12 12.1707V6H16V8H14V15C14 16.6569 12.6569 18 11 18C9.34315 18 8 16.6569 8 15C8 13.3431 9.34315 12 11 12C11.3506 12 11.6872 12.0602 12 12.1707Z" />
</svg>

After

Width:  |  Height:  |  Size: 922 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="none" d="M0 0h24v24H0z" />
<path
d="M3.91762 8.0366C3.32984 9.23328 3 10.5792 3 11.9999C3 16.9704 7.02944 20.9999 12 20.9999C13.4216 20.9999 14.7684 20.6696 15.9657 20.081C16.8385 20.4544 17.6848 20.6991 18.4564 20.762C19.3582 20.8356 20.3 20.6665 20.9818 19.9847C21.7339 19.2325 21.8625 18.1689 21.7279 17.1727C21.6052 16.2638 21.2481 15.2537 20.726 14.2114C20.9051 13.503 21 12.7619 21 11.9999C21 7.02929 16.9706 2.99986 12 2.99986C11.2389 2.99986 10.4987 3.09454 9.79103 3.27318C8.7474 2.7498 7.73605 2.39172 6.8261 2.26834C5.82897 2.13315 4.76406 2.26128 4.01121 3.01413C3.3287 3.69664 3.16001 4.63956 3.2341 5.54233C3.29752 6.315 3.54313 7.16247 3.91762 8.0366ZM5.3224 5.96574C5.2734 5.75321 5.24204 5.55715 5.2274 5.37873C5.17928 4.79243 5.31727 4.53649 5.42543 4.42834C5.54452 4.30925 5.84797 4.15403 6.55739 4.25021C6.75482 4.27698 6.96951 4.32189 7.2 4.38547C6.50364 4.82527 5.87203 5.35784 5.3224 5.96574ZM19.6124 16.803C19.6751 17.0316 19.7195 17.2445 19.7459 17.4404C19.8416 18.1485 19.6865 18.4515 19.5676 18.5705C19.4595 18.6785 19.204 18.8163 18.6189 18.7686C18.4419 18.7542 18.2475 18.7233 18.0368 18.675C18.6427 18.1268 19.1736 17.4971 19.6124 16.803ZM15.8812 17.8264C14.2046 16.9482 12.2571 15.5026 10.3752 13.6207C8.4954 11.741 7.05092 9.79561 6.17241 8.12028C7.06357 6.78465 8.40124 5.77312 9.96933 5.29866C10.6108 5.10457 11.2923 4.99986 12 4.99986C15.866 4.99986 19 8.13386 19 11.9999C19 12.7083 18.8951 13.3904 18.7006 14.0325C18.2261 15.5991 17.2155 16.9356 15.8812 17.8264ZM13.6052 18.8152C13.0901 18.9359 12.5528 18.9999 12 18.9999C8.13401 18.9999 5 15.8658 5 11.9999C5 11.4476 5.06377 10.9109 5.18429 10.3963C6.14883 11.913 7.43475 13.5087 8.96096 15.0349C10.489 16.563 12.0868 17.8502 13.6052 18.8152Z"
fill="rgba(102,102,102,1)" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

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