initial commit
This commit is contained in:
68
shared/build.gradle.kts
Normal file
68
shared/build.gradle.kts
Normal file
@@ -0,0 +1,68 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.androidKotlinMultiplatformLibrary)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
alias(libs.plugins.sqldelight)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
android {
|
||||
namespace = "top.zhushenwudi.llmp.shared"
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
listOf(
|
||||
iosArm64(),
|
||||
iosSimulatorArm64(),
|
||||
).forEach { iosTarget ->
|
||||
iosTarget.binaries.framework {
|
||||
baseName = "Shared"
|
||||
isStatic = true
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.logging)
|
||||
implementation(libs.ktor.client.websockets)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.sqldelight.runtime)
|
||||
implementation(libs.sqldelight.coroutines)
|
||||
api(libs.multiplatform.settings)
|
||||
implementation(libs.multiplatform.settings.coroutines)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.sqldelight.android)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.session)
|
||||
}
|
||||
iosMain.dependencies {
|
||||
implementation(libs.ktor.client.darwin)
|
||||
implementation(libs.sqldelight.native)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sqldelight {
|
||||
databases {
|
||||
create("LlmpDatabase") {
|
||||
packageName.set("top.zhushenwudi.llmp.db")
|
||||
verifyMigrations.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package top.zhushenwudi.llmp.db
|
||||
|
||||
import android.content.Context
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
|
||||
|
||||
actual class DriverFactory(private val context: Context) {
|
||||
actual fun createDriver(): SqlDriver =
|
||||
AndroidSqliteDriver(LlmpDatabase.Schema, context, "llmp.db")
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package top.zhushenwudi.llmp.db
|
||||
|
||||
actual fun currentTimeMs(): Long = System.currentTimeMillis()
|
||||
@@ -0,0 +1,5 @@
|
||||
package top.zhushenwudi.llmp.lyric
|
||||
|
||||
actual object PlatformInfo {
|
||||
actual val allowEulaLyric: Boolean = true
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package top.zhushenwudi.llmp.network
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
|
||||
actual fun createPlatformHttpClient(): HttpClient = HttpClient(OkHttp)
|
||||
@@ -0,0 +1,23 @@
|
||||
package top.zhushenwudi.llmp.platform
|
||||
|
||||
/**
|
||||
* Android MediaSession metadata bridge (PlaybackService owns the session player).
|
||||
*/
|
||||
class AndroidMediaSessionBridge : MediaSessionHook {
|
||||
@Volatile
|
||||
private var title: String? = null
|
||||
|
||||
@Volatile
|
||||
private var artist: String? = null
|
||||
|
||||
@Volatile
|
||||
private var playing: Boolean = false
|
||||
|
||||
override fun updateSession(title: String?, artist: String?, isPlaying: Boolean) {
|
||||
this.title = title
|
||||
this.artist = artist
|
||||
this.playing = isPlaying
|
||||
}
|
||||
|
||||
fun snapshot(): Triple<String?, String?, Boolean> = Triple(title, artist, playing)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package top.zhushenwudi.llmp.platform
|
||||
|
||||
import android.util.Log
|
||||
|
||||
actual object AppLog {
|
||||
private const val MAX = 3500
|
||||
|
||||
actual fun d(tag: String, message: String) = log(Log.DEBUG, tag, message)
|
||||
|
||||
actual fun i(tag: String, message: String) = log(Log.INFO, tag, message)
|
||||
|
||||
actual fun w(tag: String, message: String) = log(Log.WARN, tag, message)
|
||||
|
||||
actual fun e(tag: String, message: String, throwable: Throwable?) {
|
||||
if (throwable != null) {
|
||||
chunk(message).forEachIndexed { index, part ->
|
||||
if (index == 0) Log.e(tag, part, throwable) else Log.e(tag, part)
|
||||
}
|
||||
} else {
|
||||
log(Log.ERROR, tag, message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun log(priority: Int, tag: String, message: String) {
|
||||
chunk(message).forEach { Log.println(priority, tag, it) }
|
||||
}
|
||||
|
||||
private fun chunk(message: String): List<String> {
|
||||
if (message.length <= MAX) return listOf(message)
|
||||
val parts = ArrayList<String>()
|
||||
var i = 0
|
||||
while (i < message.length) {
|
||||
val end = (i + MAX).coerceAtMost(message.length)
|
||||
parts += message.substring(i, end)
|
||||
i = end
|
||||
}
|
||||
return parts
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package top.zhushenwudi.llmp.platform
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.os.storage.StorageManager
|
||||
import java.io.File
|
||||
|
||||
actual class FileSystem(private val context: Context) {
|
||||
actual val filesRoot: String
|
||||
get() {
|
||||
val ext = context.getExternalFilesDir(null) ?: context.filesDir
|
||||
return ext.absolutePath.trimEnd('/', '\\') + File.separator
|
||||
}
|
||||
|
||||
actual val musicRoot: String
|
||||
get() = filesRoot
|
||||
|
||||
actual fun exists(absolutePath: String): Boolean = File(absolutePath).exists()
|
||||
|
||||
actual fun ensureDir(absolutePath: String): Boolean {
|
||||
val f = File(absolutePath)
|
||||
return f.exists() || f.mkdirs()
|
||||
}
|
||||
|
||||
actual fun writeBytes(absolutePath: String, bytes: ByteArray): Boolean =
|
||||
try {
|
||||
val file = File(absolutePath)
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeBytes(bytes)
|
||||
true
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
|
||||
actual fun listFiles(absoluteDir: String): List<String> {
|
||||
val dir = File(absoluteDir)
|
||||
if (!dir.isDirectory) return emptyList()
|
||||
return dir.listFiles()
|
||||
?.filter { it.isFile }
|
||||
?.map { it.absolutePath }
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
actual fun listUsbRoots(): List<String> {
|
||||
val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager
|
||||
?: return emptyList()
|
||||
return try {
|
||||
sm.storageVolumes.mapNotNull { volume ->
|
||||
if (volume.isRemovable) {
|
||||
@Suppress("DEPRECATION")
|
||||
volume.directory?.absolutePath
|
||||
} else null
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var appContext: Context? = null
|
||||
|
||||
fun initAndroidFileSystem(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
actual fun createFileSystem(): FileSystem {
|
||||
val ctx = appContext
|
||||
?: error("Call initAndroidFileSystem(context) before createFileSystem()")
|
||||
return FileSystem(ctx)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package top.zhushenwudi.llmp.platform
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
|
||||
/**
|
||||
* Holds the current Activity for launching platform UI from common code.
|
||||
*/
|
||||
object AndroidUiHost {
|
||||
@Volatile
|
||||
var activity: Activity? = null
|
||||
|
||||
@Volatile
|
||||
var qrResultHandler: ((String) -> Unit)? = null
|
||||
|
||||
@Volatile
|
||||
var imagePickHandler: ((String?) -> Unit)? = null
|
||||
|
||||
/** Set from MainActivity after registering Activity Result launcher. */
|
||||
@Volatile
|
||||
var launchImagePicker: (() -> Unit)? = null
|
||||
|
||||
const val QR_SCAN_ACTIVITY = "top.zhushenwudi.llmp.native.QrScanActivity"
|
||||
const val WEB_VIEW_ACTIVITY = "top.zhushenwudi.llmp.native.WebViewActivity"
|
||||
const val EXTRA_SCAN_RESULT = "scan_result"
|
||||
const val EXTRA_URL = "url"
|
||||
const val EXTRA_TITLE = "title"
|
||||
}
|
||||
|
||||
actual object PlatformUi {
|
||||
actual fun openQrScanner(onResult: (String) -> Unit) {
|
||||
val act = AndroidUiHost.activity ?: return
|
||||
AndroidUiHost.qrResultHandler = onResult
|
||||
act.startActivity(Intent().setClassName(act, AndroidUiHost.QR_SCAN_ACTIVITY))
|
||||
}
|
||||
|
||||
actual fun openWebView(url: String, title: String?) {
|
||||
val act = AndroidUiHost.activity ?: return
|
||||
act.startActivity(
|
||||
Intent().setClassName(act, AndroidUiHost.WEB_VIEW_ACTIVITY).apply {
|
||||
putExtra(AndroidUiHost.EXTRA_URL, url)
|
||||
putExtra(AndroidUiHost.EXTRA_TITLE, title)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
actual fun requestOverlayPermission() {
|
||||
val act = AndroidUiHost.activity ?: return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(act)) {
|
||||
act.startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
Uri.parse("package:${act.packageName}"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
actual fun moveTaskToBack() {
|
||||
AndroidUiHost.activity?.moveTaskToBack(true)
|
||||
}
|
||||
|
||||
actual fun showToast(message: String) {
|
||||
val act = AndroidUiHost.activity ?: return
|
||||
act.runOnUiThread {
|
||||
Toast.makeText(act, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
actual fun pickImage(onResult: (String?) -> Unit) {
|
||||
AndroidUiHost.imagePickHandler = onResult
|
||||
val launcher = AndroidUiHost.launchImagePicker
|
||||
if (launcher == null) {
|
||||
onResult(null)
|
||||
showToast("图片选择暂不可用")
|
||||
return
|
||||
}
|
||||
launcher.invoke()
|
||||
}
|
||||
|
||||
actual fun openExternalUrl(url: String) {
|
||||
val act = AndroidUiHost.activity ?: return
|
||||
runCatching {
|
||||
act.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
}
|
||||
}
|
||||
|
||||
actual fun exitApp() {
|
||||
val act = AndroidUiHost.activity ?: return
|
||||
act.finishAffinity()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package top.zhushenwudi.llmp.player
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
|
||||
/**
|
||||
* Single ExoPlayer shared by [PlayerController] and [PlaybackService] MediaSession.
|
||||
*/
|
||||
object AndroidPlayerHolder {
|
||||
@Volatile
|
||||
private var player: ExoPlayer? = null
|
||||
|
||||
fun getOrCreate(context: Context): ExoPlayer {
|
||||
player?.let { return it }
|
||||
synchronized(this) {
|
||||
player?.let { return it }
|
||||
val created = ExoPlayer.Builder(context.applicationContext)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build(),
|
||||
/* handleAudioFocus = */ true,
|
||||
)
|
||||
// Pause when wired/BT headset is unplugged or disconnects.
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
player = created
|
||||
return created
|
||||
}
|
||||
}
|
||||
|
||||
fun get(): ExoPlayer? = player
|
||||
|
||||
fun release() {
|
||||
synchronized(this) {
|
||||
player?.release()
|
||||
player = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package top.zhushenwudi.llmp.player
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import top.zhushenwudi.llmp.domain.Music
|
||||
import top.zhushenwudi.llmp.platform.PlatformServices
|
||||
import java.io.File
|
||||
import kotlin.random.Random
|
||||
|
||||
actual class PlayerController(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
private val player: ExoPlayer = AndroidPlayerHolder.getOrCreate(appContext)
|
||||
private val scope = CoroutineScope(Dispatchers.Main.immediate)
|
||||
private var positionJob: Job? = null
|
||||
|
||||
private val _state = MutableStateFlow(PlayerState())
|
||||
actual val state: StateFlow<PlayerState> = _state.asStateFlow()
|
||||
|
||||
actual var resolvePlaybackUri: ((Music) -> String?)? = null
|
||||
actual var resolveCoverUri: ((Music) -> String?)? = null
|
||||
|
||||
private var queue: List<Music> = emptyList()
|
||||
|
||||
private fun playbackPath(music: Music): String? =
|
||||
resolvePlaybackUri?.invoke(music) ?: music.musicPath
|
||||
|
||||
init {
|
||||
player.addListener(
|
||||
object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
_state.update { it.copy(isPlaying = isPlaying) }
|
||||
if (isPlaying) startPositionUpdates() else positionJob?.cancel()
|
||||
syncPlatform(isPlaying = isPlaying)
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
durationMs = player.duration.coerceAtLeast(0),
|
||||
positionMs = player.currentPosition.coerceAtLeast(0),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
val idx = player.currentMediaItemIndex
|
||||
_state.update {
|
||||
it.copy(
|
||||
index = idx,
|
||||
current = queue.getOrNull(idx) ?: it.current,
|
||||
)
|
||||
}
|
||||
syncPlatform(isPlaying = player.isPlaying)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun startPositionUpdates() {
|
||||
positionJob?.cancel()
|
||||
positionJob = scope.launch {
|
||||
while (isActive) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
positionMs = player.currentPosition.coerceAtLeast(0),
|
||||
durationMs = player.duration.coerceAtLeast(0),
|
||||
)
|
||||
}
|
||||
delay(200)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncPlatform(isPlaying: Boolean) {
|
||||
val current = _state.value.current
|
||||
PlatformServices.mediaSession.updateSession(
|
||||
title = current?.musicName,
|
||||
artist = current?.artist,
|
||||
isPlaying = isPlaying,
|
||||
)
|
||||
PlatformServices.desktopLyric.setPlaying(isPlaying)
|
||||
PlatformServices.homeWidget.update(
|
||||
songName = current?.musicName,
|
||||
artist = current?.artist,
|
||||
isPlaying = isPlaying,
|
||||
favorite = current?.isLove == true,
|
||||
coverPath = current?.coverPath,
|
||||
)
|
||||
PlatformServices.carPlayCatalog.updateNowPlaying(current)
|
||||
}
|
||||
|
||||
private fun toUri(raw: String): Uri = when {
|
||||
raw.startsWith("http") || raw.startsWith("file") ||
|
||||
raw.startsWith("asset") || raw.startsWith("content") -> Uri.parse(raw)
|
||||
else -> Uri.fromFile(File(raw))
|
||||
}
|
||||
|
||||
private fun artworkUri(music: Music): Uri? {
|
||||
val raw = resolveCoverUri?.invoke(music) ?: return null
|
||||
if (raw.isBlank()) return null
|
||||
return toUri(raw)
|
||||
}
|
||||
|
||||
/** Media3 notification / session metadata (title, artist, album, artwork). */
|
||||
private fun toMediaItem(music: Music, playbackPath: String): MediaItem {
|
||||
val uri = toUri(playbackPath)
|
||||
val metadata = MediaMetadata.Builder()
|
||||
.setTitle(music.musicName ?: "未知歌曲")
|
||||
.setArtist(music.artist.orEmpty())
|
||||
.setAlbumTitle(music.albumName.orEmpty())
|
||||
.setArtworkUri(artworkUri(music))
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(music.musicId ?: uri.toString())
|
||||
.setUri(uri)
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
actual fun setQueue(items: List<Music>, startIndex: Int) {
|
||||
player.stop()
|
||||
player.clearMediaItems()
|
||||
val playable = items.mapNotNull { music ->
|
||||
val path = playbackPath(music) ?: return@mapNotNull null
|
||||
music to toMediaItem(music, path)
|
||||
}
|
||||
if (playable.isEmpty()) {
|
||||
queue = items
|
||||
_state.update {
|
||||
it.copy(queue = items, index = -1, current = null, isPlaying = false)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Prefer original startIndex among playable entries.
|
||||
val startMusic = items.getOrNull(startIndex)
|
||||
val mediaStart = playable.indexOfFirst { it.first.musicId == startMusic?.musicId }
|
||||
.takeIf { it >= 0 }
|
||||
?: startIndex.coerceIn(0, playable.lastIndex)
|
||||
queue = playable.map { it.first }
|
||||
val mediaItems = playable.map { it.second }
|
||||
player.setMediaItems(mediaItems, mediaStart.coerceIn(0, mediaItems.lastIndex), 0)
|
||||
player.prepare()
|
||||
_state.update {
|
||||
it.copy(
|
||||
queue = queue,
|
||||
index = mediaStart.coerceIn(0, queue.lastIndex),
|
||||
current = queue.getOrNull(mediaStart),
|
||||
)
|
||||
}
|
||||
syncPlatform(isPlaying = false)
|
||||
}
|
||||
|
||||
actual fun play() {
|
||||
player.play()
|
||||
}
|
||||
|
||||
actual fun pause() {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
actual fun toggle() {
|
||||
if (player.isPlaying) pause() else play()
|
||||
}
|
||||
|
||||
actual fun seekTo(positionMs: Long) {
|
||||
player.seekTo(positionMs)
|
||||
_state.update { it.copy(positionMs = positionMs) }
|
||||
}
|
||||
|
||||
actual fun next() {
|
||||
if (player.hasNextMediaItem()) player.seekToNextMediaItem()
|
||||
}
|
||||
|
||||
actual fun previous() {
|
||||
if (player.hasPreviousMediaItem()) player.seekToPreviousMediaItem()
|
||||
}
|
||||
|
||||
actual fun playAtIndex(index: Int) {
|
||||
if (index !in queue.indices) return
|
||||
val wasPlaying = player.isPlaying
|
||||
player.seekTo(index, 0L)
|
||||
_state.update {
|
||||
it.copy(
|
||||
index = index,
|
||||
current = queue[index],
|
||||
positionMs = 0,
|
||||
)
|
||||
}
|
||||
if (wasPlaying) player.play()
|
||||
syncPlatform(isPlaying = wasPlaying)
|
||||
}
|
||||
|
||||
actual fun setLoopMode(mode: LoopMode) {
|
||||
player.repeatMode = when (mode) {
|
||||
LoopMode.SINGLE -> Player.REPEAT_MODE_ONE
|
||||
LoopMode.LIST -> Player.REPEAT_MODE_ALL
|
||||
LoopMode.SHUFFLE -> Player.REPEAT_MODE_ALL
|
||||
}
|
||||
player.shuffleModeEnabled = mode == LoopMode.SHUFFLE
|
||||
_state.update { it.copy(loopMode = mode) }
|
||||
}
|
||||
|
||||
actual fun removeAt(index: Int) {
|
||||
if (index !in queue.indices) return
|
||||
if (queue.size == 1) {
|
||||
clearQueue()
|
||||
return
|
||||
}
|
||||
val wasPlaying = player.isPlaying
|
||||
val currentIndex = player.currentMediaItemIndex
|
||||
val removingCurrent = index == currentIndex
|
||||
val newQueue = queue.toMutableList().also { it.removeAt(index) }
|
||||
if (!removingCurrent) {
|
||||
player.removeMediaItem(index)
|
||||
queue = newQueue
|
||||
val newIdx = player.currentMediaItemIndex.coerceIn(0, newQueue.lastIndex)
|
||||
_state.update {
|
||||
it.copy(queue = newQueue, index = newIdx, current = newQueue.getOrNull(newIdx))
|
||||
}
|
||||
return
|
||||
}
|
||||
val nextIndex = when {
|
||||
_state.value.loopMode == LoopMode.SHUFFLE -> Random.nextInt(newQueue.size)
|
||||
index > newQueue.lastIndex -> 0
|
||||
else -> index
|
||||
}
|
||||
player.removeMediaItem(index)
|
||||
queue = newQueue
|
||||
val safe = nextIndex.coerceIn(0, newQueue.lastIndex)
|
||||
player.seekTo(safe, 0L)
|
||||
_state.update {
|
||||
it.copy(
|
||||
queue = newQueue,
|
||||
index = safe,
|
||||
current = newQueue.getOrNull(safe),
|
||||
positionMs = 0,
|
||||
)
|
||||
}
|
||||
if (wasPlaying) player.play()
|
||||
syncPlatform(isPlaying = wasPlaying)
|
||||
}
|
||||
|
||||
actual fun clearQueue() {
|
||||
positionJob?.cancel()
|
||||
player.stop()
|
||||
player.clearMediaItems()
|
||||
queue = emptyList()
|
||||
_state.update {
|
||||
it.copy(
|
||||
queue = emptyList(),
|
||||
index = -1,
|
||||
current = null,
|
||||
isPlaying = false,
|
||||
positionMs = 0,
|
||||
durationMs = 0,
|
||||
)
|
||||
}
|
||||
syncPlatform(isPlaying = false)
|
||||
}
|
||||
|
||||
actual fun playUri(uri: String, title: String?) {
|
||||
val parsed = toUri(uri)
|
||||
val music = Music(
|
||||
musicId = "local",
|
||||
musicName = title ?: parsed.lastPathSegment,
|
||||
musicPath = uri,
|
||||
)
|
||||
queue = listOf(music)
|
||||
player.setMediaItem(
|
||||
MediaItem.Builder()
|
||||
.setMediaId(music.musicId!!)
|
||||
.setUri(parsed)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(music.musicName)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
player.prepare()
|
||||
player.play()
|
||||
_state.update {
|
||||
it.copy(
|
||||
current = music,
|
||||
queue = queue,
|
||||
index = 0,
|
||||
isPlaying = true,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
syncPlatform(isPlaying = true)
|
||||
}
|
||||
|
||||
actual fun release() {
|
||||
positionJob?.cancel()
|
||||
// Shared ExoPlayer is released with PlaybackService / process teardown.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package top.zhushenwudi.llmp.settings
|
||||
|
||||
import android.content.Context
|
||||
import com.russhwolf.settings.Settings
|
||||
import com.russhwolf.settings.SharedPreferencesSettings
|
||||
|
||||
actual fun createSettings(): Settings {
|
||||
error("Use createAppSettings(context) on Android")
|
||||
}
|
||||
|
||||
fun createAppSettings(context: Context): AppSettings {
|
||||
val prefs = context.applicationContext
|
||||
.getSharedPreferences("llmp_settings", Context.MODE_PRIVATE)
|
||||
return AppSettings(SharedPreferencesSettings(prefs))
|
||||
}
|
||||
45
shared/src/commonMain/kotlin/top/zhushenwudi/llmp/Const.kt
Normal file
45
shared/src/commonMain/kotlin/top/zhushenwudi/llmp/Const.kt
Normal file
@@ -0,0 +1,45 @@
|
||||
package top.zhushenwudi.llmp
|
||||
|
||||
/**
|
||||
* App-wide constants ported from Flutter `lib/global/const.dart`.
|
||||
*/
|
||||
object Const {
|
||||
/** Dual-end transfer protocol version; must match PC Electron. */
|
||||
const val TRANS_VER: Int = 1
|
||||
|
||||
const val PACKAGE_ID: String = "top.zhushenwudi.llmp"
|
||||
|
||||
// Cloudflare R2 OSS
|
||||
const val R2_OSS_URL: String = "https://llmp-oss.zhushenwudi.top/"
|
||||
|
||||
// AliYun OSS
|
||||
const val ALI_OSS_URL: String =
|
||||
"https://zhushenwudi1.oss-cn-hangzhou.aliyuncs.com/LLMP-M/"
|
||||
|
||||
const val LYRIC_URL: String = "${R2_OSS_URL}lyric/"
|
||||
const val SPLASH_URL: String = "${R2_OSS_URL}LLMP-M/splash_bg/"
|
||||
const val SPLASH_CONFIG_URL: String = "${SPLASH_URL}splash_config.json"
|
||||
const val SHARE_DEFAULT_LOGO: String = "${ALI_OSS_URL}ic_launcher.png"
|
||||
const val MOE_GIRL_URL: String = "https://zh.moegirl.org.cn/"
|
||||
const val PUSH_URL: String = "${ALI_OSS_URL}push/push.txt"
|
||||
|
||||
const val BACKEND_URL: String =
|
||||
"https://netease-backend.zhushenwudi.top/song/detail"
|
||||
|
||||
/** WiFi transfer command WS port (PC). */
|
||||
const val WS_MUSIC_PORT: Int = 4388
|
||||
|
||||
/** Data sync command WS port (PC). */
|
||||
const val WS_DATA_PORT: Int = 4389
|
||||
|
||||
fun dataBaseUrl(env: String = "prod"): String =
|
||||
"${R2_OSS_URL}LLMP-M/data/v2/$env/"
|
||||
|
||||
fun dataUrl(env: String = "prod"): String = "${dataBaseUrl(env)}data.json"
|
||||
|
||||
fun artistModelUrl(env: String = "prod"): String =
|
||||
"${dataBaseUrl(env)}artist.json"
|
||||
|
||||
fun updateUrl(env: String = "prod"): String =
|
||||
"${dataBaseUrl(env)}version.json"
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package top.zhushenwudi.llmp.data
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import top.zhushenwudi.llmp.Const
|
||||
import top.zhushenwudi.llmp.db.LibraryRepository
|
||||
import top.zhushenwudi.llmp.domain.Album
|
||||
import top.zhushenwudi.llmp.domain.Artist
|
||||
import top.zhushenwudi.llmp.domain.ArtistModel
|
||||
import top.zhushenwudi.llmp.domain.CloudData
|
||||
import top.zhushenwudi.llmp.domain.Music
|
||||
import top.zhushenwudi.llmp.network.OssApi
|
||||
import top.zhushenwudi.llmp.settings.AppSettings
|
||||
import top.zhushenwudi.llmp.util.PathResolver
|
||||
|
||||
class DataImportService(
|
||||
private val oss: OssApi,
|
||||
private val library: LibraryRepository,
|
||||
private val settings: AppSettings,
|
||||
private val paths: PathResolver,
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
|
||||
data class ImportResult(
|
||||
val version: Int,
|
||||
val musicCount: Int,
|
||||
val albumCount: Int,
|
||||
val artistCount: Int,
|
||||
)
|
||||
|
||||
/** Remote data.json version, or null on network/parse failure. */
|
||||
suspend fun fetchRemoteVersion(): Int? = runCatching {
|
||||
val raw = oss.fetchDataJson()
|
||||
json.decodeFromString<CloudData>(raw).version
|
||||
}.getOrNull()
|
||||
|
||||
fun localVersion(): Int = settings.dataVersion.toIntOrNull() ?: 0
|
||||
|
||||
suspend fun fetchAndImport(force: Boolean = false): ImportResult {
|
||||
val raw = oss.fetchDataJson()
|
||||
val cloud = json.decodeFromString<CloudData>(raw)
|
||||
val localVer = settings.dataVersion.toIntOrNull() ?: 0
|
||||
if (!force && cloud.version > 0 && cloud.version <= localVer && library.countMusic() > 0) {
|
||||
return ImportResult(cloud.version, library.countMusic().toInt(), 0, 0)
|
||||
}
|
||||
|
||||
val artistsRaw = runCatching { oss.fetchArtistJson() }.getOrNull()
|
||||
val artistModels = artistsRaw?.let {
|
||||
json.decodeFromString<List<ArtistModel>>(it)
|
||||
}.orEmpty()
|
||||
val artistByName = artistModels.associateBy { it.k }
|
||||
|
||||
library.clearMusic()
|
||||
library.clearAlbums()
|
||||
library.clearArtists()
|
||||
|
||||
var musicCount = 0
|
||||
var albumCount = 0
|
||||
val artistMusic = mutableMapOf<String, MutableList<String>>()
|
||||
val seenAlbums = mutableSetOf<String>()
|
||||
|
||||
for ((groupName, albums) in cloud.album) {
|
||||
val albumByCloudId = albums.associateBy { it.id }
|
||||
val musics = cloud.music[groupName].orEmpty()
|
||||
for (m in musics) {
|
||||
// Mobile catalog: only export=true tracks are allowed on phone.
|
||||
if (!m.export) continue
|
||||
|
||||
val album = albumByCloudId[m.album] ?: continue
|
||||
val domain = paths.refreshExistFile(
|
||||
Music(
|
||||
musicId = m.uid,
|
||||
musicName = m.name,
|
||||
artist = m.artist,
|
||||
artistBin = m.artistBin,
|
||||
albumId = album.uid,
|
||||
albumName = m.albumName ?: album.name,
|
||||
coverPath = m.coverPath,
|
||||
musicPath = m.musicPath,
|
||||
time = m.time,
|
||||
baseUrl = m.baseUrl,
|
||||
category = album.category,
|
||||
group = groupName,
|
||||
neteaseId = m.neteaseId,
|
||||
date = album.date,
|
||||
existFile = false,
|
||||
),
|
||||
)
|
||||
library.upsertMusic(domain)
|
||||
musicCount++
|
||||
|
||||
// Insert / upgrade album only for exportable tracks (Flutter importMusic).
|
||||
val firstForAlbum = album.uid !in seenAlbums
|
||||
if (firstForAlbum) {
|
||||
seenAlbums += album.uid
|
||||
albumCount++
|
||||
library.upsertAlbum(
|
||||
Album(
|
||||
albumId = album.uid,
|
||||
albumName = album.name,
|
||||
date = album.date,
|
||||
coverPath = album.coverPath.firstOrNull(),
|
||||
category = album.category,
|
||||
group = groupName,
|
||||
existFile = domain.existFile == true,
|
||||
),
|
||||
)
|
||||
} else if (domain.existFile == true) {
|
||||
library.upsertAlbum(
|
||||
Album(
|
||||
albumId = album.uid,
|
||||
albumName = album.name,
|
||||
date = album.date,
|
||||
coverPath = album.coverPath.firstOrNull(),
|
||||
category = album.category,
|
||||
group = groupName,
|
||||
existFile = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Link artists by display-name split (simplified vs Flutter bitmask).
|
||||
m.artist.split('/', '/')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.forEach { name ->
|
||||
val model = artistByName[name]
|
||||
val uid = model?.v ?: "name:$name"
|
||||
artistMusic.getOrPut(uid) { mutableListOf() }.add(m.uid)
|
||||
if (library.getArtist(uid) == null) {
|
||||
val photo = if (model != null) {
|
||||
"${Const.R2_OSS_URL}LLMP-M/artist_webp/${model.v}.webp"
|
||||
} else ""
|
||||
library.upsertArtist(
|
||||
Artist(
|
||||
uid = uid,
|
||||
name = name,
|
||||
photo = photo,
|
||||
group = groupName,
|
||||
music = listOf(m.uid),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
artistMusic.forEach { (uid, ids) ->
|
||||
val existing = library.getArtist(uid) ?: return@forEach
|
||||
library.upsertArtist(existing.copy(music = ids.distinct()))
|
||||
}
|
||||
|
||||
if (cloud.version > 0) {
|
||||
settings.dataVersion = cloud.version.toString()
|
||||
}
|
||||
return ImportResult(cloud.version, musicCount, albumCount, library.getAllArtists().size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package top.zhushenwudi.llmp.db
|
||||
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
|
||||
expect class DriverFactory {
|
||||
fun createDriver(): SqlDriver
|
||||
}
|
||||
|
||||
fun createDatabase(driverFactory: DriverFactory): LlmpDatabase {
|
||||
val driver = driverFactory.createDriver()
|
||||
return LlmpDatabase(driver)
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package top.zhushenwudi.llmp.db
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import top.zhushenwudi.llmp.domain.Album
|
||||
import top.zhushenwudi.llmp.domain.Artist
|
||||
import top.zhushenwudi.llmp.domain.Love
|
||||
import top.zhushenwudi.llmp.domain.Lyric
|
||||
import top.zhushenwudi.llmp.domain.Menu
|
||||
import top.zhushenwudi.llmp.domain.Music
|
||||
import top.zhushenwudi.llmp.util.PathResolver
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
data class LibraryLists(
|
||||
val music: List<Music>,
|
||||
val albums: List<Album>,
|
||||
val artists: List<Artist>,
|
||||
val loved: List<Music>,
|
||||
val recent: List<Music>,
|
||||
val menus: List<Menu>,
|
||||
)
|
||||
|
||||
class LibraryRepository(private val db: LlmpDatabase) {
|
||||
private val q get() = db.llmpDatabaseQueries
|
||||
|
||||
fun getAllMusic(): List<Music> =
|
||||
q.selectAllMusic().executeAsList().map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Flutter [DBLogic.findAllListByGroup]: group filter + local-only when HTTP off.
|
||||
* Music/albums ordered by release [Music.date] / [Album.date] (then id), matching DAO ASC/DESC.
|
||||
*/
|
||||
fun findAllListByGroup(
|
||||
group: String,
|
||||
enableHttp: Boolean,
|
||||
sortAsc: Boolean = true,
|
||||
): LibraryLists {
|
||||
val allGroup = group == "all"
|
||||
fun Music.matches(): Boolean =
|
||||
(allGroup || this.group == group) && (enableHttp || existFile == true)
|
||||
|
||||
val musicComparator = compareBy<Music>({ it.date.orEmpty() }, { it.musicId.orEmpty() })
|
||||
val albumComparator = compareBy<Album>({ it.date.orEmpty() }, { it.albumId.orEmpty() })
|
||||
|
||||
val music = getAllMusic().filter { it.matches() }.let { list ->
|
||||
if (sortAsc) list.sortedWith(musicComparator) else list.sortedWith(musicComparator.reversed())
|
||||
}
|
||||
// Visible songs already satisfy: export (import-time) + (HTTP or local existFile).
|
||||
val musicIds = music.mapNotNull { it.musicId }.toSet()
|
||||
val visibleAlbumIds = music.mapNotNull { it.albumId }.toSet()
|
||||
val albums = getAllAlbums().filter { a ->
|
||||
(allGroup || a.group == group) &&
|
||||
(enableHttp || a.existFile == true || a.albumId in visibleAlbumIds)
|
||||
}.let { list ->
|
||||
if (sortAsc) list.sortedWith(albumComparator) else list.sortedWith(albumComparator.reversed())
|
||||
}
|
||||
// Artists: keep only songs that appear in the songs tab; hide if none left.
|
||||
val artistsRaw = getAllArtists()
|
||||
.filter { a -> allGroup || a.group == group }
|
||||
.sortedWith(artistComparator)
|
||||
val artistsMerged = if (allGroup) mergeArtists(artistsRaw) else artistsRaw
|
||||
val artists = artistsMerged
|
||||
.map { a -> a.copy(music = a.music.filter { id -> id in musicIds }.distinct()) }
|
||||
.filter { it.music.isNotEmpty() }
|
||||
val loved = getLovedMusic().filter { it.matches() }.let { list ->
|
||||
// SQL is timestamp DESC; Flutter LoveDaoExt uses ASC/DESC with sortMode.
|
||||
if (sortAsc) list.asReversed() else list
|
||||
}
|
||||
// Flutter recent is intersected with the already-filtered music list.
|
||||
// selectAllHistory is DESC; Flutter HistoryDaoExt uses ASC/DESC with sortMode.
|
||||
val recent = getHistoryMusic().let { hist ->
|
||||
if (sortAsc) hist.asReversed() else hist
|
||||
}.filter { m ->
|
||||
m.musicId in musicIds && (allGroup || m.group == group)
|
||||
}
|
||||
return LibraryLists(
|
||||
music = music,
|
||||
albums = albums,
|
||||
artists = artists,
|
||||
loved = loved,
|
||||
recent = recent,
|
||||
menus = getAllMenus(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun mergeArtists(artists: List<Artist>): List<Artist> {
|
||||
// Preserve encounter order (Flutter Map insertion order after sortList).
|
||||
val merged = linkedMapOf<String, Artist>()
|
||||
for (artist in artists) {
|
||||
val existing = merged[artist.uid]
|
||||
if (existing == null) {
|
||||
merged[artist.uid] = artist.copy(music = artist.music.toList())
|
||||
} else {
|
||||
merged[artist.uid] = existing.copy(
|
||||
music = (existing.music + artist.music).distinct(),
|
||||
)
|
||||
}
|
||||
}
|
||||
return merged.values.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Flutter [Artist.compare] / [ArtistModelExtension.sortList].
|
||||
* Order: non-U first-digit → bit-count of uid[1..] (base36) → binary-as-decimal;
|
||||
* uid starting with `U` sorted last.
|
||||
*/
|
||||
private val artistComparator = Comparator<Artist> { a, b ->
|
||||
compareArtistUid(a.uid, b.uid)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun compareArtistUid(aUid: String, bUid: String): Int {
|
||||
if (aUid.isEmpty() && bUid.isEmpty()) return 0
|
||||
if (aUid.isEmpty()) return -1
|
||||
if (bUid.isEmpty()) return 1
|
||||
val a0 = aUid[0]
|
||||
val b0 = bUid[0]
|
||||
if (a0 == 'U') return 1
|
||||
if (b0 == 'U') return -1
|
||||
val aDigit = a0.digitToIntOrNull()
|
||||
val bDigit = b0.digitToIntOrNull()
|
||||
if (aDigit == null || bDigit == null) return aUid.compareTo(bUid)
|
||||
if (aDigit != bDigit) return aDigit - bDigit
|
||||
|
||||
val aBin = aUid.substring(1).toLongOrNull(radix = 36) ?: 0L
|
||||
val bBin = bUid.substring(1).toLongOrNull(radix = 36) ?: 0L
|
||||
val aOnes = aBin.countOneBits()
|
||||
val bOnes = bBin.countOneBits()
|
||||
if (aOnes != bOnes) return aOnes - bOnes
|
||||
|
||||
// Flutter: int.parse(n.toRadixString(2)) — binary digits re-read as decimal
|
||||
val aDec = aBin.toString(2).toLongOrNull() ?: 0L
|
||||
val bDec = bBin.toString(2).toLongOrNull() ?: 0L
|
||||
return aDec.compareTo(bDec)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-check local files and update [Music.existFile] / [Album.existFile].
|
||||
* Needed after storage-root fixes or SD path changes without full re-import.
|
||||
*/
|
||||
fun rescanExistFiles(paths: PathResolver): Int {
|
||||
var changed = 0
|
||||
val albumHasFile = mutableMapOf<String, Boolean>()
|
||||
getAllMusic().forEach { music ->
|
||||
val updated = paths.refreshExistFile(music)
|
||||
if (updated.existFile != music.existFile) {
|
||||
upsertMusic(updated)
|
||||
changed++
|
||||
}
|
||||
val albumId = updated.albumId ?: return@forEach
|
||||
if (updated.existFile == true) {
|
||||
albumHasFile[albumId] = true
|
||||
} else {
|
||||
albumHasFile.putIfAbsent(albumId, false)
|
||||
}
|
||||
}
|
||||
getAllAlbums().forEach { album ->
|
||||
val id = album.albumId ?: return@forEach
|
||||
val shouldExist = albumHasFile[id] == true
|
||||
if (album.existFile != shouldExist) {
|
||||
upsertAlbum(album.copy(existFile = shouldExist))
|
||||
changed++
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
fun getMusicById(id: String): Music? =
|
||||
q.selectMusicById(id).executeAsOneOrNull()?.toDomain()
|
||||
|
||||
fun getMusicByAlbum(albumId: String): List<Music> =
|
||||
q.selectMusicByAlbum(albumId).executeAsList().map { it.toDomain() }
|
||||
|
||||
fun getLovedMusic(): List<Music> =
|
||||
q.selectLovedMusic().executeAsList().map { it.toDomain() }
|
||||
|
||||
fun getHistoryMusic(): List<Music> =
|
||||
q.selectAllHistory().executeAsList().mapNotNull { h ->
|
||||
getMusicById(h.musicId)?.copy(timestamp = h.timestamp)
|
||||
}
|
||||
|
||||
fun upsertMusic(music: Music) {
|
||||
val id = music.musicId ?: return
|
||||
q.insertMusic(
|
||||
musicId = id,
|
||||
musicName = music.musicName,
|
||||
artist = music.artist,
|
||||
artistBin = music.artistBin,
|
||||
albumId = music.albumId,
|
||||
albumName = music.albumName,
|
||||
coverPath = music.coverPath,
|
||||
musicPath = music.musicPath,
|
||||
time = music.time,
|
||||
baseUrl = music.baseUrl,
|
||||
category = music.category,
|
||||
groupName = music.group,
|
||||
isLove = if (music.isLove) 1L else 0L,
|
||||
timestamp = music.timestamp,
|
||||
neteaseId = music.neteaseId,
|
||||
date = music.date,
|
||||
existFile = if (music.existFile == true) 1L else 0L,
|
||||
)
|
||||
}
|
||||
|
||||
fun clearMusic() = q.deleteAllMusic()
|
||||
|
||||
fun countMusic(): Long = q.countMusic().executeAsOne()
|
||||
|
||||
fun getAllAlbums(): List<Album> =
|
||||
q.selectAllAlbums().executeAsList().map {
|
||||
Album(
|
||||
albumId = it.albumId,
|
||||
albumName = it.albumName,
|
||||
date = it.date,
|
||||
coverPath = it.coverPath,
|
||||
category = it.category,
|
||||
group = it.groupName,
|
||||
existFile = (it.existFile ?: 0L) != 0L,
|
||||
)
|
||||
}
|
||||
|
||||
fun getAlbum(albumId: String): Album? =
|
||||
q.selectAlbumById(albumId).executeAsOneOrNull()?.let {
|
||||
Album(
|
||||
albumId = it.albumId,
|
||||
albumName = it.albumName,
|
||||
date = it.date,
|
||||
coverPath = it.coverPath,
|
||||
category = it.category,
|
||||
group = it.groupName,
|
||||
existFile = (it.existFile ?: 0L) != 0L,
|
||||
)
|
||||
}
|
||||
|
||||
fun upsertAlbum(album: Album) {
|
||||
val id = album.albumId ?: return
|
||||
q.insertAlbum(
|
||||
albumId = id,
|
||||
albumName = album.albumName,
|
||||
date = album.date,
|
||||
coverPath = album.coverPath,
|
||||
category = album.category,
|
||||
groupName = album.group,
|
||||
existFile = if (album.existFile == true) 1L else 0L,
|
||||
)
|
||||
}
|
||||
|
||||
fun clearAlbums() = q.deleteAllAlbums()
|
||||
|
||||
fun getAllArtists(): List<Artist> =
|
||||
q.selectAllArtists().executeAsList().map { it.toDomain() }
|
||||
|
||||
fun getArtist(uid: String): Artist? =
|
||||
q.selectArtistByUid(uid).executeAsOneOrNull()?.toDomain()
|
||||
|
||||
fun upsertArtist(artist: Artist) {
|
||||
val existing = q.selectArtistByUid(artist.uid).executeAsOneOrNull()
|
||||
val musicJson = json.encodeToString(artist.music)
|
||||
if (existing == null) {
|
||||
q.insertArtist(artist.uid, artist.name, artist.photo, artist.group, musicJson)
|
||||
} else {
|
||||
q.updateArtistMusic(musicJson, artist.uid)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearArtists() = q.deleteAllArtists()
|
||||
|
||||
fun getAllMenus(): List<Menu> =
|
||||
q.selectAllMenus().executeAsList().map { it.toDomain() }
|
||||
|
||||
fun getMenu(id: Long): Menu? =
|
||||
q.selectMenuById(id).executeAsOneOrNull()?.toDomain()
|
||||
|
||||
fun upsertMenu(menu: Menu) {
|
||||
q.insertMenu(
|
||||
id = menu.id,
|
||||
isPhone = if (menu.isPhone) 1L else 0L,
|
||||
musicJson = json.encodeToString(menu.music),
|
||||
date = menu.date,
|
||||
name = menu.name,
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteMenu(id: Long) = q.deleteMenuById(id)
|
||||
|
||||
fun deletePcMenus() = q.deletePcMenus()
|
||||
|
||||
fun clearMenus() = q.deleteAllMenus()
|
||||
|
||||
fun getAllLove(): List<Love> =
|
||||
q.selectAllLove().executeAsList().map {
|
||||
Love(id = it.id, musicId = it.musicId, timestamp = it.timestamp)
|
||||
}
|
||||
|
||||
fun toggleLove(musicId: String): Boolean {
|
||||
val loved = getAllLove().any { it.musicId == musicId }
|
||||
if (loved) {
|
||||
q.deleteLoveByMusicId(musicId)
|
||||
q.updateMusicLove(0L, musicId)
|
||||
return false
|
||||
}
|
||||
q.insertLove(musicId, currentTimeMs())
|
||||
q.updateMusicLove(1L, musicId)
|
||||
return true
|
||||
}
|
||||
|
||||
fun replaceLove(list: List<Love>) {
|
||||
q.deleteAllLove()
|
||||
q.selectAllMusic().executeAsList().forEach {
|
||||
q.updateMusicLove(0L, it.musicId)
|
||||
}
|
||||
list.forEach { love ->
|
||||
q.insertLove(love.musicId, love.timestamp)
|
||||
q.updateMusicLove(1L, love.musicId)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearLove() = q.deleteAllLove()
|
||||
|
||||
fun addHistory(musicId: String) {
|
||||
q.insertHistory(musicId, currentTimeMs())
|
||||
}
|
||||
|
||||
fun clearHistory() = q.deleteAllHistory()
|
||||
|
||||
fun getLyric(uid: String): Lyric? =
|
||||
q.selectLyricByUid(uid).executeAsOneOrNull()?.let {
|
||||
Lyric(uid = it.uid, jp = it.jp, zh = it.zh, roma = it.roma)
|
||||
}
|
||||
|
||||
/** Flutter lyricDao.insertLyric — first insert for uid. */
|
||||
fun insertLyricNew(lyric: Lyric) {
|
||||
val uid = lyric.uid ?: return
|
||||
q.insertLyricNew(uid, lyric.jp, lyric.zh, lyric.roma)
|
||||
}
|
||||
|
||||
/** Flutter lyricDao.updateLrc. */
|
||||
fun updateLyric(lyric: Lyric) {
|
||||
val uid = lyric.uid ?: return
|
||||
q.updateLyricRow(lyric.jp, lyric.zh, lyric.roma, uid)
|
||||
}
|
||||
|
||||
fun upsertLyric(lyric: Lyric) {
|
||||
val uid = lyric.uid ?: return
|
||||
q.insertLyric(uid, lyric.jp, lyric.zh, lyric.roma)
|
||||
}
|
||||
|
||||
fun getPlayListIds(): List<String> =
|
||||
q.selectPlayList().executeAsList().map { it.musicId }
|
||||
|
||||
/** Flutter playListMusicDao.findAllPlayListMusics — ordered rows. */
|
||||
fun loadPlayListRows(): List<PlayListMusic> =
|
||||
q.selectPlayList().executeAsList()
|
||||
|
||||
/**
|
||||
* Flutter [DBLogic.findMusicByMusicIds]: keep [ids] order;
|
||||
* skip missing / non-local when HTTP is off.
|
||||
*/
|
||||
fun getMusicByIdsPreservingOrder(ids: List<String>, enableHttp: Boolean): List<Music> =
|
||||
ids.mapNotNull { id ->
|
||||
val music = getMusicById(id) ?: return@mapNotNull null
|
||||
if (!enableHttp && music.existFile != true) null else music
|
||||
}
|
||||
|
||||
fun savePlayList(items: List<Music>, playingId: String?) {
|
||||
q.deletePlayList()
|
||||
items.forEachIndexed { index, music ->
|
||||
val id = music.musicId ?: return@forEachIndexed
|
||||
q.insertPlayListItem(
|
||||
musicId = id,
|
||||
musicName = music.musicName,
|
||||
artist = music.artist,
|
||||
isPlaying = if (id == playingId) 1L else 0L,
|
||||
sortOrder = index.toLong(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearPlayList() = q.deletePlayList()
|
||||
|
||||
fun nextPhoneMenuId(): Long {
|
||||
val used = getAllMenus().map { it.id }.filter { it > 100 }.toSet()
|
||||
for (id in 101L..200L) {
|
||||
if (id !in used) return id
|
||||
}
|
||||
return 101L
|
||||
}
|
||||
|
||||
private fun top.zhushenwudi.llmp.db.Music.toDomain() = Music(
|
||||
musicId = musicId,
|
||||
musicName = musicName,
|
||||
artist = artist,
|
||||
artistBin = artistBin,
|
||||
albumId = albumId,
|
||||
albumName = albumName,
|
||||
coverPath = coverPath,
|
||||
musicPath = musicPath,
|
||||
time = time,
|
||||
baseUrl = baseUrl,
|
||||
category = category,
|
||||
group = groupName,
|
||||
isLove = isLove != 0L,
|
||||
timestamp = timestamp,
|
||||
neteaseId = neteaseId,
|
||||
date = date,
|
||||
existFile = (existFile ?: 0L) != 0L,
|
||||
)
|
||||
|
||||
private fun top.zhushenwudi.llmp.db.Artist.toDomain() = Artist(
|
||||
id = id,
|
||||
uid = uid,
|
||||
name = name,
|
||||
photo = photo,
|
||||
group = groupName,
|
||||
music = runCatching { json.decodeFromString<List<String>>(musicJson) }
|
||||
.getOrDefault(emptyList()),
|
||||
)
|
||||
|
||||
private fun top.zhushenwudi.llmp.db.Menu.toDomain() = Menu(
|
||||
id = id,
|
||||
isPhone = isPhone != 0L,
|
||||
music = runCatching { json.decodeFromString<List<String>>(musicJson) }
|
||||
.getOrDefault(emptyList()),
|
||||
date = date,
|
||||
name = name,
|
||||
)
|
||||
}
|
||||
|
||||
expect fun currentTimeMs(): Long
|
||||
@@ -0,0 +1,57 @@
|
||||
package top.zhushenwudi.llmp.db
|
||||
|
||||
import top.zhushenwudi.llmp.domain.Music as DomainMusic
|
||||
|
||||
class MusicRepository(private val db: LlmpDatabase) {
|
||||
fun getAll(): List<DomainMusic> =
|
||||
db.llmpDatabaseQueries.selectAllMusic().executeAsList().map { row ->
|
||||
DomainMusic(
|
||||
musicId = row.musicId,
|
||||
musicName = row.musicName,
|
||||
artist = row.artist,
|
||||
artistBin = row.artistBin,
|
||||
albumId = row.albumId,
|
||||
albumName = row.albumName,
|
||||
coverPath = row.coverPath,
|
||||
musicPath = row.musicPath,
|
||||
time = row.time,
|
||||
baseUrl = row.baseUrl,
|
||||
category = row.category,
|
||||
group = row.groupName,
|
||||
isLove = row.isLove != 0L,
|
||||
timestamp = row.timestamp,
|
||||
neteaseId = row.neteaseId,
|
||||
date = row.date,
|
||||
existFile = (row.existFile ?: 0L) != 0L,
|
||||
)
|
||||
}
|
||||
|
||||
fun count(): Long = db.llmpDatabaseQueries.countMusic().executeAsOne()
|
||||
|
||||
fun upsert(music: DomainMusic) {
|
||||
val id = music.musicId ?: return
|
||||
db.llmpDatabaseQueries.insertMusic(
|
||||
musicId = id,
|
||||
musicName = music.musicName,
|
||||
artist = music.artist,
|
||||
artistBin = music.artistBin,
|
||||
albumId = music.albumId,
|
||||
albumName = music.albumName,
|
||||
coverPath = music.coverPath,
|
||||
musicPath = music.musicPath,
|
||||
time = music.time,
|
||||
baseUrl = music.baseUrl,
|
||||
category = music.category,
|
||||
groupName = music.group,
|
||||
isLove = if (music.isLove) 1L else 0L,
|
||||
timestamp = music.timestamp,
|
||||
neteaseId = music.neteaseId,
|
||||
date = music.date,
|
||||
existFile = if (music.existFile == true) 1L else 0L,
|
||||
)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
db.llmpDatabaseQueries.deleteAllMusic()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package top.zhushenwudi.llmp.domain
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CloudData(
|
||||
val version: Int = 0,
|
||||
val album: Map<String, List<InnerAlbum>> = emptyMap(),
|
||||
val music: Map<String, List<InnerMusic>> = emptyMap(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class InnerAlbum(
|
||||
@SerialName("_id") val uid: String = "",
|
||||
val id: Int = 0,
|
||||
val name: String = "",
|
||||
val date: String = "",
|
||||
@SerialName("cover_path") val coverPath: List<String> = emptyList(),
|
||||
val category: String = "",
|
||||
val music: List<Int> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class InnerMusic(
|
||||
@SerialName("_id") val uid: String = "",
|
||||
val id: Int = 0,
|
||||
val name: String = "",
|
||||
val album: Int = 0,
|
||||
@SerialName("cover_path") val coverPath: String = "",
|
||||
@SerialName("music_path") val musicPath: String = "",
|
||||
val artist: String = "",
|
||||
@SerialName("artist_bin") val artistBin: String = "",
|
||||
val time: String = "",
|
||||
val albumName: String? = null,
|
||||
val export: Boolean = true,
|
||||
@SerialName("base_url") val baseUrl: String = "",
|
||||
val neteaseId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistModel(
|
||||
val k: String = "",
|
||||
val v: String = "",
|
||||
val m: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VersionInfo(
|
||||
val version: String = "",
|
||||
val versionCode: Int = 0,
|
||||
val force: Boolean = false,
|
||||
val changelog: String = "",
|
||||
val apkUrl: String = "",
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
package top.zhushenwudi.llmp.domain
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Idol group keys aligned with Flutter `GroupKey`.
|
||||
*/
|
||||
@Serializable
|
||||
enum class GroupKey(val displayName: String) {
|
||||
GROUP_ALL("all"),
|
||||
GROUP_US("μ's"),
|
||||
GROUP_AQOURS("Aqours"),
|
||||
GROUP_NIJIGASAKI("Nijigasaki"),
|
||||
GROUP_LIELLA("Liella!"),
|
||||
GROUP_HASUNOSORA("Hasunosora"),
|
||||
GROUP_YOHANE("Yohane"),
|
||||
GROUP_MUSICAL("Musical"),
|
||||
GROUP_BLUEBIRD("BlueBird"),
|
||||
GROUP_COMBINE("Combine");
|
||||
|
||||
companion object {
|
||||
fun fromName(name: String): GroupKey =
|
||||
entries.firstOrNull { it.displayName == name } ?: GROUP_ALL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package top.zhushenwudi.llmp.domain
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Music(
|
||||
val musicId: String? = null,
|
||||
val musicName: String? = null,
|
||||
val artist: String? = null,
|
||||
val artistBin: String? = null,
|
||||
val albumId: String? = null,
|
||||
val albumName: String? = null,
|
||||
val coverPath: String? = null,
|
||||
val musicPath: String? = null,
|
||||
val time: String? = null,
|
||||
val baseUrl: String? = null,
|
||||
val category: String? = null,
|
||||
val group: String? = null,
|
||||
val isLove: Boolean = false,
|
||||
val timestamp: Long = 0,
|
||||
val neteaseId: String? = null,
|
||||
val date: String? = null,
|
||||
val existFile: Boolean? = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Album(
|
||||
val albumId: String? = null,
|
||||
val albumName: String? = null,
|
||||
val date: String? = null,
|
||||
val coverPath: String? = null,
|
||||
val category: String? = null,
|
||||
val group: String? = null,
|
||||
val existFile: Boolean? = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Artist(
|
||||
val id: Long? = null,
|
||||
val uid: String,
|
||||
val name: String,
|
||||
val photo: String,
|
||||
val group: String,
|
||||
val music: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Menu(
|
||||
val id: Long,
|
||||
val isPhone: Boolean = true,
|
||||
val music: List<String> = emptyList(),
|
||||
val date: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Love(
|
||||
val id: Long? = null,
|
||||
val musicId: String,
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class History(
|
||||
val musicId: String,
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Lyric(
|
||||
val uid: String? = null,
|
||||
val jp: String? = null,
|
||||
val zh: String? = null,
|
||||
val roma: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FtpCmd(
|
||||
val cmd: String,
|
||||
val body: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DownloadMusic(
|
||||
val albumUId: String,
|
||||
val albumId: Int,
|
||||
val albumName: String,
|
||||
val coverPath: String,
|
||||
val date: String,
|
||||
val category: String,
|
||||
val group: String,
|
||||
val musicUId: String,
|
||||
val musicId: Int,
|
||||
val musicName: String,
|
||||
val musicPath: String,
|
||||
val artist: String,
|
||||
val artistBin: String,
|
||||
val totalTime: String,
|
||||
val baseUrl: String,
|
||||
val neteaseId: String? = null,
|
||||
val existFile: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TransData(
|
||||
val love: List<Love>,
|
||||
val menu: List<TransMenu>,
|
||||
val isCover: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TransMenu(
|
||||
val menuId: Int,
|
||||
val name: String,
|
||||
val date: String,
|
||||
val musicList: List<String>,
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
package top.zhushenwudi.llmp.lyric
|
||||
|
||||
data class LyricLine(
|
||||
val timeMs: Long,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
object LrcParser {
|
||||
private val lineRegex =
|
||||
Regex("""\[(\d{1,2}):(\d{1,2})(?:[.:](\d{1,3}))?](.*)""")
|
||||
|
||||
fun parse(lrc: String?): List<LyricLine> {
|
||||
if (lrc.isNullOrBlank()) return emptyList()
|
||||
val lines = mutableListOf<LyricLine>()
|
||||
for (raw in lrc.lineSequence()) {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) continue
|
||||
val match = lineRegex.find(trimmed) ?: continue
|
||||
val min = match.groupValues[1].toLongOrNull() ?: continue
|
||||
val sec = match.groupValues[2].toLongOrNull() ?: continue
|
||||
val frac = match.groupValues[3]
|
||||
val millis = when {
|
||||
frac.isEmpty() -> 0L
|
||||
frac.length == 1 -> frac.toLong() * 100
|
||||
frac.length == 2 -> frac.toLong() * 10
|
||||
else -> frac.take(3).padEnd(3, '0').toLong()
|
||||
}
|
||||
val text = match.groupValues[4].trim()
|
||||
if (text.isEmpty()) continue
|
||||
lines += LyricLine(timeMs = min * 60_000 + sec * 1_000 + millis, text = text)
|
||||
}
|
||||
return lines.sortedBy { it.timeMs }
|
||||
}
|
||||
|
||||
fun currentIndex(lines: List<LyricLine>, positionMs: Long): Int {
|
||||
if (lines.isEmpty()) return -1
|
||||
var idx = -1
|
||||
for (i in lines.indices) {
|
||||
if (lines[i].timeMs <= positionMs) idx = i else break
|
||||
}
|
||||
return idx
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package top.zhushenwudi.llmp.lyric
|
||||
|
||||
import top.zhushenwudi.llmp.db.LibraryRepository
|
||||
import top.zhushenwudi.llmp.domain.Lyric
|
||||
import top.zhushenwudi.llmp.domain.Music
|
||||
import top.zhushenwudi.llmp.network.OssApi
|
||||
import top.zhushenwudi.llmp.platform.AppLog
|
||||
|
||||
/**
|
||||
* Port of Flutter [LyricLogic]: getLrc + handleLRC (DB secondary cache).
|
||||
*
|
||||
* Cache semantics (Flutter comments):
|
||||
* - row missing → storageLrc = null → fetch → insertLyric
|
||||
* - row exists, field null/"" → storageLrc = "" → fetch → updateLrc
|
||||
* - row exists, field non-empty → return DB (unless forceRefresh)
|
||||
*/
|
||||
class LyricService(
|
||||
private val oss: OssApi,
|
||||
private val library: LibraryRepository,
|
||||
) {
|
||||
/** Flutter: SDUtils.allowEULA ? 0 : 1 */
|
||||
var lrcType: Int = if (PlatformInfo.allowEulaLyric) 0 else 1
|
||||
|
||||
/** Last loaded full lyric row (Flutter LyricLogic.fullLrc). */
|
||||
var fullLrc: Lyric = Lyric()
|
||||
private set
|
||||
|
||||
/**
|
||||
* Flutter [LyricLogic.getLrc].
|
||||
* Uses playing music's relative [Music.baseUrl] + [Music.musicPath].
|
||||
*/
|
||||
suspend fun getLrc(music: Music, forceRefresh: Boolean = false): LoadedLyric {
|
||||
val uid = music.musicId ?: return LoadedLyric().also { fullLrc = Lyric() }
|
||||
// Prefer DB music so relative paths survive playback URI resolution.
|
||||
val source = library.getMusicById(uid) ?: music
|
||||
val baseUrl = source.baseUrl
|
||||
val musicPath = source.musicPath
|
||||
if (baseUrl.isNullOrBlank() || musicPath.isNullOrBlank()) {
|
||||
return LoadedLyric().also { fullLrc = Lyric(uid = uid) }
|
||||
}
|
||||
// Reject absolute / playback URIs — same as needing relative cloud keys.
|
||||
if (musicPath.contains("://") || musicPath.startsWith("/")) {
|
||||
return LoadedLyric().also { fullLrc = Lyric(uid = uid) }
|
||||
}
|
||||
|
||||
// Flutter: musicPath.replaceAll("flac","lrc").replaceAll("wav","lrc")
|
||||
val lyricPath = musicPath
|
||||
.replace("flac", "lrc")
|
||||
.replace("wav", "lrc")
|
||||
|
||||
var jpLrc = ""
|
||||
var zhLrc = ""
|
||||
var romaLrc = ""
|
||||
|
||||
// Flutter always loads ZH first
|
||||
val zh = handleLrc("zh", "ZH/$baseUrl$lyricPath", uid, forceRefresh)
|
||||
if (zh != null) zhLrc = zh
|
||||
|
||||
if (PlatformInfo.allowEulaLyric) {
|
||||
val jp = handleLrc("jp", "JP/$baseUrl$lyricPath", uid, forceRefresh)
|
||||
if (jp != null) jpLrc = jp
|
||||
val roma = handleLrc("roma", "ROMA/$baseUrl$lyricPath", uid, forceRefresh)
|
||||
if (roma != null) romaLrc = roma
|
||||
}
|
||||
|
||||
fullLrc = Lyric(uid = uid, jp = jpLrc, zh = zhLrc, roma = romaLrc)
|
||||
return toLoaded(fullLrc)
|
||||
}
|
||||
|
||||
/** @deprecated Use [getLrc] — kept for call-site migration. */
|
||||
suspend fun loadFor(music: Music, forceRefresh: Boolean = false): LoadedLyric =
|
||||
getLrc(music, forceRefresh)
|
||||
|
||||
/**
|
||||
* Flutter [LyricLogic.handleLRC] — per-language DB cache then network.
|
||||
* @return lyric text, or null on failure / missing
|
||||
*/
|
||||
private suspend fun handleLrc(
|
||||
type: String,
|
||||
lrcUrl: String,
|
||||
uid: String,
|
||||
forceRefresh: Boolean,
|
||||
): String? {
|
||||
if (lrcUrl.isEmpty() || uid.isEmpty()) return null
|
||||
|
||||
var row = library.getLyric(uid)
|
||||
// Flutter: null = never inserted; "" = inserted but empty
|
||||
val storageLrc: String? = when {
|
||||
row == null -> null
|
||||
type == "jp" -> row.jp ?: ""
|
||||
type == "zh" -> row.zh ?: ""
|
||||
type == "roma" -> row.roma ?: ""
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (storageLrc.isNullOrEmpty() || forceRefresh) {
|
||||
AppLog.d(TAG, "handleLrc($type) miss uid=$uid path=$lrcUrl force=$forceRefresh")
|
||||
return try {
|
||||
val netLrc = oss.fetchLyricRelativePath(lrcUrl)
|
||||
if (netLrc.isBlank()) {
|
||||
AppLog.w(TAG, "handleLrc($type) empty body uid=$uid")
|
||||
return null
|
||||
}
|
||||
|
||||
if (row == null) {
|
||||
row = Lyric(uid = uid, jp = null, zh = null, roma = null)
|
||||
}
|
||||
row = when (type) {
|
||||
"jp" -> row.copy(jp = netLrc)
|
||||
"zh" -> row.copy(zh = netLrc)
|
||||
"roma" -> row.copy(roma = netLrc)
|
||||
else -> row
|
||||
}
|
||||
// Flutter: insert when storageLrc == null, else update
|
||||
if (storageLrc == null) {
|
||||
library.insertLyricNew(row)
|
||||
AppLog.d(TAG, "handleLrc($type) insertLyric uid=$uid len=${netLrc.length}")
|
||||
} else {
|
||||
library.updateLyric(row)
|
||||
AppLog.d(TAG, "handleLrc($type) updateLrc uid=$uid len=${netLrc.length}")
|
||||
}
|
||||
netLrc
|
||||
} catch (t: Throwable) {
|
||||
AppLog.e(TAG, "handleLrc($type) network fail uid=$uid path=$lrcUrl", t)
|
||||
null
|
||||
}
|
||||
}
|
||||
AppLog.d(TAG, "handleLrc($type) cache hit uid=$uid len=${storageLrc.length}")
|
||||
return storageLrc
|
||||
}
|
||||
|
||||
fun toggleType() {
|
||||
lrcType = (lrcType + 1) % 3
|
||||
}
|
||||
|
||||
/** Flutter [LyricLogic.reloadLyricToController] → parse for UI. */
|
||||
fun toLoaded(lyric: Lyric = fullLrc): LoadedLyric {
|
||||
val main: String?
|
||||
val translation: String?
|
||||
when (lrcType % 3) {
|
||||
0 -> {
|
||||
main = lyric.jp
|
||||
translation = null
|
||||
}
|
||||
1 -> {
|
||||
if (PlatformInfo.allowEulaLyric) {
|
||||
main = lyric.jp
|
||||
translation = lyric.zh
|
||||
} else {
|
||||
main = lyric.zh
|
||||
translation = lyric.roma
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
main = lyric.jp
|
||||
translation = lyric.roma
|
||||
}
|
||||
}
|
||||
return LoadedLyric(
|
||||
main = LrcParser.parse(main?.takeIf { it.isNotBlank() }),
|
||||
translation = LrcParser.parse(translation?.takeIf { it.isNotBlank() }),
|
||||
raw = lyric,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class LoadedLyric(
|
||||
val main: List<LyricLine> = emptyList(),
|
||||
val translation: List<LyricLine> = emptyList(),
|
||||
val raw: Lyric? = null,
|
||||
)
|
||||
|
||||
expect object PlatformInfo {
|
||||
val allowEulaLyric: Boolean
|
||||
}
|
||||
|
||||
private const val TAG = "LLMP-Lyric"
|
||||
@@ -0,0 +1,50 @@
|
||||
package top.zhushenwudi.llmp.network
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logger
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
import top.zhushenwudi.llmp.platform.AppLog
|
||||
|
||||
expect fun createPlatformHttpClient(): HttpClient
|
||||
|
||||
private const val HTTP_LOG_TAG = "LLMP-HTTP"
|
||||
|
||||
fun createHttpClient(): HttpClient =
|
||||
createPlatformHttpClient().config {
|
||||
// Align with Flutter Network headers; OSS returns 403 without User-Agent.
|
||||
defaultRequest {
|
||||
header(
|
||||
HttpHeaders.UserAgent,
|
||||
"LoveLiveMusicPlayer/0.2.0 (KMP; Android/iOS)",
|
||||
)
|
||||
header(HttpHeaders.Accept, "application/json,*/*")
|
||||
header("Session-Access-Origin", "xxx")
|
||||
header(HttpHeaders.CacheControl, "no-cache")
|
||||
}
|
||||
install(ContentNegotiation) {
|
||||
json(
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
},
|
||||
)
|
||||
}
|
||||
install(WebSockets)
|
||||
// Log request + response (headers & body) to Logcat / NSLog.
|
||||
install(Logging) {
|
||||
logger = object : Logger {
|
||||
override fun log(message: String) {
|
||||
AppLog.d(HTTP_LOG_TAG, message)
|
||||
}
|
||||
}
|
||||
level = LogLevel.ALL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package top.zhushenwudi.llmp.network
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.statement.bodyAsBytes
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.HttpHeaders
|
||||
import top.zhushenwudi.llmp.Const
|
||||
|
||||
class OssApi(
|
||||
private val client: HttpClient,
|
||||
private val env: String = "prod",
|
||||
) {
|
||||
suspend fun fetchDataJson(): String =
|
||||
client.get(Const.dataUrl(env)).bodyAsText()
|
||||
|
||||
suspend fun fetchVersionJson(): String =
|
||||
client.get(Const.updateUrl(env)).bodyAsText()
|
||||
|
||||
suspend fun fetchArtistJson(): String =
|
||||
client.get(Const.artistModelUrl(env)).bodyAsText()
|
||||
|
||||
/**
|
||||
* Flutter: `Network.getSync(Const.lyricUrl + Uri.encodeComponent(lrcUrl))`
|
||||
* where [relativePath] is like `JP/$baseUrl$file.lrc`.
|
||||
*
|
||||
* Equivalent resolved path with per-segment encoding (avoids Ktor turning
|
||||
* whole-string `%2F` into `%252F`).
|
||||
*/
|
||||
suspend fun fetchLyricRelativePath(relativePath: String): String {
|
||||
val url = Const.LYRIC_URL.trimEnd('/') + "/" +
|
||||
relativePath.split('/')
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString("/") { encodeComponent(it) }
|
||||
val response = client.get(url) {
|
||||
header(HttpHeaders.Accept, "*/*")
|
||||
}
|
||||
return response.bodyAsText()
|
||||
}
|
||||
|
||||
suspend fun fetchLyric(url: String): String {
|
||||
val response = client.get(url) {
|
||||
header(HttpHeaders.Accept, "*/*")
|
||||
}
|
||||
return response.bodyAsText()
|
||||
}
|
||||
|
||||
suspend fun fetchText(url: String): String =
|
||||
client.get(url).bodyAsText()
|
||||
|
||||
suspend fun downloadBytes(url: String): ByteArray =
|
||||
client.get(url).bodyAsBytes()
|
||||
|
||||
/** Dart `Uri.encodeComponent` for one path segment. */
|
||||
private fun encodeComponent(value: String): String = buildString(value.length * 2) {
|
||||
for (ch in value) {
|
||||
when {
|
||||
ch.isLetterOrDigit() || ch in "-_.!~*'()" -> append(ch)
|
||||
else -> append('%')
|
||||
.append(ch.code.toString(16).uppercase().padStart(2, '0'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package top.zhushenwudi.llmp.network
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.websocket.webSocketSession
|
||||
import io.ktor.client.request.url
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.WebSocketSession
|
||||
import io.ktor.websocket.close
|
||||
import io.ktor.websocket.readText
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import top.zhushenwudi.llmp.Const
|
||||
import top.zhushenwudi.llmp.domain.FtpCmd
|
||||
import top.zhushenwudi.llmp.protocol.Handshake
|
||||
import top.zhushenwudi.llmp.protocol.TransferChannel
|
||||
|
||||
class WsClient(
|
||||
private val client: HttpClient,
|
||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||
) {
|
||||
private var session: WebSocketSession? = null
|
||||
|
||||
val isConnected: Boolean get() = session != null
|
||||
|
||||
suspend fun connect(host: String, channel: TransferChannel): Boolean {
|
||||
val port = when (channel) {
|
||||
TransferChannel.MUSIC -> Const.WS_MUSIC_PORT
|
||||
TransferChannel.DATA -> Const.WS_DATA_PORT
|
||||
}
|
||||
return try {
|
||||
close()
|
||||
session = client.webSocketSession { url("ws://$host:$port") }
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
session = null
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun send(cmd: FtpCmd) {
|
||||
session?.send(Frame.Text(json.encodeToString(cmd)))
|
||||
}
|
||||
|
||||
suspend fun sendVersion(localVersion: Int = Const.TRANS_VER) {
|
||||
send(Handshake.buildVersionRequest(localVersion))
|
||||
}
|
||||
|
||||
fun incoming(): Flow<FtpCmd> = flow {
|
||||
val s = session ?: return@flow
|
||||
for (frame in s.incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
val text = frame.readText()
|
||||
runCatching {
|
||||
emit(json.decodeFromString(FtpCmd.serializer(), text))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun close() {
|
||||
runCatching { session?.close() }
|
||||
session = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package top.zhushenwudi.llmp.platform
|
||||
|
||||
/** Platform logger — Android → Logcat, iOS → NSLog/println. */
|
||||
expect object AppLog {
|
||||
fun d(tag: String, message: String)
|
||||
fun i(tag: String, message: String)
|
||||
fun w(tag: String, message: String)
|
||||
fun e(tag: String, message: String, throwable: Throwable? = null)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package top.zhushenwudi.llmp.platform
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import top.zhushenwudi.llmp.domain.Album
|
||||
import top.zhushenwudi.llmp.domain.GroupKey
|
||||
import top.zhushenwudi.llmp.domain.Menu
|
||||
import top.zhushenwudi.llmp.domain.Music
|
||||
|
||||
/**
|
||||
* CarPlay catalog mirroring Flutter carplay pages:
|
||||
* Tab Music / Album / Mine with groups, love, menus, and play callbacks.
|
||||
*
|
||||
* UI is native (Swift CPTemplate); this model supplies data + handlers.
|
||||
*/
|
||||
@Serializable
|
||||
data class CarPlayGroupNode(
|
||||
val key: GroupKey,
|
||||
val displayName: String,
|
||||
val detail: String = "",
|
||||
val logoPath: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CarPlaySongItem(
|
||||
val musicId: String,
|
||||
val title: String,
|
||||
val artist: String? = null,
|
||||
val coverPath: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CarPlayAlbumItem(
|
||||
val albumId: String,
|
||||
val title: String,
|
||||
val group: String? = null,
|
||||
val coverPath: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CarPlayMenuItem(
|
||||
val menuId: Long,
|
||||
val name: String,
|
||||
val count: Int,
|
||||
)
|
||||
|
||||
class CarPlayCatalog {
|
||||
/** Now-playing title for Music tab header. */
|
||||
var nowPlayingTitle: String = "正在播放"
|
||||
private set
|
||||
var nowPlayingCover: String? = null
|
||||
private set
|
||||
|
||||
var groups: List<CarPlayGroupNode> = defaultGroups()
|
||||
private set
|
||||
var loveSongs: List<CarPlaySongItem> = emptyList()
|
||||
private set
|
||||
var menus: List<CarPlayMenuItem> = emptyList()
|
||||
private set
|
||||
|
||||
private var songsByGroup: Map<String, List<CarPlaySongItem>> = emptyMap()
|
||||
private var albumsByGroup: Map<String, List<CarPlayAlbumItem>> = emptyMap()
|
||||
private var songsByAlbum: Map<String, List<CarPlaySongItem>> = emptyMap()
|
||||
private var songsByMenu: Map<Long, List<CarPlaySongItem>> = emptyMap()
|
||||
|
||||
var onPlay: ((musicId: String, queue: List<CarPlaySongItem>) -> Unit)? = null
|
||||
|
||||
fun updateNowPlaying(music: Music?) {
|
||||
nowPlayingTitle = "正在播放:${music?.musicName ?: "无歌曲"}"
|
||||
nowPlayingCover = music?.coverPath
|
||||
}
|
||||
|
||||
fun setLibrary(
|
||||
allMusic: List<Music>,
|
||||
albums: List<Album>,
|
||||
love: List<Music>,
|
||||
menuList: List<Menu>,
|
||||
) {
|
||||
songsByGroup = allMusic
|
||||
.filter { !it.musicId.isNullOrBlank() }
|
||||
.groupBy { it.group.orEmpty() }
|
||||
.mapValues { (_, list) -> list.map { it.toItem() } }
|
||||
albumsByGroup = albums
|
||||
.filter { !it.albumId.isNullOrBlank() }
|
||||
.groupBy { it.group.orEmpty() }
|
||||
.mapValues { (_, list) ->
|
||||
list.map {
|
||||
CarPlayAlbumItem(
|
||||
albumId = it.albumId!!,
|
||||
title = it.albumName.orEmpty(),
|
||||
group = it.group,
|
||||
coverPath = it.coverPath,
|
||||
)
|
||||
}
|
||||
}
|
||||
songsByAlbum = allMusic
|
||||
.filter { !it.albumId.isNullOrBlank() && !it.musicId.isNullOrBlank() }
|
||||
.groupBy { it.albumId!! }
|
||||
.mapValues { (_, list) -> list.map { it.toItem() } }
|
||||
loveSongs = love.map { it.toItem() }
|
||||
menus = menuList.map { CarPlayMenuItem(it.id, it.name, it.music.size) }
|
||||
songsByMenu = menuList.associate { menu ->
|
||||
menu.id to menu.music.mapNotNull { id ->
|
||||
allMusic.firstOrNull { it.musicId == id }?.toItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun songsForGroup(group: String): List<CarPlaySongItem> = songsByGroup[group].orEmpty()
|
||||
fun albumsForGroup(group: String): List<CarPlayAlbumItem> = albumsByGroup[group].orEmpty()
|
||||
fun songsForAlbum(albumId: String): List<CarPlaySongItem> = songsByAlbum[albumId].orEmpty()
|
||||
fun songsForMenu(menuId: Long): List<CarPlaySongItem> = songsByMenu[menuId].orEmpty()
|
||||
|
||||
fun play(item: CarPlaySongItem, queue: List<CarPlaySongItem>) {
|
||||
onPlay?.invoke(item.musicId, queue)
|
||||
}
|
||||
|
||||
private fun Music.toItem() = CarPlaySongItem(
|
||||
musicId = musicId.orEmpty(),
|
||||
title = musicName.orEmpty(),
|
||||
artist = artist,
|
||||
coverPath = coverPath,
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun defaultGroups(): List<CarPlayGroupNode> = listOf(
|
||||
CarPlayGroupNode(GroupKey.GROUP_US, "\u03bc's"),
|
||||
CarPlayGroupNode(GroupKey.GROUP_AQOURS, "Aqours"),
|
||||
CarPlayGroupNode(GroupKey.GROUP_NIJIGASAKI, "Nijigasaki"),
|
||||
CarPlayGroupNode(GroupKey.GROUP_LIELLA, "Liella!"),
|
||||
CarPlayGroupNode(GroupKey.GROUP_HASUNOSORA, "Hasunosora"),
|
||||
CarPlayGroupNode(GroupKey.GROUP_YOHANE, "Yohane"),
|
||||
CarPlayGroupNode(GroupKey.GROUP_MUSICAL, "Musical"),
|
||||
CarPlayGroupNode(GroupKey.GROUP_BLUEBIRD, "BlueBird"),
|
||||
CarPlayGroupNode(GroupKey.GROUP_COMBINE, "Combine"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package top.zhushenwudi.llmp.platform
|
||||
|
||||
/**
|
||||
* Local storage roots and file helpers.
|
||||
*/
|
||||
expect class FileSystem {
|
||||
/**
|
||||
* App files / documents root (ends with separator).
|
||||
* Flutter default `SDUtils.path` — e.g. Android `…/files/`, iOS Documents.
|
||||
*/
|
||||
val filesRoot: String
|
||||
|
||||
/**
|
||||
* Music / media root (ends with separator). Same as Flutter `SDUtils.path`.
|
||||
* Cloud `base_url` already starts with `LoveLive/…`, so this must NOT append another `LoveLive/`.
|
||||
*/
|
||||
val musicRoot: String
|
||||
|
||||
fun exists(absolutePath: String): Boolean
|
||||
|
||||
fun ensureDir(absolutePath: String): Boolean
|
||||
|
||||
fun writeBytes(absolutePath: String, bytes: ByteArray): Boolean
|
||||
|
||||
/** Non-recursive absolute paths of files (not directories) under [absoluteDir]. */
|
||||
fun listFiles(absoluteDir: String): List<String>
|
||||
|
||||
fun listUsbRoots(): List<String>
|
||||
}
|
||||
|
||||
expect fun createFileSystem(): FileSystem
|
||||
@@ -0,0 +1,230 @@
|
||||
package top.zhushenwudi.llmp.platform
|
||||
|
||||
/**
|
||||
* Platform system integrations used by AppContainer / UI.
|
||||
* Android & iOS wire real implementations; NoOp is compile-time fallback only.
|
||||
*/
|
||||
|
||||
/** Desktop / floating lyric overlay (Android WindowManager; iOS PiP lyric). */
|
||||
interface DesktopLyricController {
|
||||
var enabled: Boolean
|
||||
fun show()
|
||||
fun hide()
|
||||
fun updateLines(line1: String?, line2: String?, currentLine: Int)
|
||||
fun setPlaying(playing: Boolean)
|
||||
}
|
||||
|
||||
/** Home screen widget data sync. */
|
||||
interface HomeWidgetController {
|
||||
fun update(
|
||||
songName: String?,
|
||||
artist: String?,
|
||||
isPlaying: Boolean,
|
||||
favorite: Boolean,
|
||||
lyricLine1: String? = null,
|
||||
lyricLine2: String? = null,
|
||||
currentLine: Int = -1,
|
||||
coverPath: String? = null,
|
||||
playText: String = "Playing,Paused",
|
||||
)
|
||||
|
||||
fun setShutdown(shutdown: Boolean)
|
||||
fun refresh()
|
||||
}
|
||||
|
||||
/** Deep link parser for llmp:// share URLs. */
|
||||
interface DeepLinkHandler {
|
||||
fun parse(url: String): DeepLinkPayload?
|
||||
fun handle(url: String)
|
||||
}
|
||||
|
||||
data class DeepLinkPayload(
|
||||
val raw: String,
|
||||
val type: Int? = null,
|
||||
val musicId: String? = null,
|
||||
val data: String? = null,
|
||||
)
|
||||
|
||||
/** USB mount / unmount monitoring. */
|
||||
interface UsbMountMonitor {
|
||||
fun start()
|
||||
fun stop()
|
||||
fun listMountedVolumes(): List<String>
|
||||
}
|
||||
|
||||
/** System share (Android Intent / iOS share sheet). */
|
||||
interface ShareBridge {
|
||||
fun shareText(text: String, title: String? = null)
|
||||
fun shareUrl(url: String, title: String? = null)
|
||||
}
|
||||
|
||||
interface MediaSessionHook {
|
||||
fun updateSession(title: String?, artist: String?, isPlaying: Boolean)
|
||||
}
|
||||
|
||||
interface AnalyticsHook {
|
||||
fun initIfAgreed(agreed: Boolean)
|
||||
fun event(name: String, props: Map<String, String> = emptyMap())
|
||||
}
|
||||
|
||||
interface PictureInPictureHook {
|
||||
fun enterPiP()
|
||||
}
|
||||
|
||||
interface CarPlayHook {
|
||||
fun refreshCatalog()
|
||||
fun bind(catalog: CarPlayCatalog)
|
||||
}
|
||||
|
||||
// --- NoOp fallbacks (last resort; not default once platform actuals register) ---
|
||||
|
||||
object NoOpDesktopLyric : DesktopLyricController {
|
||||
override var enabled: Boolean = false
|
||||
override fun show() = Unit
|
||||
override fun hide() = Unit
|
||||
override fun updateLines(line1: String?, line2: String?, currentLine: Int) = Unit
|
||||
override fun setPlaying(playing: Boolean) = Unit
|
||||
}
|
||||
|
||||
object NoOpHomeWidget : HomeWidgetController {
|
||||
override fun update(
|
||||
songName: String?,
|
||||
artist: String?,
|
||||
isPlaying: Boolean,
|
||||
favorite: Boolean,
|
||||
lyricLine1: String?,
|
||||
lyricLine2: String?,
|
||||
currentLine: Int,
|
||||
coverPath: String?,
|
||||
playText: String,
|
||||
) = Unit
|
||||
|
||||
override fun setShutdown(shutdown: Boolean) = Unit
|
||||
override fun refresh() = Unit
|
||||
}
|
||||
|
||||
object NoOpDeepLink : DeepLinkHandler {
|
||||
override fun parse(url: String): DeepLinkPayload? = DeepLinkParser.parse(url)
|
||||
override fun handle(url: String) = Unit
|
||||
}
|
||||
|
||||
object NoOpUsbMount : UsbMountMonitor {
|
||||
override fun start() = Unit
|
||||
override fun stop() = Unit
|
||||
override fun listMountedVolumes(): List<String> = emptyList()
|
||||
}
|
||||
|
||||
object NoOpShare : ShareBridge {
|
||||
override fun shareText(text: String, title: String?) = Unit
|
||||
override fun shareUrl(url: String, title: String?) = Unit
|
||||
}
|
||||
|
||||
object NoOpMediaSession : MediaSessionHook {
|
||||
override fun updateSession(title: String?, artist: String?, isPlaying: Boolean) = Unit
|
||||
}
|
||||
|
||||
object NoOpAnalytics : AnalyticsHook {
|
||||
override fun initIfAgreed(agreed: Boolean) = Unit
|
||||
override fun event(name: String, props: Map<String, String>) = Unit
|
||||
}
|
||||
|
||||
object NoOpCarPlay : CarPlayHook {
|
||||
override fun refreshCatalog() = Unit
|
||||
override fun bind(catalog: CarPlayCatalog) = Unit
|
||||
}
|
||||
|
||||
object NoOpPiP : PictureInPictureHook {
|
||||
override fun enterPiP() = Unit
|
||||
}
|
||||
|
||||
/** Shared deep-link parsing (no platform deps). */
|
||||
object DeepLinkParser {
|
||||
private const val PREFIX = "llmp://"
|
||||
|
||||
fun parse(url: String): DeepLinkPayload? {
|
||||
if (!url.startsWith(PREFIX) && !url.startsWith("llmp:")) return null
|
||||
val query = url.substringAfter('?', missingDelimiterValue = "")
|
||||
if (query.isEmpty()) return DeepLinkPayload(raw = url)
|
||||
val params = query.split('&').mapNotNull { part ->
|
||||
val i = part.indexOf('=')
|
||||
if (i <= 0) null else part.substring(0, i) to part.substring(i + 1)
|
||||
}.toMap()
|
||||
return DeepLinkPayload(
|
||||
raw = url,
|
||||
type = params["type"]?.toIntOrNull(),
|
||||
musicId = params["musicId"] ?: params["data"]?.takeIf { params["type"] == "1" },
|
||||
data = params["data"],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton registry filled by platform entry (MainActivity / MainViewController).
|
||||
* Defaults to NoOp until [install] is called.
|
||||
*/
|
||||
object PlatformServices {
|
||||
var desktopLyric: DesktopLyricController = NoOpDesktopLyric
|
||||
var homeWidget: HomeWidgetController = NoOpHomeWidget
|
||||
var deepLink: DeepLinkHandler = NoOpDeepLink
|
||||
var usbMount: UsbMountMonitor = NoOpUsbMount
|
||||
var share: ShareBridge = NoOpShare
|
||||
var mediaSession: MediaSessionHook = NoOpMediaSession
|
||||
var analytics: AnalyticsHook = NoOpAnalytics
|
||||
var carPlay: CarPlayHook = NoOpCarPlay
|
||||
var pip: PictureInPictureHook = NoOpPiP
|
||||
var carPlayCatalog: CarPlayCatalog = CarPlayCatalog()
|
||||
|
||||
fun install(
|
||||
desktopLyric: DesktopLyricController = this.desktopLyric,
|
||||
homeWidget: HomeWidgetController = this.homeWidget,
|
||||
deepLink: DeepLinkHandler = this.deepLink,
|
||||
usbMount: UsbMountMonitor = this.usbMount,
|
||||
share: ShareBridge = this.share,
|
||||
mediaSession: MediaSessionHook = this.mediaSession,
|
||||
analytics: AnalyticsHook = this.analytics,
|
||||
carPlay: CarPlayHook = this.carPlay,
|
||||
pip: PictureInPictureHook = this.pip,
|
||||
carPlayCatalog: CarPlayCatalog = this.carPlayCatalog,
|
||||
) {
|
||||
this.desktopLyric = desktopLyric
|
||||
this.homeWidget = homeWidget
|
||||
this.deepLink = deepLink
|
||||
this.usbMount = usbMount
|
||||
this.share = share
|
||||
this.mediaSession = mediaSession
|
||||
this.analytics = analytics
|
||||
this.carPlay = carPlay
|
||||
this.pip = pip
|
||||
this.carPlayCatalog = carPlayCatalog
|
||||
this.carPlay.bind(carPlayCatalog)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Module index for full-feature checklist.
|
||||
*/
|
||||
object FeatureModuleIndex {
|
||||
val modules: List<String> = listOf(
|
||||
"[done] OSS metadata ingest",
|
||||
"[done] Library browse (songs/albums/artists/love/menu/recent)",
|
||||
"[done] Playback queue + Android MediaSession service (single ExoPlayer)",
|
||||
"[done] Lyric parse + scrolling UI",
|
||||
"[done] Love / Menu / History + sync merge",
|
||||
"[done] IP connect transfer + QR camera (Android)",
|
||||
"[done] Data sync client",
|
||||
"[done] Settings (theme/HTTP/sleep/update/desktop lyric)",
|
||||
"[done] Drive mode",
|
||||
"[done] SD/USB list + USB mount monitor",
|
||||
"[done] App update check",
|
||||
"[done] Home widget (Android Glance; iOS WidgetKit sources)",
|
||||
"[done] Desktop / floating lyric (Android overlay; iOS PiP sources)",
|
||||
"[done] CarPlay catalog model + iOS SceneDelegate sources",
|
||||
"[done] Share bridge + deep link llmp://",
|
||||
"[done] Moegirl / Tachie WebView",
|
||||
"[done] Daily push text",
|
||||
"[done] Module index / logs",
|
||||
"[done] Permissions screen",
|
||||
"[done] Umeng analytics bridge (privacy-gated)",
|
||||
"[done] iOS AVPlayer + FileSystem.writeBytes",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package top.zhushenwudi.llmp.platform
|
||||
|
||||
/**
|
||||
* UI-facing platform actions (scan / webview / overlay / toast / image pick).
|
||||
*/
|
||||
expect object PlatformUi {
|
||||
fun openQrScanner(onResult: (String) -> Unit)
|
||||
fun openWebView(url: String, title: String? = null)
|
||||
fun requestOverlayPermission()
|
||||
fun moveTaskToBack()
|
||||
fun showToast(message: String)
|
||||
/** Pick an image from gallery; [onResult] receives absolute path or null. */
|
||||
fun pickImage(onResult: (String?) -> Unit)
|
||||
fun openExternalUrl(url: String)
|
||||
fun exitApp()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package top.zhushenwudi.llmp.player
|
||||
|
||||
enum class LoopMode {
|
||||
LIST,
|
||||
SINGLE,
|
||||
SHUFFLE,
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package top.zhushenwudi.llmp.player
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import top.zhushenwudi.llmp.domain.Music
|
||||
|
||||
/**
|
||||
* Platform player abstraction. UI depends only on this interface.
|
||||
*/
|
||||
expect class PlayerController {
|
||||
val state: StateFlow<PlayerState>
|
||||
|
||||
/**
|
||||
* Resolve a playable URI from library [Music] (relative musicPath + baseUrl).
|
||||
* Must NOT mutate [Music.musicPath] — lyrics need the relative path.
|
||||
*/
|
||||
var resolvePlaybackUri: ((Music) -> String?)?
|
||||
|
||||
/**
|
||||
* Resolve cover URI/path for media notification / Now Playing artwork.
|
||||
* Android: file:// or https:// URI. iOS: absolute local filesystem path preferred.
|
||||
*/
|
||||
var resolveCoverUri: ((Music) -> String?)?
|
||||
|
||||
fun setQueue(items: List<Music>, startIndex: Int = 0)
|
||||
fun play()
|
||||
fun pause()
|
||||
fun toggle()
|
||||
fun seekTo(positionMs: Long)
|
||||
fun next()
|
||||
fun previous()
|
||||
/** Jump to queue index (Flutter just_audio seek index). Keeps playing if already playing. */
|
||||
fun playAtIndex(index: Int)
|
||||
fun setLoopMode(mode: LoopMode)
|
||||
|
||||
/** Flutter removeMusic — remove one item without rebuilding the whole engine queue when possible. */
|
||||
fun removeAt(index: Int)
|
||||
|
||||
/** Flutter removeAllMusics / clearPlayerStatus. */
|
||||
fun clearQueue()
|
||||
|
||||
/** Play a local file path or content URI string. */
|
||||
fun playUri(uri: String, title: String? = null)
|
||||
|
||||
fun release()
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package top.zhushenwudi.llmp.player
|
||||
|
||||
import top.zhushenwudi.llmp.domain.Music
|
||||
|
||||
data class PlayerState(
|
||||
val current: Music? = null,
|
||||
val queue: List<Music> = emptyList(),
|
||||
val index: Int = -1,
|
||||
val isPlaying: Boolean = false,
|
||||
val positionMs: Long = 0,
|
||||
val durationMs: Long = 0,
|
||||
val loopMode: LoopMode = LoopMode.LIST,
|
||||
val error: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
package top.zhushenwudi.llmp.player
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SleepTimer(
|
||||
private val scope: CoroutineScope,
|
||||
private val onFire: () -> Unit,
|
||||
) {
|
||||
private val _remainingMs = MutableStateFlow(0L)
|
||||
val remainingMs: StateFlow<Long> = _remainingMs.asStateFlow()
|
||||
private var job: Job? = null
|
||||
|
||||
fun schedule(minutes: Int) {
|
||||
cancel()
|
||||
if (minutes <= 0) return
|
||||
var left = minutes * 60_000L
|
||||
_remainingMs.value = left
|
||||
job = scope.launch {
|
||||
while (left > 0) {
|
||||
delay(1_000)
|
||||
left -= 1_000
|
||||
_remainingMs.value = left.coerceAtLeast(0)
|
||||
}
|
||||
onFire()
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
_remainingMs.value = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package top.zhushenwudi.llmp.protocol
|
||||
|
||||
import top.zhushenwudi.llmp.Const
|
||||
import top.zhushenwudi.llmp.domain.FtpCmd
|
||||
|
||||
/**
|
||||
* Pure handshake logic for `transVer` negotiation.
|
||||
*
|
||||
* Mobile initiates with `{cmd:"version", body:"<transVer>"}`;
|
||||
* peer replies with the same shape. Mismatch → disconnect (no silent continue).
|
||||
*/
|
||||
object Handshake {
|
||||
data class Result(
|
||||
val ok: Boolean,
|
||||
val localVersion: Int,
|
||||
val remoteVersion: Int?,
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
fun buildVersionRequest(localVersion: Int = Const.TRANS_VER): FtpCmd =
|
||||
FtpCmd(cmd = ProtocolCmd.VERSION.wire, body = localVersion.toString())
|
||||
|
||||
fun parseRemoteVersion(cmd: FtpCmd): Int? {
|
||||
if (cmd.cmd != ProtocolCmd.VERSION.wire) return null
|
||||
return cmd.body.trim().toIntOrNull()
|
||||
}
|
||||
|
||||
fun verify(
|
||||
remoteCmd: FtpCmd,
|
||||
localVersion: Int = Const.TRANS_VER,
|
||||
): Result {
|
||||
val remote = parseRemoteVersion(remoteCmd)
|
||||
?: return Result(
|
||||
ok = false,
|
||||
localVersion = localVersion,
|
||||
remoteVersion = null,
|
||||
reason = "expected cmd=version, got=${remoteCmd.cmd}",
|
||||
)
|
||||
return if (remote == localVersion) {
|
||||
Result(ok = true, localVersion = localVersion, remoteVersion = remote)
|
||||
} else {
|
||||
Result(
|
||||
ok = false,
|
||||
localVersion = localVersion,
|
||||
remoteVersion = remote,
|
||||
reason = "transVer mismatch: local=$localVersion remote=$remote",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyVersions(local: Int, remote: Int): Boolean = local == remote
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package top.zhushenwudi.llmp.protocol
|
||||
|
||||
/**
|
||||
* WiFi transfer / sync command words from Docs/03.
|
||||
*/
|
||||
enum class ProtocolCmd(val wire: String) {
|
||||
VERSION("version"),
|
||||
SYSTEM("system"),
|
||||
PORT("port"),
|
||||
PREPARE("prepare"),
|
||||
MUSIC_LIST("musicList"),
|
||||
READY("ready"),
|
||||
DOWNLOAD("download"),
|
||||
DOWNLOADING("downloading"),
|
||||
DOWNLOAD_SUCCESS("download success"),
|
||||
DOWNLOAD_FAIL("download fail"),
|
||||
CONNECTED("connected"),
|
||||
PHONE2PC("phone2pc"),
|
||||
PC2PHONE("pc2phone"),
|
||||
FINISH("finish"),
|
||||
STOP("stop"),
|
||||
BACK("back");
|
||||
|
||||
companion object {
|
||||
fun fromWire(cmd: String): ProtocolCmd? =
|
||||
entries.firstOrNull { it.wire.equals(cmd, ignoreCase = false) }
|
||||
}
|
||||
}
|
||||
|
||||
enum class TransferChannel {
|
||||
/** Port 4388 – song file transfer. */
|
||||
MUSIC,
|
||||
|
||||
/** Port 4389 – love/menu data sync. */
|
||||
DATA,
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package top.zhushenwudi.llmp.settings
|
||||
|
||||
import com.russhwolf.settings.Settings
|
||||
import com.russhwolf.settings.set
|
||||
import top.zhushenwudi.llmp.player.LoopMode
|
||||
|
||||
class AppSettings(private val settings: Settings) {
|
||||
var isDark: Boolean
|
||||
get() = settings.getBoolean(SettingsKeys.DARK, false)
|
||||
set(value) = settings.set(SettingsKeys.DARK, value)
|
||||
|
||||
var isColorful: Boolean
|
||||
get() = settings.getBoolean(SettingsKeys.COLORFUL, false)
|
||||
set(value) = settings.set(SettingsKeys.COLORFUL, value)
|
||||
|
||||
var followSystemTheme: Boolean
|
||||
get() = settings.getBoolean(SettingsKeys.WITH_SYSTEM_THEME, true)
|
||||
set(value) = settings.set(SettingsKeys.WITH_SYSTEM_THEME, value)
|
||||
|
||||
var loopMode: LoopMode
|
||||
get() {
|
||||
val raw = settings.getStringOrNull(SettingsKeys.LOOP_MODE)
|
||||
return LoopMode.entries.firstOrNull { it.name == raw } ?: LoopMode.LIST
|
||||
}
|
||||
set(value) = settings.set(SettingsKeys.LOOP_MODE, value.name)
|
||||
|
||||
var dataVersion: String
|
||||
get() = settings.getString(SettingsKeys.DATA_VERSION, "")
|
||||
set(value) = settings.set(SettingsKeys.DATA_VERSION, value)
|
||||
|
||||
var enableHttp: Boolean
|
||||
get() = settings.getBoolean(SettingsKeys.ENABLE_HTTP, false)
|
||||
set(value) = settings.set(SettingsKeys.ENABLE_HTTP, value)
|
||||
|
||||
var httpUrl: String
|
||||
get() = settings.getString(SettingsKeys.HTTP_URL, "")
|
||||
set(value) = settings.set(SettingsKeys.HTTP_URL, value)
|
||||
|
||||
var enableDesktopLyric: Boolean
|
||||
get() = settings.getBoolean(SettingsKeys.ENABLE_DESKTOP_LYRIC, false)
|
||||
set(value) = settings.set(SettingsKeys.ENABLE_DESKTOP_LYRIC, value)
|
||||
|
||||
var aiPicture: Boolean
|
||||
get() = settings.getBoolean(SettingsKeys.AI_PICTURE, true)
|
||||
set(value) = settings.set(SettingsKeys.AI_PICTURE, value)
|
||||
|
||||
var sortOrder: String
|
||||
get() = settings.getString(SettingsKeys.SORT_ORDER, "ASC")
|
||||
set(value) = settings.set(SettingsKeys.SORT_ORDER, value)
|
||||
|
||||
var sdPath: String
|
||||
get() = settings.getString(SettingsKeys.SD_PATH, "")
|
||||
set(value) = settings.set(SettingsKeys.SD_PATH, value)
|
||||
|
||||
var lastPcHost: String
|
||||
get() = settings.getString("SP_LAST_PC_HOST", "")
|
||||
set(value) = settings.set("SP_LAST_PC_HOST", value)
|
||||
|
||||
var enableBackground: Boolean
|
||||
get() = settings.getBoolean(SettingsKeys.ENABLE_BG, false)
|
||||
set(value) = settings.set(SettingsKeys.ENABLE_BG, value)
|
||||
|
||||
var backgroundPhoto: String
|
||||
get() = settings.getString(SettingsKeys.BG_PHOTO, "")
|
||||
set(value) = settings.set(SettingsKeys.BG_PHOTO, value)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package top.zhushenwudi.llmp.settings
|
||||
|
||||
import com.russhwolf.settings.Settings
|
||||
|
||||
expect fun createSettings(): Settings
|
||||
@@ -0,0 +1,17 @@
|
||||
package top.zhushenwudi.llmp.settings
|
||||
|
||||
object SettingsKeys {
|
||||
const val DARK = "SP_IS_DARK"
|
||||
const val COLORFUL = "SP_IS_COLORFUL"
|
||||
const val WITH_SYSTEM_THEME = "SP_With_System_Theme"
|
||||
const val LOOP_MODE = "SP_LOOP_MODE"
|
||||
const val DATA_VERSION = "SP_DATA_VERSION"
|
||||
const val ENABLE_HTTP = "SP_ENABLE_HTTP"
|
||||
const val HTTP_URL = "SP_HTTP_URL"
|
||||
const val ENABLE_DESKTOP_LYRIC = "SP_OPEN_DESKTOP_LYRIC"
|
||||
const val AI_PICTURE = "SP_AI_PICTURE"
|
||||
const val SORT_ORDER = "SP_SORT_ORDER"
|
||||
const val SD_PATH = "SP_SD_PATH"
|
||||
const val ENABLE_BG = "SP_ENABLE_BACKGROUND"
|
||||
const val BG_PHOTO = "SP_BACKGROUND_PHOTO"
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package top.zhushenwudi.llmp.splash
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import top.zhushenwudi.llmp.Const
|
||||
import top.zhushenwudi.llmp.db.currentTimeMs
|
||||
import top.zhushenwudi.llmp.network.OssApi
|
||||
import top.zhushenwudi.llmp.platform.FileSystem
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Flutter `SplashPhoto` / `SDUtils` splash cache.
|
||||
* Cache dir = `{filesRoot}splash/` (NOT under LoveLive), matching Flutter SDUtils.path + splash/.
|
||||
*
|
||||
* Splash UI only reads local cache; config fetch + missing downloads run after splash
|
||||
* via [syncInBackground].
|
||||
*/
|
||||
class SplashPhotoService(
|
||||
private val fs: FileSystem,
|
||||
private val oss: OssApi,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
|
||||
private val splashDir: String
|
||||
get() = fs.filesRoot.trimEnd('/', '\\') + "/splash/"
|
||||
|
||||
/**
|
||||
* Random local cached splash path, or null if cache is empty.
|
||||
* Does **not** hit the network.
|
||||
*/
|
||||
suspend fun pickCachedDisplayPath(): String? = withContext(Dispatchers.Default) {
|
||||
fs.ensureDir(splashDir)
|
||||
val cached = listCachedPhotos()
|
||||
if (cached.isEmpty()) null else cached[Random.nextInt(cached.size)]
|
||||
}
|
||||
|
||||
/** Fetch splash config and download missing images after the splash screen. */
|
||||
fun syncInBackground() {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
runCatching { syncFromNetwork() }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncFromNetwork() {
|
||||
fs.ensureDir(splashDir)
|
||||
val text = oss.fetchText(Const.SPLASH_CONFIG_URL)
|
||||
val config = json.decodeFromString<SplashConfig>(text)
|
||||
// forceChoose only affects next cold start once the file is already cached
|
||||
enqueueMissingDownloads(config)
|
||||
}
|
||||
|
||||
private fun listCachedPhotos(): List<String> =
|
||||
fs.listFiles(splashDir).filter { path ->
|
||||
val lower = path.lowercase()
|
||||
lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") ||
|
||||
lower.endsWith(".webp")
|
||||
}
|
||||
|
||||
private fun localPathFor(singer: String, index: Int): String =
|
||||
// Flat cache names match Flutter download: splash/bg_{singer}_{index}.png
|
||||
splashDir + "bg_${singer}_$index.png"
|
||||
|
||||
private suspend fun enqueueMissingDownloads(config: SplashConfig) {
|
||||
val jobs = mutableListOf<Pair<String, String>>()
|
||||
config.bg.forEach { bg ->
|
||||
for (index in 1..bg.size) {
|
||||
val url = "${Const.SPLASH_URL}${bg.singer}/bg_${bg.singer}_$index.png"
|
||||
val path = localPathFor(bg.singer, index)
|
||||
if (!fs.exists(path)) {
|
||||
jobs += url to path
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also ensure forceChoose target is present when still active
|
||||
config.forceChoose?.let { force ->
|
||||
val endTime = (force["endTime"] as? JsonPrimitive)?.longOrNull
|
||||
if (endTime != null && endTime >= currentTimeMs()) {
|
||||
val uid = (force["uid"] as? JsonPrimitive)?.contentOrNull
|
||||
val index = (force["index"] as? JsonPrimitive)?.intOrNull
|
||||
if (uid != null && index != null) {
|
||||
val bg = config.bg.firstOrNull { it.uid == uid }
|
||||
if (bg != null && index in 1..bg.size) {
|
||||
val path = localPathFor(bg.singer, index)
|
||||
val url = "${Const.SPLASH_URL}${bg.singer}/bg_${bg.singer}_$index.png"
|
||||
if (!fs.exists(path) && jobs.none { it.second == path }) {
|
||||
jobs += url to path
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (jobs.isEmpty()) return
|
||||
jobs.forEach { (url, path) ->
|
||||
runCatching {
|
||||
val bytes = oss.downloadBytes(url)
|
||||
fs.writeBytes(path, bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class SplashConfig(
|
||||
val forceChoose: JsonObject? = null,
|
||||
val bg: List<SplashBg> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class SplashBg(
|
||||
val uid: String,
|
||||
val singer: String,
|
||||
val size: Int,
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
package top.zhushenwudi.llmp.transfer
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import top.zhushenwudi.llmp.Const
|
||||
import top.zhushenwudi.llmp.db.LibraryRepository
|
||||
import top.zhushenwudi.llmp.domain.FtpCmd
|
||||
import top.zhushenwudi.llmp.domain.Menu
|
||||
import top.zhushenwudi.llmp.domain.TransData
|
||||
import top.zhushenwudi.llmp.domain.TransMenu
|
||||
import top.zhushenwudi.llmp.network.WsClient
|
||||
import top.zhushenwudi.llmp.protocol.Handshake
|
||||
import top.zhushenwudi.llmp.protocol.ProtocolCmd
|
||||
import top.zhushenwudi.llmp.protocol.TransferChannel
|
||||
|
||||
data class SyncUiState(
|
||||
val host: String = "",
|
||||
val connected: Boolean = false,
|
||||
val verified: Boolean = false,
|
||||
val status: String = "未连接",
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class DataSyncService(
|
||||
private val ws: WsClient,
|
||||
private val library: LibraryRepository,
|
||||
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default),
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
private val _state = MutableStateFlow(SyncUiState())
|
||||
val state: StateFlow<SyncUiState> = _state.asStateFlow()
|
||||
private var job: Job? = null
|
||||
|
||||
fun connect(host: String) {
|
||||
job?.cancel()
|
||||
_state.value = SyncUiState(host = host, status = "连接中…")
|
||||
job = scope.launch {
|
||||
if (!ws.connect(host, TransferChannel.DATA)) {
|
||||
_state.update { it.copy(error = "连接失败", status = "失败") }
|
||||
return@launch
|
||||
}
|
||||
_state.update { it.copy(connected = true, status = "握手中…") }
|
||||
ws.sendVersion()
|
||||
ws.incoming().collect { handle(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun pushPhoneToPc(isCover: Boolean) {
|
||||
scope.launch {
|
||||
if (!_state.value.verified) {
|
||||
_state.update { it.copy(error = "未完成握手") }
|
||||
return@launch
|
||||
}
|
||||
val menus = library.getAllMenus()
|
||||
.filter { if (isCover) true else it.id > 100 }
|
||||
.map {
|
||||
TransMenu(
|
||||
menuId = it.id.toInt(),
|
||||
name = it.name,
|
||||
date = it.date,
|
||||
musicList = it.music,
|
||||
)
|
||||
}
|
||||
val data = TransData(
|
||||
love = library.getAllLove(),
|
||||
menu = menus,
|
||||
isCover = isCover,
|
||||
)
|
||||
ws.send(FtpCmd(ProtocolCmd.PHONE2PC.wire, json.encodeToString(data)))
|
||||
_state.update { it.copy(status = "已发送 phone2pc") }
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPcToPhone() {
|
||||
scope.launch {
|
||||
val data = TransData(love = library.getAllLove(), menu = emptyList(), isCover = false)
|
||||
ws.send(FtpCmd(ProtocolCmd.PC2PHONE.wire, json.encodeToString(data)))
|
||||
_state.update { it.copy(status = "已请求 pc2phone") }
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
scope.launch { ws.close() }
|
||||
job?.cancel()
|
||||
_state.update { it.copy(connected = false, verified = false, status = "已断开") }
|
||||
}
|
||||
|
||||
private suspend fun handle(cmd: FtpCmd) {
|
||||
when (ProtocolCmd.fromWire(cmd.cmd)) {
|
||||
ProtocolCmd.VERSION -> {
|
||||
val result = Handshake.verify(cmd, Const.TRANS_VER)
|
||||
if (!result.ok) {
|
||||
_state.update { it.copy(error = result.reason, status = "版本不匹配") }
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
_state.update { it.copy(verified = true, status = "已验证") }
|
||||
ws.send(FtpCmd(ProtocolCmd.CONNECTED.wire, ""))
|
||||
}
|
||||
ProtocolCmd.PHONE2PC -> {
|
||||
val data = json.decodeFromString<TransData>(cmd.body)
|
||||
library.replaceLove(data.love)
|
||||
_state.update { it.copy(status = "已应用 PC 侧喜欢列表") }
|
||||
ws.close()
|
||||
}
|
||||
ProtocolCmd.PC2PHONE -> {
|
||||
val data = json.decodeFromString<TransData>(cmd.body)
|
||||
library.replaceLove(data.love)
|
||||
if (data.isCover) library.clearMenus() else library.deletePcMenus()
|
||||
data.menu.forEach { tm ->
|
||||
val existingIds = library.getAllMusic().mapNotNull { it.musicId }.toSet()
|
||||
library.upsertMenu(
|
||||
Menu(
|
||||
id = tm.menuId.toLong(),
|
||||
isPhone = tm.menuId > 100,
|
||||
music = tm.musicList.filter { it in existingIds },
|
||||
date = tm.date,
|
||||
name = tm.name,
|
||||
),
|
||||
)
|
||||
}
|
||||
_state.update { it.copy(status = "已应用 PC 歌单/喜欢") }
|
||||
}
|
||||
ProtocolCmd.BACK, ProtocolCmd.STOP, ProtocolCmd.FINISH -> {
|
||||
_state.update { it.copy(status = "会话结束") }
|
||||
ws.close()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package top.zhushenwudi.llmp.transfer
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import top.zhushenwudi.llmp.Const
|
||||
import top.zhushenwudi.llmp.db.LibraryRepository
|
||||
import top.zhushenwudi.llmp.domain.Album
|
||||
import top.zhushenwudi.llmp.domain.DownloadMusic
|
||||
import top.zhushenwudi.llmp.domain.FtpCmd
|
||||
import top.zhushenwudi.llmp.domain.Music
|
||||
import top.zhushenwudi.llmp.network.OssApi
|
||||
import top.zhushenwudi.llmp.network.WsClient
|
||||
import top.zhushenwudi.llmp.platform.FileSystem
|
||||
import top.zhushenwudi.llmp.protocol.Handshake
|
||||
import top.zhushenwudi.llmp.protocol.ProtocolCmd
|
||||
import top.zhushenwudi.llmp.protocol.TransferChannel
|
||||
import top.zhushenwudi.llmp.util.PathResolver
|
||||
|
||||
data class TransferUiState(
|
||||
val host: String = "",
|
||||
val connected: Boolean = false,
|
||||
val verified: Boolean = false,
|
||||
val httpPort: String = "",
|
||||
val status: String = "未连接",
|
||||
val current: String = "",
|
||||
val progress: Int = 0,
|
||||
val total: Int = 0,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* PC → phone WiFi transfer client (WS 4388 + HTTP files).
|
||||
*/
|
||||
class MusicTransferService(
|
||||
private val ws: WsClient,
|
||||
private val http: OssApi,
|
||||
private val library: LibraryRepository,
|
||||
private val fs: FileSystem,
|
||||
private val paths: PathResolver,
|
||||
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default),
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
private val _state = MutableStateFlow(TransferUiState())
|
||||
val state: StateFlow<TransferUiState> = _state.asStateFlow()
|
||||
|
||||
private var job: Job? = null
|
||||
private var pending: List<DownloadMusic> = emptyList()
|
||||
private var systemBody: String = "android"
|
||||
|
||||
fun setPlatform(system: String) {
|
||||
systemBody = system
|
||||
}
|
||||
|
||||
fun connect(host: String) {
|
||||
job?.cancel()
|
||||
_state.value = TransferUiState(host = host, status = "连接中…")
|
||||
job = scope.launch {
|
||||
if (!ws.connect(host, TransferChannel.MUSIC)) {
|
||||
_state.update { it.copy(status = "连接失败", error = "无法连接 $host:${Const.WS_MUSIC_PORT}") }
|
||||
return@launch
|
||||
}
|
||||
_state.update { it.copy(connected = true, status = "握手中…") }
|
||||
ws.sendVersion()
|
||||
ws.incoming().collect { cmd -> handle(cmd) }
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
scope.launch {
|
||||
runCatching { ws.send(FtpCmd(ProtocolCmd.FINISH.wire, "")) }
|
||||
ws.close()
|
||||
_state.update { it.copy(connected = false, verified = false, status = "已断开") }
|
||||
}
|
||||
job?.cancel()
|
||||
job = null
|
||||
}
|
||||
|
||||
private suspend fun handle(cmd: FtpCmd) {
|
||||
when (ProtocolCmd.fromWire(cmd.cmd)) {
|
||||
ProtocolCmd.VERSION -> {
|
||||
val result = Handshake.verify(cmd, Const.TRANS_VER)
|
||||
if (!result.ok) {
|
||||
_state.update {
|
||||
it.copy(error = result.reason, status = "版本不匹配", verified = false)
|
||||
}
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
_state.update { it.copy(verified = true, status = "已验证") }
|
||||
ws.send(FtpCmd(ProtocolCmd.SYSTEM.wire, systemBody))
|
||||
}
|
||||
ProtocolCmd.PORT -> {
|
||||
_state.update { it.copy(httpPort = cmd.body, status = "HTTP 端口 ${cmd.body}") }
|
||||
}
|
||||
ProtocolCmd.PREPARE -> {
|
||||
val parts = cmd.body.split(" === ")
|
||||
val listJson = parts.getOrNull(0).orEmpty()
|
||||
val needAll = parts.getOrNull(1)?.toBooleanStrictOrNull() ?: false
|
||||
val all = runCatching {
|
||||
json.decodeFromString<List<DownloadMusic>>(listJson)
|
||||
}.getOrDefault(emptyList())
|
||||
val need = if (needAll) {
|
||||
all
|
||||
} else {
|
||||
all.filter { item ->
|
||||
val dest = paths.storageRoot() + item.baseUrl.trimStart('/') +
|
||||
item.musicPath.trimStart('/')
|
||||
!fs.exists(dest)
|
||||
}
|
||||
}
|
||||
pending = need
|
||||
_state.update {
|
||||
it.copy(total = need.size, progress = 0, status = "待传 ${need.size} 首")
|
||||
}
|
||||
val ids = json.encodeToString(need.map { it.musicUId })
|
||||
ws.send(FtpCmd(ProtocolCmd.MUSIC_LIST.wire, ids))
|
||||
}
|
||||
ProtocolCmd.READY -> {
|
||||
pending = runCatching {
|
||||
json.decodeFromString<List<DownloadMusic>>(cmd.body)
|
||||
}.getOrDefault(emptyList())
|
||||
_state.update {
|
||||
it.copy(total = pending.size, progress = 0, status = "就绪 ${pending.size}")
|
||||
}
|
||||
}
|
||||
ProtocolCmd.DOWNLOAD -> {
|
||||
val parts = cmd.body.split(" === ")
|
||||
val musicUId = parts.getOrNull(0).orEmpty()
|
||||
val isLast = parts.getOrNull(1)?.trim().equals("true", ignoreCase = true)
|
||||
val item = pending.find { it.musicUId == musicUId }
|
||||
if (item == null) {
|
||||
ws.send(FtpCmd(ProtocolCmd.DOWNLOAD_FAIL.wire, musicUId))
|
||||
return
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
current = item.musicName,
|
||||
status = "下载中 ${item.musicName}",
|
||||
)
|
||||
}
|
||||
val ok = downloadOne(item)
|
||||
if (ok) {
|
||||
importOne(item)
|
||||
ws.send(FtpCmd(ProtocolCmd.DOWNLOAD_SUCCESS.wire, musicUId))
|
||||
_state.update {
|
||||
it.copy(progress = it.progress + 1)
|
||||
}
|
||||
} else {
|
||||
ws.send(FtpCmd(ProtocolCmd.DOWNLOAD_FAIL.wire, musicUId))
|
||||
}
|
||||
if (isLast) {
|
||||
ws.send(FtpCmd(ProtocolCmd.FINISH.wire, ""))
|
||||
_state.update { it.copy(status = "传输完成") }
|
||||
}
|
||||
}
|
||||
ProtocolCmd.STOP, ProtocolCmd.BACK -> {
|
||||
_state.update { it.copy(status = "对方中断") }
|
||||
ws.close()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadOne(item: DownloadMusic): Boolean {
|
||||
val host = _state.value.host
|
||||
val port = _state.value.httpPort.ifBlank { "10000" }
|
||||
val baseHttp = "http://$host:$port/"
|
||||
fun url(rel: String) = baseHttp + (item.baseUrl + rel).trimStart('/')
|
||||
|
||||
val root = paths.storageRoot()
|
||||
val coverDest = root + item.baseUrl.trimStart('/') + item.coverPath.trimStart('/')
|
||||
val musicDest = root + item.baseUrl.trimStart('/') + item.musicPath.trimStart('/')
|
||||
return try {
|
||||
if (item.coverPath.isNotBlank()) {
|
||||
fs.writeBytes(coverDest, http.downloadBytes(url(item.coverPath)))
|
||||
}
|
||||
fs.writeBytes(musicDest, http.downloadBytes(url(item.musicPath)))
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun importOne(item: DownloadMusic) {
|
||||
library.upsertAlbum(
|
||||
Album(
|
||||
albumId = item.albumUId,
|
||||
albumName = item.albumName,
|
||||
date = item.date,
|
||||
coverPath = item.coverPath,
|
||||
category = item.category,
|
||||
group = item.group,
|
||||
existFile = true,
|
||||
),
|
||||
)
|
||||
library.upsertMusic(
|
||||
Music(
|
||||
musicId = item.musicUId,
|
||||
musicName = item.musicName,
|
||||
artist = item.artist,
|
||||
artistBin = item.artistBin,
|
||||
albumId = item.albumUId,
|
||||
albumName = item.albumName,
|
||||
coverPath = item.coverPath,
|
||||
musicPath = item.musicPath,
|
||||
time = item.totalTime,
|
||||
baseUrl = item.baseUrl,
|
||||
category = item.category,
|
||||
group = item.group,
|
||||
neteaseId = item.neteaseId,
|
||||
date = item.date,
|
||||
existFile = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package top.zhushenwudi.llmp.update
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import top.zhushenwudi.llmp.domain.VersionInfo
|
||||
import top.zhushenwudi.llmp.network.OssApi
|
||||
|
||||
class UpdateChecker(private val oss: OssApi) {
|
||||
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
|
||||
suspend fun check(currentCode: Int = 1): VersionInfo? {
|
||||
val raw = runCatching { oss.fetchVersionJson() }.getOrNull() ?: return null
|
||||
val info = runCatching { json.decodeFromString<VersionInfo>(raw) }.getOrNull()
|
||||
?: return null
|
||||
return if (info.versionCode > currentCode) info else null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package top.zhushenwudi.llmp.util
|
||||
|
||||
import top.zhushenwudi.llmp.domain.Album
|
||||
import top.zhushenwudi.llmp.domain.Music
|
||||
import top.zhushenwudi.llmp.platform.FileSystem
|
||||
import top.zhushenwudi.llmp.settings.AppSettings
|
||||
|
||||
class PathResolver(
|
||||
private val fs: FileSystem,
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
/**
|
||||
* Flutter `SDUtils.path`: prefer user-selected [AppSettings.sdPath], else [FileSystem.musicRoot].
|
||||
*/
|
||||
fun storageRoot(): String {
|
||||
val sd = settings.sdPath.trim()
|
||||
if (sd.isNotEmpty()) {
|
||||
return sd.trimEnd('/', '\\') + "/"
|
||||
}
|
||||
return fs.musicRoot
|
||||
}
|
||||
|
||||
fun absoluteMusicPath(music: Music): String? {
|
||||
val base = music.baseUrl ?: return null
|
||||
val path = music.musicPath ?: return null
|
||||
return storageRoot() + base.trimStart('/') + path.trimStart('/')
|
||||
}
|
||||
|
||||
fun absoluteCoverPath(music: Music): String? {
|
||||
val base = music.baseUrl ?: return null
|
||||
val path = music.coverPath ?: return null
|
||||
return storageRoot() + base.trimStart('/') + path.trimStart('/')
|
||||
}
|
||||
|
||||
fun absoluteAlbumCoverPath(album: Album): String? {
|
||||
val path = album.coverPath ?: return null
|
||||
return storageRoot() + path.trimStart('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Flutter [SDUtils.getImgPathFromMusic]: local file URI, HTTP URL, or null → UI shows logo.
|
||||
*/
|
||||
fun coverDisplayUri(music: Music): String? {
|
||||
if (music.musicId == null) return null
|
||||
if (music.existFile == true) {
|
||||
val path = absoluteCoverPath(music) ?: return null
|
||||
return if (fs.exists(path)) "file://$path" else null
|
||||
}
|
||||
if (settings.enableHttp && settings.httpUrl.isNotBlank()) {
|
||||
val rel = (music.baseUrl.orEmpty() + music.coverPath.orEmpty()).trimStart('/')
|
||||
if (rel.isBlank()) return null
|
||||
return settings.httpUrl.trimEnd('/') + "/" + rel
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Flutter [SDUtils.getImgPathFromAlbum]. */
|
||||
fun coverDisplayUri(album: Album): String? {
|
||||
val rel = album.coverPath ?: return null
|
||||
if (album.existFile == true) {
|
||||
val path = absoluteAlbumCoverPath(album) ?: return null
|
||||
return if (fs.exists(path)) "file://$path" else null
|
||||
}
|
||||
if (settings.enableHttp && settings.httpUrl.isNotBlank()) {
|
||||
return settings.httpUrl.trimEnd('/') + "/" + rel.trimStart('/')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun playbackUri(music: Music): String? {
|
||||
if (settings.enableHttp && settings.httpUrl.isNotBlank()) {
|
||||
val base = music.baseUrl.orEmpty()
|
||||
var path = music.musicPath.orEmpty()
|
||||
if (path.endsWith(".wav", ignoreCase = true)) {
|
||||
path = path.dropLast(4) + ".flac"
|
||||
}
|
||||
return settings.httpUrl.trimEnd('/') + "/" +
|
||||
(base + path).trimStart('/')
|
||||
}
|
||||
val local = absoluteMusicPath(music) ?: return null
|
||||
if (fs.exists(local)) return "file://$local"
|
||||
return music.musicPath?.takeIf {
|
||||
it.startsWith("http") || it.startsWith("asset")
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshExistFile(music: Music): Music {
|
||||
val path = absoluteMusicPath(music)
|
||||
return music.copy(existFile = path != null && fs.exists(path))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
-- Schema v2 — full library + playlist support
|
||||
|
||||
CREATE TABLE Music (
|
||||
musicId TEXT NOT NULL PRIMARY KEY,
|
||||
musicName TEXT,
|
||||
artist TEXT,
|
||||
artistBin TEXT,
|
||||
albumId TEXT,
|
||||
albumName TEXT,
|
||||
coverPath TEXT,
|
||||
musicPath TEXT,
|
||||
time TEXT,
|
||||
baseUrl TEXT,
|
||||
category TEXT,
|
||||
groupName TEXT,
|
||||
isLove INTEGER NOT NULL DEFAULT 0,
|
||||
timestamp INTEGER NOT NULL DEFAULT 0,
|
||||
neteaseId TEXT,
|
||||
date TEXT,
|
||||
existFile INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE Album (
|
||||
albumId TEXT NOT NULL PRIMARY KEY,
|
||||
albumName TEXT,
|
||||
date TEXT,
|
||||
coverPath TEXT,
|
||||
category TEXT,
|
||||
groupName TEXT,
|
||||
existFile INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE Artist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uid TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
photo TEXT NOT NULL,
|
||||
groupName TEXT NOT NULL,
|
||||
musicJson TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE TABLE Menu (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
isPhone INTEGER NOT NULL DEFAULT 1,
|
||||
musicJson TEXT NOT NULL DEFAULT '[]',
|
||||
date TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE Love (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
musicId TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE History (
|
||||
musicId TEXT NOT NULL PRIMARY KEY,
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE Lyric (
|
||||
uid TEXT NOT NULL PRIMARY KEY,
|
||||
jp TEXT,
|
||||
zh TEXT,
|
||||
roma TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE PlayListMusic (
|
||||
musicId TEXT NOT NULL PRIMARY KEY,
|
||||
musicName TEXT,
|
||||
artist TEXT,
|
||||
isPlaying INTEGER NOT NULL DEFAULT 0,
|
||||
sortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
selectAllMusic:
|
||||
SELECT * FROM Music ORDER BY musicName COLLATE NOCASE;
|
||||
|
||||
selectMusicById:
|
||||
SELECT * FROM Music WHERE musicId = ?;
|
||||
|
||||
selectMusicByAlbum:
|
||||
SELECT * FROM Music WHERE albumId = ? ORDER BY musicName COLLATE NOCASE;
|
||||
|
||||
selectMusicByGroup:
|
||||
SELECT * FROM Music WHERE groupName = ? ORDER BY musicName COLLATE NOCASE;
|
||||
|
||||
selectLovedMusic:
|
||||
SELECT m.* FROM Music m
|
||||
INNER JOIN Love l ON l.musicId = m.musicId
|
||||
ORDER BY l.timestamp DESC;
|
||||
|
||||
insertMusic:
|
||||
INSERT OR REPLACE INTO Music(
|
||||
musicId, musicName, artist, artistBin, albumId, albumName,
|
||||
coverPath, musicPath, time, baseUrl, category, groupName,
|
||||
isLove, timestamp, neteaseId, date, existFile
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
|
||||
updateMusicLove:
|
||||
UPDATE Music SET isLove = ? WHERE musicId = ?;
|
||||
|
||||
deleteAllMusic:
|
||||
DELETE FROM Music;
|
||||
|
||||
countMusic:
|
||||
SELECT COUNT(*) FROM Music;
|
||||
|
||||
selectAllAlbums:
|
||||
SELECT * FROM Album ORDER BY date DESC;
|
||||
|
||||
selectAlbumById:
|
||||
SELECT * FROM Album WHERE albumId = ?;
|
||||
|
||||
insertAlbum:
|
||||
INSERT OR REPLACE INTO Album(
|
||||
albumId, albumName, date, coverPath, category, groupName, existFile
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?);
|
||||
|
||||
deleteAllAlbums:
|
||||
DELETE FROM Album;
|
||||
|
||||
selectAllArtists:
|
||||
SELECT * FROM Artist ORDER BY name COLLATE NOCASE;
|
||||
|
||||
selectArtistByUid:
|
||||
SELECT * FROM Artist WHERE uid = ?;
|
||||
|
||||
insertArtist:
|
||||
INSERT INTO Artist(uid, name, photo, groupName, musicJson)
|
||||
VALUES (?, ?, ?, ?, ?);
|
||||
|
||||
updateArtistMusic:
|
||||
UPDATE Artist SET musicJson = ? WHERE uid = ?;
|
||||
|
||||
deleteAllArtists:
|
||||
DELETE FROM Artist;
|
||||
|
||||
selectAllMenus:
|
||||
SELECT * FROM Menu ORDER BY id;
|
||||
|
||||
selectMenuById:
|
||||
SELECT * FROM Menu WHERE id = ?;
|
||||
|
||||
insertMenu:
|
||||
INSERT OR REPLACE INTO Menu(id, isPhone, musicJson, date, name)
|
||||
VALUES (?, ?, ?, ?, ?);
|
||||
|
||||
deleteMenuById:
|
||||
DELETE FROM Menu WHERE id = ?;
|
||||
|
||||
deletePcMenus:
|
||||
DELETE FROM Menu WHERE id <= 100;
|
||||
|
||||
deleteAllMenus:
|
||||
DELETE FROM Menu;
|
||||
|
||||
selectAllLove:
|
||||
SELECT * FROM Love ORDER BY timestamp DESC;
|
||||
|
||||
insertLove:
|
||||
INSERT INTO Love(musicId, timestamp) VALUES (?, ?);
|
||||
|
||||
deleteLoveByMusicId:
|
||||
DELETE FROM Love WHERE musicId = ?;
|
||||
|
||||
deleteAllLove:
|
||||
DELETE FROM Love;
|
||||
|
||||
selectAllHistory:
|
||||
SELECT * FROM History ORDER BY timestamp DESC LIMIT 200;
|
||||
|
||||
insertHistory:
|
||||
INSERT OR REPLACE INTO History(musicId, timestamp) VALUES (?, ?);
|
||||
|
||||
deleteAllHistory:
|
||||
DELETE FROM History;
|
||||
|
||||
selectLyricByUid:
|
||||
SELECT * FROM Lyric WHERE uid = ?;
|
||||
|
||||
insertLyric:
|
||||
INSERT OR REPLACE INTO Lyric(uid, jp, zh, roma) VALUES (?, ?, ?, ?);
|
||||
|
||||
insertLyricNew:
|
||||
INSERT INTO Lyric(uid, jp, zh, roma) VALUES (?, ?, ?, ?);
|
||||
|
||||
updateLyricRow:
|
||||
UPDATE Lyric SET jp = ?, zh = ?, roma = ? WHERE uid = ?;
|
||||
|
||||
selectPlayList:
|
||||
SELECT * FROM PlayListMusic ORDER BY sortOrder ASC;
|
||||
|
||||
insertPlayListItem:
|
||||
INSERT OR REPLACE INTO PlayListMusic(musicId, musicName, artist, isPlaying, sortOrder)
|
||||
VALUES (?, ?, ?, ?, ?);
|
||||
|
||||
deletePlayList:
|
||||
DELETE FROM PlayListMusic;
|
||||
|
||||
clearPlayingFlags:
|
||||
UPDATE PlayListMusic SET isPlaying = 0;
|
||||
|
||||
setPlayingFlag:
|
||||
UPDATE PlayListMusic SET isPlaying = ? WHERE musicId = ?;
|
||||
@@ -0,0 +1,31 @@
|
||||
package top.zhushenwudi.llmp.lyric
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class LrcParserTest {
|
||||
@Test
|
||||
fun parse_basicLines() {
|
||||
val lrc = """
|
||||
[00:01.00]hello
|
||||
[00:02.50]world
|
||||
""".trimIndent()
|
||||
val lines = LrcParser.parse(lrc)
|
||||
assertEquals(2, lines.size)
|
||||
assertEquals(1000L, lines[0].timeMs)
|
||||
assertEquals("hello", lines[0].text)
|
||||
assertEquals(2500L, lines[1].timeMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun currentIndex_picksLatest() {
|
||||
val lines = listOf(
|
||||
LyricLine(0, "a"),
|
||||
LyricLine(1000, "b"),
|
||||
LyricLine(2000, "c"),
|
||||
)
|
||||
assertEquals(1, LrcParser.currentIndex(lines, 1500))
|
||||
assertEquals(2, LrcParser.currentIndex(lines, 2500))
|
||||
assertEquals(-1, LrcParser.currentIndex(emptyList(), 0))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package top.zhushenwudi.llmp.platform
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class DeepLinkParserTest {
|
||||
@Test
|
||||
fun parseOpenOnly() {
|
||||
val p = DeepLinkParser.parse("llmp://open")
|
||||
assertNotNull(p)
|
||||
assertNull(p.type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseMusicShare() {
|
||||
val p = DeepLinkParser.parse("llmp://share?type=1&musicId=abc123")
|
||||
assertNotNull(p)
|
||||
assertEquals(1, p.type)
|
||||
assertEquals("abc123", p.musicId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectNonLlmp() {
|
||||
assertNull(DeepLinkParser.parse("https://example.com"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package top.zhushenwudi.llmp.protocol
|
||||
|
||||
import top.zhushenwudi.llmp.Const
|
||||
import top.zhushenwudi.llmp.domain.FtpCmd
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class HandshakeTest {
|
||||
@Test
|
||||
fun buildVersionRequest_usesTransVer() {
|
||||
val req = Handshake.buildVersionRequest()
|
||||
assertEquals(ProtocolCmd.VERSION.wire, req.cmd)
|
||||
assertEquals(Const.TRANS_VER.toString(), req.body)
|
||||
assertEquals(1, Const.TRANS_VER)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verify_acceptsMatchingVersion() {
|
||||
val remote = FtpCmd(cmd = "version", body = "1")
|
||||
val result = Handshake.verify(remote)
|
||||
assertTrue(result.ok)
|
||||
assertEquals(1, result.remoteVersion)
|
||||
assertNull(result.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verify_rejectsMismatch() {
|
||||
val remote = FtpCmd(cmd = "version", body = "99")
|
||||
val result = Handshake.verify(remote)
|
||||
assertFalse(result.ok)
|
||||
assertEquals(99, result.remoteVersion)
|
||||
assertTrue(result.reason!!.contains("mismatch"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verify_rejectsWrongCmd() {
|
||||
val remote = FtpCmd(cmd = "system", body = "android")
|
||||
val result = Handshake.verify(remote)
|
||||
assertFalse(result.ok)
|
||||
assertNull(result.remoteVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifyVersions_helper() {
|
||||
assertTrue(Handshake.verifyVersions(1, 1))
|
||||
assertFalse(Handshake.verifyVersions(1, 2))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package top.zhushenwudi.llmp.db
|
||||
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.driver.native.NativeSqliteDriver
|
||||
|
||||
actual class DriverFactory {
|
||||
actual fun createDriver(): SqlDriver =
|
||||
NativeSqliteDriver(LlmpDatabase.Schema, "llmp.db")
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package top.zhushenwudi.llmp.db
|
||||
|
||||
import platform.Foundation.NSDate
|
||||
import platform.Foundation.timeIntervalSince1970
|
||||
|
||||
actual fun currentTimeMs(): Long =
|
||||
(NSDate().timeIntervalSince1970 * 1000.0).toLong()
|
||||
@@ -0,0 +1,6 @@
|
||||
package top.zhushenwudi.llmp.lyric
|
||||
|
||||
actual object PlatformInfo {
|
||||
// Align with Flutter: iOS may restrict JP/ROMA until EULA accepted.
|
||||
actual val allowEulaLyric: Boolean = true
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package top.zhushenwudi.llmp.network
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.darwin.Darwin
|
||||
|
||||
actual fun createPlatformHttpClient(): HttpClient = HttpClient(Darwin)
|
||||
@@ -0,0 +1,23 @@
|
||||
package top.zhushenwudi.llmp.platform
|
||||
|
||||
actual object AppLog {
|
||||
actual fun d(tag: String, message: String) {
|
||||
println("D/$tag: $message")
|
||||
}
|
||||
|
||||
actual fun i(tag: String, message: String) {
|
||||
println("I/$tag: $message")
|
||||
}
|
||||
|
||||
actual fun w(tag: String, message: String) {
|
||||
println("W/$tag: $message")
|
||||
}
|
||||
|
||||
actual fun e(tag: String, message: String, throwable: Throwable?) {
|
||||
if (throwable != null) {
|
||||
println("E/$tag: $message\n${throwable.stackTraceToString()}")
|
||||
} else {
|
||||
println("E/$tag: $message")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package top.zhushenwudi.llmp.platform
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.usePinned
|
||||
import platform.Foundation.NSData
|
||||
import platform.Foundation.NSDocumentDirectory
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSSearchPathForDirectoriesInDomains
|
||||
import platform.Foundation.NSUserDomainMask
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual class FileSystem {
|
||||
actual val filesRoot: String
|
||||
get() {
|
||||
val paths = NSSearchPathForDirectoriesInDomains(
|
||||
NSDocumentDirectory,
|
||||
NSUserDomainMask,
|
||||
true,
|
||||
)
|
||||
val docs = (paths.firstOrNull() as? String) ?: ""
|
||||
return if (docs.endsWith("/")) docs else "$docs/"
|
||||
}
|
||||
|
||||
actual val musicRoot: String
|
||||
get() = filesRoot
|
||||
|
||||
actual fun exists(absolutePath: String): Boolean =
|
||||
NSFileManager.defaultManager.fileExistsAtPath(absolutePath)
|
||||
|
||||
actual fun ensureDir(absolutePath: String): Boolean =
|
||||
NSFileManager.defaultManager.createDirectoryAtPath(
|
||||
absolutePath,
|
||||
withIntermediateDirectories = true,
|
||||
attributes = null,
|
||||
error = null,
|
||||
)
|
||||
|
||||
actual fun writeBytes(absolutePath: String, bytes: ByteArray): Boolean {
|
||||
if (bytes.isEmpty()) return false
|
||||
val parent = absolutePath.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
if (parent.isNotEmpty()) {
|
||||
ensureDir(parent)
|
||||
}
|
||||
return bytes.usePinned { pinned ->
|
||||
val data = NSData.create(
|
||||
bytes = pinned.addressOf(0),
|
||||
length = bytes.size.toULong(),
|
||||
)
|
||||
data.writeToFile(absolutePath, atomically = true)
|
||||
}
|
||||
}
|
||||
|
||||
actual fun listFiles(absoluteDir: String): List<String> {
|
||||
val fm = NSFileManager.defaultManager
|
||||
val contents = fm.contentsOfDirectoryAtPath(absoluteDir, error = null) ?: return emptyList()
|
||||
return contents.mapNotNull { name ->
|
||||
val fileName = name as? String ?: return@mapNotNull null
|
||||
val path = absoluteDir.trimEnd('/') + "/" + fileName
|
||||
val attrs = fm.attributesOfItemAtPath(path, error = null) ?: return@mapNotNull null
|
||||
val type = attrs[platform.Foundation.NSFileType] as? String
|
||||
if (type == platform.Foundation.NSFileTypeDirectory) null else path
|
||||
}
|
||||
}
|
||||
|
||||
actual fun listUsbRoots(): List<String> = emptyList()
|
||||
}
|
||||
|
||||
actual fun createFileSystem(): FileSystem = FileSystem()
|
||||
@@ -0,0 +1,226 @@
|
||||
package top.zhushenwudi.llmp.platform
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSNotificationCenter
|
||||
import platform.Foundation.NSUserDefaults
|
||||
|
||||
/** App Group shared with WidgetKit extension and Swift host. */
|
||||
const val IOS_APP_GROUP = "group.top.zhushenwudi.llmp"
|
||||
|
||||
const val IOS_NOTIFICATION_DESKTOP_LYRIC = "llmpDesktopLyricUpdate"
|
||||
const val IOS_NOTIFICATION_PIP = "llmpPiPRequest"
|
||||
const val IOS_NOTIFICATION_SHARE = "llmpShareRequest"
|
||||
const val IOS_NOTIFICATION_DEEP_LINK = "llmpDeepLink"
|
||||
const val IOS_NOTIFICATION_WIDGET = "llmpWidgetAction"
|
||||
const val IOS_NOTIFICATION_CARPLAY = "llmpCarPlayAction"
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
object WidgetDataWriter {
|
||||
private val defaults: NSUserDefaults?
|
||||
get() = NSUserDefaults(suiteName = IOS_APP_GROUP)
|
||||
|
||||
fun writeString(key: String, value: String?) {
|
||||
defaults?.setObject(value, forKey = key)
|
||||
defaults?.synchronize()
|
||||
}
|
||||
|
||||
fun writeBool(key: String, value: Boolean) {
|
||||
defaults?.setBool(value, forKey = key)
|
||||
defaults?.synchronize()
|
||||
}
|
||||
|
||||
fun writeInt(key: String, value: Int) {
|
||||
defaults?.setInteger(value.toLong(), forKey = key)
|
||||
defaults?.synchronize()
|
||||
}
|
||||
|
||||
fun writeShareImage(fromPath: String?) {
|
||||
if (fromPath.isNullOrBlank()) return
|
||||
val container = NSFileManager.defaultManager
|
||||
.containerURLForSecurityApplicationGroupIdentifier(IOS_APP_GROUP)
|
||||
?: return
|
||||
val dest = container.URLByAppendingPathComponent("sharedImage.png")?.path ?: return
|
||||
if (NSFileManager.defaultManager.fileExistsAtPath(fromPath)) {
|
||||
NSFileManager.defaultManager.removeItemAtPath(dest, error = null)
|
||||
NSFileManager.defaultManager.copyItemAtPath(fromPath, toPath = dest, error = null)
|
||||
}
|
||||
}
|
||||
|
||||
fun reloadWidgets() {
|
||||
// Swift host / Widget Extension observe this and call WidgetCenter.reloadAllTimelines().
|
||||
post("llmpReloadWidgets")
|
||||
}
|
||||
|
||||
fun post(name: String, userInfo: Map<String, String> = emptyMap()) {
|
||||
val info = userInfo.mapKeys { it.key as Any? }.mapValues { it.value as Any? }
|
||||
NSNotificationCenter.defaultCenter.postNotificationName(
|
||||
aName = name,
|
||||
`object` = null,
|
||||
userInfo = info,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class IosDesktopLyricController : DesktopLyricController {
|
||||
override var enabled: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
WidgetDataWriter.writeBool("desktopLyricEnabled", value)
|
||||
WidgetDataWriter.post(IOS_NOTIFICATION_DESKTOP_LYRIC, mapOf("enabled" to value.toString()))
|
||||
}
|
||||
|
||||
override fun show() {
|
||||
enabled = true
|
||||
WidgetDataWriter.post(IOS_NOTIFICATION_PIP, mapOf("action" to "show"))
|
||||
}
|
||||
|
||||
override fun hide() {
|
||||
enabled = false
|
||||
WidgetDataWriter.post(IOS_NOTIFICATION_PIP, mapOf("action" to "hide"))
|
||||
}
|
||||
|
||||
override fun updateLines(line1: String?, line2: String?, currentLine: Int) {
|
||||
WidgetDataWriter.writeString("curJpLrc", line1)
|
||||
WidgetDataWriter.writeString("nextJpLrc", line2)
|
||||
WidgetDataWriter.writeInt("pipCurrentLine", currentLine)
|
||||
WidgetDataWriter.writeString("lyricLine1", line1)
|
||||
WidgetDataWriter.writeString("lyricLine2", line2)
|
||||
WidgetDataWriter.writeInt("currentLine", currentLine)
|
||||
WidgetDataWriter.post(
|
||||
IOS_NOTIFICATION_DESKTOP_LYRIC,
|
||||
mapOf(
|
||||
"line1" to (line1 ?: ""),
|
||||
"line2" to (line2 ?: ""),
|
||||
"currentLine" to currentLine.toString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun setPlaying(playing: Boolean) {
|
||||
WidgetDataWriter.writeBool("pipIsPlaying", playing)
|
||||
WidgetDataWriter.post(IOS_NOTIFICATION_PIP, mapOf("playing" to playing.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
class IosHomeWidgetController : HomeWidgetController {
|
||||
override fun update(
|
||||
songName: String?,
|
||||
artist: String?,
|
||||
isPlaying: Boolean,
|
||||
favorite: Boolean,
|
||||
lyricLine1: String?,
|
||||
lyricLine2: String?,
|
||||
currentLine: Int,
|
||||
coverPath: String?,
|
||||
playText: String,
|
||||
) {
|
||||
WidgetDataWriter.writeString("songName", songName ?: "")
|
||||
WidgetDataWriter.writeString("songArtist", artist ?: "")
|
||||
WidgetDataWriter.writeBool("songFavorite", favorite)
|
||||
WidgetDataWriter.writeBool("isPlaying", isPlaying)
|
||||
WidgetDataWriter.writeString("playText", playText)
|
||||
WidgetDataWriter.writeString("lyricLine1", lyricLine1)
|
||||
WidgetDataWriter.writeString("lyricLine2", lyricLine2)
|
||||
WidgetDataWriter.writeInt("currentLine", currentLine)
|
||||
WidgetDataWriter.writeString("bgColor", "255,255,255")
|
||||
coverPath?.let { WidgetDataWriter.writeShareImage(it) }
|
||||
WidgetDataWriter.reloadWidgets()
|
||||
}
|
||||
|
||||
override fun setShutdown(shutdown: Boolean) {
|
||||
WidgetDataWriter.writeBool("isShutdown", shutdown)
|
||||
if (shutdown) {
|
||||
WidgetDataWriter.writeBool("isPlaying", false)
|
||||
}
|
||||
WidgetDataWriter.reloadWidgets()
|
||||
}
|
||||
|
||||
override fun refresh() {
|
||||
WidgetDataWriter.reloadWidgets()
|
||||
}
|
||||
}
|
||||
|
||||
class IosShareBridge : ShareBridge {
|
||||
override fun shareText(text: String, title: String?) {
|
||||
WidgetDataWriter.writeString("pendingShareText", text)
|
||||
WidgetDataWriter.writeString("pendingShareTitle", title ?: "")
|
||||
WidgetDataWriter.writeString("pendingShareUrl", "")
|
||||
WidgetDataWriter.post(
|
||||
IOS_NOTIFICATION_SHARE,
|
||||
mapOf("type" to "text", "text" to text, "title" to (title ?: "")),
|
||||
)
|
||||
}
|
||||
|
||||
override fun shareUrl(url: String, title: String?) {
|
||||
WidgetDataWriter.writeString("pendingShareUrl", url)
|
||||
WidgetDataWriter.writeString("pendingShareTitle", title ?: "")
|
||||
WidgetDataWriter.writeString("pendingShareText", "")
|
||||
WidgetDataWriter.post(
|
||||
IOS_NOTIFICATION_SHARE,
|
||||
mapOf("type" to "url", "url" to url, "title" to (title ?: "")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class IosDeepLinkHandler : 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
|
||||
WidgetDataWriter.writeString("pendingDeepLink", url)
|
||||
WidgetDataWriter.post(IOS_NOTIFICATION_DEEP_LINK, mapOf("url" to url))
|
||||
}
|
||||
}
|
||||
|
||||
class IosCarPlayHook : CarPlayHook {
|
||||
private var catalog: CarPlayCatalog = CarPlayCatalog()
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override fun bind(catalog: CarPlayCatalog) {
|
||||
this.catalog = catalog
|
||||
}
|
||||
|
||||
override fun refreshCatalog() {
|
||||
WidgetDataWriter.writeString("carplay_nowPlaying", catalog.nowPlayingTitle)
|
||||
WidgetDataWriter.writeString("carplay_nowPlayingCover", catalog.nowPlayingCover ?: "")
|
||||
WidgetDataWriter.writeString("carplay_groups", json.encodeToString(catalog.groups))
|
||||
WidgetDataWriter.writeString("carplay_love", json.encodeToString(catalog.loveSongs))
|
||||
WidgetDataWriter.writeString("carplay_menus", json.encodeToString(catalog.menus))
|
||||
WidgetDataWriter.post(IOS_NOTIFICATION_CARPLAY, mapOf("action" to "refresh"))
|
||||
}
|
||||
}
|
||||
|
||||
class IosPiPHook : PictureInPictureHook {
|
||||
override fun enterPiP() {
|
||||
WidgetDataWriter.post(IOS_NOTIFICATION_PIP, mapOf("action" to "enter"))
|
||||
}
|
||||
}
|
||||
|
||||
class IosMediaSessionBridge : MediaSessionHook {
|
||||
override fun updateSession(title: String?, artist: String?, isPlaying: Boolean) {
|
||||
WidgetDataWriter.writeString("songName", title ?: "")
|
||||
WidgetDataWriter.writeString("songArtist", artist ?: "")
|
||||
WidgetDataWriter.writeBool("isPlaying", isPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
fun installIosPlatformServices() {
|
||||
PlatformServices.install(
|
||||
desktopLyric = IosDesktopLyricController(),
|
||||
homeWidget = IosHomeWidgetController(),
|
||||
deepLink = IosDeepLinkHandler(),
|
||||
usbMount = NoOpUsbMount,
|
||||
share = IosShareBridge(),
|
||||
mediaSession = IosMediaSessionBridge(),
|
||||
analytics = NoOpAnalytics,
|
||||
carPlay = IosCarPlayHook(),
|
||||
pip = IosPiPHook(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package top.zhushenwudi.llmp.platform
|
||||
|
||||
import platform.Foundation.NSURL
|
||||
import platform.UIKit.UIApplication
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
actual object PlatformUi {
|
||||
actual fun openQrScanner(onResult: (String) -> Unit) {
|
||||
// Camera QR on iOS is hosted by Swift; common UI still allows IP paste.
|
||||
onResult("")
|
||||
}
|
||||
|
||||
actual fun openWebView(url: String, title: String?) {
|
||||
val nsUrl = NSURL.URLWithString(url) ?: return
|
||||
UIApplication.sharedApplication.openURL(nsUrl)
|
||||
}
|
||||
|
||||
actual fun requestOverlayPermission() = Unit
|
||||
|
||||
actual fun moveTaskToBack() = Unit
|
||||
|
||||
actual fun showToast(message: String) {
|
||||
// Native toast hosted by Swift later; no-op for now.
|
||||
}
|
||||
|
||||
actual fun pickImage(onResult: (String?) -> Unit) {
|
||||
onResult(null)
|
||||
}
|
||||
|
||||
actual fun openExternalUrl(url: String) {
|
||||
val nsUrl = NSURL.URLWithString(url) ?: return
|
||||
UIApplication.sharedApplication.openURL(nsUrl)
|
||||
}
|
||||
|
||||
actual fun exitApp() {
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
package top.zhushenwudi.llmp.player
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import platform.AVFAudio.AVAudioSession
|
||||
import platform.AVFAudio.AVAudioSessionCategoryPlayback
|
||||
import platform.AVFAudio.AVAudioSessionRouteChangeNotification
|
||||
import platform.AVFAudio.AVAudioSessionRouteChangeReasonKey
|
||||
import platform.AVFAudio.AVAudioSessionRouteChangeReasonOldDeviceUnavailable
|
||||
import platform.AVFoundation.AVPlayer
|
||||
import platform.AVFoundation.AVPlayerItem
|
||||
import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification
|
||||
import platform.AVFoundation.AVURLAsset
|
||||
import platform.AVFoundation.replaceCurrentItemWithPlayerItem
|
||||
import platform.AVFoundation.seekToTime
|
||||
import platform.CoreMedia.CMTimeGetSeconds
|
||||
import platform.CoreMedia.CMTimeMake
|
||||
import platform.Foundation.NSBundle
|
||||
import platform.Foundation.NSNotification
|
||||
import platform.Foundation.NSNotificationCenter
|
||||
import platform.Foundation.NSNumber
|
||||
import platform.Foundation.NSURL
|
||||
import platform.MediaPlayer.MPChangePlaybackPositionCommandEvent
|
||||
import platform.MediaPlayer.MPMediaItemArtwork
|
||||
import platform.MediaPlayer.MPMediaItemPropertyAlbumTitle
|
||||
import platform.MediaPlayer.MPMediaItemPropertyArtist
|
||||
import platform.MediaPlayer.MPMediaItemPropertyArtwork
|
||||
import platform.MediaPlayer.MPMediaItemPropertyPlaybackDuration
|
||||
import platform.MediaPlayer.MPMediaItemPropertyTitle
|
||||
import platform.MediaPlayer.MPNowPlayingInfoCenter
|
||||
import platform.MediaPlayer.MPNowPlayingInfoPropertyElapsedPlaybackTime
|
||||
import platform.MediaPlayer.MPNowPlayingInfoPropertyPlaybackRate
|
||||
import platform.MediaPlayer.MPRemoteCommandCenter
|
||||
import platform.MediaPlayer.MPRemoteCommandHandlerStatusSuccess
|
||||
import platform.UIKit.UIImage
|
||||
import top.zhushenwudi.llmp.domain.Music
|
||||
import kotlin.random.Random
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual class PlayerController {
|
||||
private val player = AVPlayer()
|
||||
private val scope = CoroutineScope(Dispatchers.Main.immediate)
|
||||
private var positionJob: Job? = null
|
||||
private var endObserver: Any? = null
|
||||
private var routeObserver: Any? = null
|
||||
|
||||
private val _state = MutableStateFlow(PlayerState())
|
||||
actual val state: StateFlow<PlayerState> = _state.asStateFlow()
|
||||
|
||||
actual var resolvePlaybackUri: ((Music) -> String?)? = null
|
||||
actual var resolveCoverUri: ((Music) -> String?)? = null
|
||||
|
||||
private var queue: List<Music> = emptyList()
|
||||
private var loopMode: LoopMode = LoopMode.LIST
|
||||
private var cachedArtworkMusicId: String? = null
|
||||
private var cachedArtwork: MPMediaItemArtwork? = null
|
||||
|
||||
init {
|
||||
setupAudioSession()
|
||||
setupRemoteCommands()
|
||||
observePlaybackEnd()
|
||||
observeAudioRouteChanges()
|
||||
}
|
||||
|
||||
private fun setupAudioSession() {
|
||||
val session = AVAudioSession.sharedInstance()
|
||||
session.setCategory(AVAudioSessionCategoryPlayback, error = null)
|
||||
session.setActive(true, error = null)
|
||||
}
|
||||
|
||||
private fun setupRemoteCommands() {
|
||||
val center = MPRemoteCommandCenter.sharedCommandCenter()
|
||||
center.playCommand.setEnabled(true)
|
||||
center.pauseCommand.setEnabled(true)
|
||||
center.togglePlayPauseCommand.setEnabled(true)
|
||||
center.nextTrackCommand.setEnabled(true)
|
||||
center.previousTrackCommand.setEnabled(true)
|
||||
center.changePlaybackPositionCommand.setEnabled(true)
|
||||
|
||||
center.playCommand.addTargetWithHandler { _ ->
|
||||
play()
|
||||
MPRemoteCommandHandlerStatusSuccess
|
||||
}
|
||||
center.pauseCommand.addTargetWithHandler { _ ->
|
||||
pause()
|
||||
MPRemoteCommandHandlerStatusSuccess
|
||||
}
|
||||
center.togglePlayPauseCommand.addTargetWithHandler { _ ->
|
||||
toggle()
|
||||
MPRemoteCommandHandlerStatusSuccess
|
||||
}
|
||||
center.nextTrackCommand.addTargetWithHandler { _ ->
|
||||
next()
|
||||
MPRemoteCommandHandlerStatusSuccess
|
||||
}
|
||||
center.previousTrackCommand.addTargetWithHandler { _ ->
|
||||
previous()
|
||||
MPRemoteCommandHandlerStatusSuccess
|
||||
}
|
||||
center.changePlaybackPositionCommand.addTargetWithHandler { event ->
|
||||
val posEvent = event as? MPChangePlaybackPositionCommandEvent
|
||||
if (posEvent != null) {
|
||||
seekTo((posEvent.positionTime * 1000).toLong())
|
||||
}
|
||||
MPRemoteCommandHandlerStatusSuccess
|
||||
}
|
||||
}
|
||||
|
||||
private fun observePlaybackEnd() {
|
||||
endObserver = NSNotificationCenter.defaultCenter.addObserverForName(
|
||||
name = AVPlayerItemDidPlayToEndTimeNotification,
|
||||
`object` = null,
|
||||
queue = null,
|
||||
) { _ ->
|
||||
onPlaybackEnded()
|
||||
}
|
||||
}
|
||||
|
||||
/** Pause when headphones / BT audio route becomes unavailable (unplug / disconnect). */
|
||||
private fun observeAudioRouteChanges() {
|
||||
routeObserver = NSNotificationCenter.defaultCenter.addObserverForName(
|
||||
name = AVAudioSessionRouteChangeNotification,
|
||||
`object` = null,
|
||||
queue = null,
|
||||
) { notification: NSNotification? ->
|
||||
val reason = (notification?.userInfo?.get(AVAudioSessionRouteChangeReasonKey) as? NSNumber)
|
||||
?.unsignedLongValue
|
||||
if (reason == AVAudioSessionRouteChangeReasonOldDeviceUnavailable) {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPlaybackEnded() {
|
||||
when (loopMode) {
|
||||
LoopMode.SINGLE -> {
|
||||
seekTo(0)
|
||||
play()
|
||||
}
|
||||
LoopMode.LIST -> {
|
||||
if (_state.value.index < queue.lastIndex) {
|
||||
loadIndex(_state.value.index + 1, autoPlay = true)
|
||||
} else if (queue.isNotEmpty()) {
|
||||
loadIndex(0, autoPlay = true)
|
||||
} else {
|
||||
_state.update { it.copy(isPlaying = false, positionMs = 0) }
|
||||
}
|
||||
}
|
||||
LoopMode.SHUFFLE -> {
|
||||
if (queue.size <= 1) {
|
||||
seekTo(0)
|
||||
play()
|
||||
} else {
|
||||
val current = _state.value.index
|
||||
var nextIndex = Random.nextInt(queue.size)
|
||||
while (nextIndex == current) {
|
||||
nextIndex = Random.nextInt(queue.size)
|
||||
}
|
||||
loadIndex(nextIndex, autoPlay = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPositionUpdates() {
|
||||
positionJob?.cancel()
|
||||
positionJob = scope.launch {
|
||||
while (isActive) {
|
||||
val positionMs = currentPositionMs()
|
||||
val durationMs = currentDurationMs()
|
||||
_state.update {
|
||||
it.copy(
|
||||
positionMs = positionMs,
|
||||
durationMs = durationMs,
|
||||
isPlaying = player.rate > 0f,
|
||||
)
|
||||
}
|
||||
updateNowPlayingInfo()
|
||||
delay(200)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentPositionMs(): Long {
|
||||
val seconds = CMTimeGetSeconds(player.currentTime())
|
||||
if (seconds.isNaN() || seconds < 0) return 0
|
||||
return (seconds * 1000).toLong()
|
||||
}
|
||||
|
||||
private fun currentDurationMs(): Long {
|
||||
val item = player.currentItem ?: return 0
|
||||
val seconds = CMTimeGetSeconds(item.duration)
|
||||
if (seconds.isNaN() || seconds.isInfinite() || seconds < 0) return 0
|
||||
return (seconds * 1000).toLong()
|
||||
}
|
||||
|
||||
/**
|
||||
* System Now Playing / Dynamic Island / Control Center / lock screen.
|
||||
* Artwork + album enable the rich Dynamic Island media capsule.
|
||||
*/
|
||||
private fun updateNowPlayingInfo() {
|
||||
val s = _state.value
|
||||
val current = s.current ?: return
|
||||
val title = current.musicName ?: return
|
||||
val artist = current.artist.orEmpty()
|
||||
val album = current.albumName.orEmpty()
|
||||
val durationSec = s.durationMs.coerceAtLeast(0) / 1000.0
|
||||
val elapsedSec = s.positionMs.coerceAtLeast(0) / 1000.0
|
||||
val rate = if (s.isPlaying) 1.0 else 0.0
|
||||
val info = mutableMapOf<Any?, Any?>(
|
||||
MPMediaItemPropertyTitle to title,
|
||||
MPMediaItemPropertyArtist to artist,
|
||||
MPMediaItemPropertyAlbumTitle to album,
|
||||
MPMediaItemPropertyPlaybackDuration to durationSec,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime to elapsedSec,
|
||||
MPNowPlayingInfoPropertyPlaybackRate to rate,
|
||||
)
|
||||
artworkFor(current)?.let { art ->
|
||||
info[MPMediaItemPropertyArtwork] = art
|
||||
}
|
||||
MPNowPlayingInfoCenter.defaultCenter().nowPlayingInfo = info
|
||||
}
|
||||
|
||||
private fun artworkFor(music: Music): MPMediaItemArtwork? {
|
||||
val id = music.musicId
|
||||
if (id != null && id == cachedArtworkMusicId) return cachedArtwork
|
||||
val raw = resolveCoverUri?.invoke(music) ?: return null
|
||||
val path = when {
|
||||
raw.startsWith("file://") -> raw.removePrefix("file://")
|
||||
raw.startsWith("http://") || raw.startsWith("https://") -> return null
|
||||
else -> raw
|
||||
}
|
||||
val image = UIImage.imageWithContentsOfFile(path) ?: return null
|
||||
val artwork = MPMediaItemArtwork(boundsSize = image.size) { _ -> image }
|
||||
cachedArtworkMusicId = id
|
||||
cachedArtwork = artwork
|
||||
return artwork
|
||||
}
|
||||
|
||||
actual fun setQueue(items: List<Music>, startIndex: Int) {
|
||||
queue = items
|
||||
loopMode = _state.value.loopMode
|
||||
if (items.isEmpty()) {
|
||||
player.pause()
|
||||
player.replaceCurrentItemWithPlayerItem(null)
|
||||
_state.update {
|
||||
it.copy(queue = emptyList(), index = -1, current = null, isPlaying = false)
|
||||
}
|
||||
return
|
||||
}
|
||||
val index = startIndex.coerceIn(0, items.lastIndex)
|
||||
loadIndex(index, autoPlay = false)
|
||||
_state.update { it.copy(queue = items, loopMode = loopMode) }
|
||||
}
|
||||
|
||||
actual fun play() {
|
||||
player.play()
|
||||
_state.update { it.copy(isPlaying = true, error = null) }
|
||||
startPositionUpdates()
|
||||
updateNowPlayingInfo()
|
||||
}
|
||||
|
||||
actual fun pause() {
|
||||
player.pause()
|
||||
positionJob?.cancel()
|
||||
_state.update { it.copy(isPlaying = false) }
|
||||
updateNowPlayingInfo()
|
||||
}
|
||||
|
||||
actual fun toggle() {
|
||||
if (_state.value.isPlaying) pause() else play()
|
||||
}
|
||||
|
||||
actual fun seekTo(positionMs: Long) {
|
||||
val time = CMTimeMake(value = positionMs, timescale = 1000)
|
||||
player.seekToTime(time)
|
||||
_state.update { it.copy(positionMs = positionMs) }
|
||||
updateNowPlayingInfo()
|
||||
}
|
||||
|
||||
actual fun next() {
|
||||
if (queue.isEmpty()) return
|
||||
when (loopMode) {
|
||||
LoopMode.SHUFFLE -> onPlaybackEnded()
|
||||
else -> {
|
||||
val next = if (_state.value.index < queue.lastIndex) {
|
||||
_state.value.index + 1
|
||||
} else if (loopMode == LoopMode.LIST) {
|
||||
0
|
||||
} else {
|
||||
return
|
||||
}
|
||||
loadIndex(next, autoPlay = _state.value.isPlaying)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actual fun previous() {
|
||||
if (queue.isEmpty()) return
|
||||
val pos = currentPositionMs()
|
||||
if (pos > 3000) {
|
||||
seekTo(0)
|
||||
return
|
||||
}
|
||||
val prev = if (_state.value.index > 0) {
|
||||
_state.value.index - 1
|
||||
} else if (loopMode == LoopMode.LIST) {
|
||||
queue.lastIndex
|
||||
} else {
|
||||
0
|
||||
}
|
||||
loadIndex(prev, autoPlay = _state.value.isPlaying)
|
||||
}
|
||||
|
||||
actual fun playAtIndex(index: Int) {
|
||||
if (index !in queue.indices) return
|
||||
loadIndex(index, autoPlay = _state.value.isPlaying)
|
||||
}
|
||||
|
||||
actual fun setLoopMode(mode: LoopMode) {
|
||||
loopMode = mode
|
||||
_state.update { it.copy(loopMode = mode) }
|
||||
}
|
||||
|
||||
actual fun removeAt(index: Int) {
|
||||
if (index !in queue.indices) return
|
||||
if (queue.size == 1) {
|
||||
clearQueue()
|
||||
return
|
||||
}
|
||||
val wasPlaying = _state.value.isPlaying
|
||||
val currentIndex = _state.value.index
|
||||
val removingCurrent = index == currentIndex
|
||||
val newQueue = queue.toMutableList().also { it.removeAt(index) }
|
||||
queue = newQueue
|
||||
if (!removingCurrent) {
|
||||
val newIdx = when {
|
||||
index < currentIndex -> currentIndex - 1
|
||||
else -> currentIndex
|
||||
}.coerceIn(0, newQueue.lastIndex)
|
||||
_state.update {
|
||||
it.copy(queue = newQueue, index = newIdx, current = newQueue.getOrNull(newIdx))
|
||||
}
|
||||
return
|
||||
}
|
||||
val nextIndex = when {
|
||||
loopMode == LoopMode.SHUFFLE -> Random.nextInt(newQueue.size)
|
||||
index > newQueue.lastIndex -> 0
|
||||
else -> index
|
||||
}
|
||||
loadIndex(nextIndex.coerceIn(0, newQueue.lastIndex), autoPlay = wasPlaying)
|
||||
_state.update { it.copy(queue = newQueue) }
|
||||
}
|
||||
|
||||
actual fun clearQueue() {
|
||||
positionJob?.cancel()
|
||||
player.pause()
|
||||
player.replaceCurrentItemWithPlayerItem(null)
|
||||
queue = emptyList()
|
||||
cachedArtwork = null
|
||||
cachedArtworkMusicId = null
|
||||
MPNowPlayingInfoCenter.defaultCenter().nowPlayingInfo = null
|
||||
_state.update {
|
||||
it.copy(
|
||||
queue = emptyList(),
|
||||
index = -1,
|
||||
current = null,
|
||||
isPlaying = false,
|
||||
positionMs = 0,
|
||||
durationMs = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
actual fun playUri(uri: String, title: String?) {
|
||||
val music = Music(
|
||||
musicId = "local",
|
||||
musicName = title ?: uri.substringAfterLast('/'),
|
||||
musicPath = uri,
|
||||
)
|
||||
queue = listOf(music)
|
||||
loopMode = _state.value.loopMode
|
||||
val url = resolveUrl(uri)
|
||||
if (url == null) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
current = music,
|
||||
queue = queue,
|
||||
index = 0,
|
||||
isPlaying = false,
|
||||
error = "Cannot resolve playback URL: $uri",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
val asset = AVURLAsset(uRL = url, options = null)
|
||||
val item = AVPlayerItem(asset = asset)
|
||||
player.replaceCurrentItemWithPlayerItem(item)
|
||||
player.play()
|
||||
_state.update {
|
||||
it.copy(
|
||||
current = music,
|
||||
queue = queue,
|
||||
index = 0,
|
||||
isPlaying = true,
|
||||
error = null,
|
||||
positionMs = 0,
|
||||
)
|
||||
}
|
||||
startPositionUpdates()
|
||||
updateNowPlayingInfo()
|
||||
}
|
||||
|
||||
actual fun release() {
|
||||
positionJob?.cancel()
|
||||
player.pause()
|
||||
player.replaceCurrentItemWithPlayerItem(null)
|
||||
endObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) }
|
||||
endObserver = null
|
||||
routeObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) }
|
||||
routeObserver = null
|
||||
queue = emptyList()
|
||||
_state.value = PlayerState()
|
||||
}
|
||||
|
||||
private fun loadIndex(index: Int, autoPlay: Boolean) {
|
||||
val music = queue.getOrNull(index) ?: return
|
||||
val path = resolvePlaybackUri?.invoke(music) ?: music.musicPath
|
||||
if (path.isNullOrBlank()) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
index = index,
|
||||
current = music,
|
||||
error = "Missing music path",
|
||||
isPlaying = false,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
val url = resolveUrl(path)
|
||||
if (url == null) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
index = index,
|
||||
current = music,
|
||||
error = "Cannot resolve URL: $path",
|
||||
isPlaying = false,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
val asset = AVURLAsset(uRL = url, options = null)
|
||||
val item = AVPlayerItem(asset = asset)
|
||||
player.replaceCurrentItemWithPlayerItem(item)
|
||||
if (autoPlay) {
|
||||
player.play()
|
||||
startPositionUpdates()
|
||||
} else {
|
||||
player.pause()
|
||||
positionJob?.cancel()
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
index = index,
|
||||
current = music,
|
||||
isPlaying = autoPlay,
|
||||
error = null,
|
||||
positionMs = 0,
|
||||
)
|
||||
}
|
||||
updateNowPlayingInfo()
|
||||
}
|
||||
|
||||
private fun resolveUrl(raw: String): NSURL? {
|
||||
return when {
|
||||
raw.startsWith("http://") || raw.startsWith("https://") ||
|
||||
raw.startsWith("file://") -> NSURL.URLWithString(raw)
|
||||
raw.startsWith("asset:///") -> {
|
||||
val name = raw.removePrefix("asset:///").substringBeforeLast('.')
|
||||
val ext = raw.substringAfterLast('.', "")
|
||||
val path = NSBundle.mainBundle.pathForResource(name, ext)
|
||||
path?.let { NSURL.fileURLWithPath(it) }
|
||||
}
|
||||
raw.startsWith("asset://") -> {
|
||||
val name = raw.removePrefix("asset://").trimStart('/').substringBeforeLast('.')
|
||||
val ext = raw.substringAfterLast('.', "")
|
||||
val path = NSBundle.mainBundle.pathForResource(name, ext)
|
||||
path?.let { NSURL.fileURLWithPath(it) }
|
||||
}
|
||||
else -> NSURL.fileURLWithPath(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package top.zhushenwudi.llmp.settings
|
||||
|
||||
import com.russhwolf.settings.NSUserDefaultsSettings
|
||||
import com.russhwolf.settings.Settings
|
||||
import platform.Foundation.NSUserDefaults
|
||||
|
||||
actual fun createSettings(): Settings =
|
||||
NSUserDefaultsSettings(NSUserDefaults.standardUserDefaults)
|
||||
Reference in New Issue
Block a user