initial commit

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

68
shared/build.gradle.kts Normal file
View 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)
}
}
}

View File

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

View File

@@ -0,0 +1,3 @@
package top.zhushenwudi.llmp.db
actual fun currentTimeMs(): Long = System.currentTimeMillis()

View File

@@ -0,0 +1,5 @@
package top.zhushenwudi.llmp.lyric
actual object PlatformInfo {
actual val allowEulaLyric: Boolean = true
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = "",
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package top.zhushenwudi.llmp.player
enum class LoopMode {
LIST,
SINGLE,
SHUFFLE,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package top.zhushenwudi.llmp.settings
import com.russhwolf.settings.Settings
expect fun createSettings(): Settings

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = ?;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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