initial commit

This commit is contained in:
2026-08-11 00:33:28 +08:00
commit bea5ead33d
295 changed files with 20101 additions and 0 deletions
@@ -0,0 +1,45 @@
package top.zhushenwudi.llmp
/**
* App-wide constants ported from Flutter `lib/global/const.dart`.
*/
object Const {
/** Dual-end transfer protocol version; must match PC Electron. */
const val TRANS_VER: Int = 1
const val PACKAGE_ID: String = "top.zhushenwudi.llmp"
// Cloudflare R2 OSS
const val R2_OSS_URL: String = "https://llmp-oss.zhushenwudi.top/"
// AliYun OSS
const val ALI_OSS_URL: String =
"https://zhushenwudi1.oss-cn-hangzhou.aliyuncs.com/LLMP-M/"
const val LYRIC_URL: String = "${R2_OSS_URL}lyric/"
const val SPLASH_URL: String = "${R2_OSS_URL}LLMP-M/splash_bg/"
const val SPLASH_CONFIG_URL: String = "${SPLASH_URL}splash_config.json"
const val SHARE_DEFAULT_LOGO: String = "${ALI_OSS_URL}ic_launcher.png"
const val MOE_GIRL_URL: String = "https://zh.moegirl.org.cn/"
const val PUSH_URL: String = "${ALI_OSS_URL}push/push.txt"
const val BACKEND_URL: String =
"https://netease-backend.zhushenwudi.top/song/detail"
/** WiFi transfer command WS port (PC). */
const val WS_MUSIC_PORT: Int = 4388
/** Data sync command WS port (PC). */
const val WS_DATA_PORT: Int = 4389
fun dataBaseUrl(env: String = "prod"): String =
"${R2_OSS_URL}LLMP-M/data/v2/$env/"
fun dataUrl(env: String = "prod"): String = "${dataBaseUrl(env)}data.json"
fun artistModelUrl(env: String = "prod"): String =
"${dataBaseUrl(env)}artist.json"
fun updateUrl(env: String = "prod"): String =
"${dataBaseUrl(env)}version.json"
}
@@ -0,0 +1,157 @@
package top.zhushenwudi.llmp.data
import kotlinx.serialization.json.Json
import top.zhushenwudi.llmp.Const
import top.zhushenwudi.llmp.db.LibraryRepository
import top.zhushenwudi.llmp.domain.Album
import top.zhushenwudi.llmp.domain.Artist
import top.zhushenwudi.llmp.domain.ArtistModel
import top.zhushenwudi.llmp.domain.CloudData
import top.zhushenwudi.llmp.domain.Music
import top.zhushenwudi.llmp.network.OssApi
import top.zhushenwudi.llmp.settings.AppSettings
import top.zhushenwudi.llmp.util.PathResolver
class DataImportService(
private val oss: OssApi,
private val library: LibraryRepository,
private val settings: AppSettings,
private val paths: PathResolver,
) {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
data class ImportResult(
val version: Int,
val musicCount: Int,
val albumCount: Int,
val artistCount: Int,
)
/** Remote data.json version, or null on network/parse failure. */
suspend fun fetchRemoteVersion(): Int? = runCatching {
val raw = oss.fetchDataJson()
json.decodeFromString<CloudData>(raw).version
}.getOrNull()
fun localVersion(): Int = settings.dataVersion.toIntOrNull() ?: 0
suspend fun fetchAndImport(force: Boolean = false): ImportResult {
val raw = oss.fetchDataJson()
val cloud = json.decodeFromString<CloudData>(raw)
val localVer = settings.dataVersion.toIntOrNull() ?: 0
if (!force && cloud.version > 0 && cloud.version <= localVer && library.countMusic() > 0) {
return ImportResult(cloud.version, library.countMusic().toInt(), 0, 0)
}
val artistsRaw = runCatching { oss.fetchArtistJson() }.getOrNull()
val artistModels = artistsRaw?.let {
json.decodeFromString<List<ArtistModel>>(it)
}.orEmpty()
val artistByName = artistModels.associateBy { it.k }
library.clearMusic()
library.clearAlbums()
library.clearArtists()
var musicCount = 0
var albumCount = 0
val artistMusic = mutableMapOf<String, MutableList<String>>()
val seenAlbums = mutableSetOf<String>()
for ((groupName, albums) in cloud.album) {
val albumByCloudId = albums.associateBy { it.id }
val musics = cloud.music[groupName].orEmpty()
for (m in musics) {
// Mobile catalog: only export=true tracks are allowed on phone.
if (!m.export) continue
val album = albumByCloudId[m.album] ?: continue
val domain = paths.refreshExistFile(
Music(
musicId = m.uid,
musicName = m.name,
artist = m.artist,
artistBin = m.artistBin,
albumId = album.uid,
albumName = m.albumName ?: album.name,
coverPath = m.coverPath,
musicPath = m.musicPath,
time = m.time,
baseUrl = m.baseUrl,
category = album.category,
group = groupName,
neteaseId = m.neteaseId,
date = album.date,
existFile = false,
),
)
library.upsertMusic(domain)
musicCount++
// Insert / upgrade album only for exportable tracks (Flutter importMusic).
val firstForAlbum = album.uid !in seenAlbums
if (firstForAlbum) {
seenAlbums += album.uid
albumCount++
library.upsertAlbum(
Album(
albumId = album.uid,
albumName = album.name,
date = album.date,
coverPath = album.coverPath.firstOrNull(),
category = album.category,
group = groupName,
existFile = domain.existFile == true,
),
)
} else if (domain.existFile == true) {
library.upsertAlbum(
Album(
albumId = album.uid,
albumName = album.name,
date = album.date,
coverPath = album.coverPath.firstOrNull(),
category = album.category,
group = groupName,
existFile = true,
),
)
}
// Link artists by display-name split (simplified vs Flutter bitmask).
m.artist.split('/', '/')
.map { it.trim() }
.filter { it.isNotEmpty() }
.forEach { name ->
val model = artistByName[name]
val uid = model?.v ?: "name:$name"
artistMusic.getOrPut(uid) { mutableListOf() }.add(m.uid)
if (library.getArtist(uid) == null) {
val photo = if (model != null) {
"${Const.R2_OSS_URL}LLMP-M/artist_webp/${model.v}.webp"
} else ""
library.upsertArtist(
Artist(
uid = uid,
name = name,
photo = photo,
group = groupName,
music = listOf(m.uid),
),
)
}
}
}
}
artistMusic.forEach { (uid, ids) ->
val existing = library.getArtist(uid) ?: return@forEach
library.upsertArtist(existing.copy(music = ids.distinct()))
}
if (cloud.version > 0) {
settings.dataVersion = cloud.version.toString()
}
return ImportResult(cloud.version, musicCount, albumCount, library.getAllArtists().size)
}
}
@@ -0,0 +1,12 @@
package top.zhushenwudi.llmp.db
import app.cash.sqldelight.db.SqlDriver
expect class DriverFactory {
fun createDriver(): SqlDriver
}
fun createDatabase(driverFactory: DriverFactory): LlmpDatabase {
val driver = driverFactory.createDriver()
return LlmpDatabase(driver)
}
@@ -0,0 +1,431 @@
package top.zhushenwudi.llmp.db
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import top.zhushenwudi.llmp.domain.Album
import top.zhushenwudi.llmp.domain.Artist
import top.zhushenwudi.llmp.domain.Love
import top.zhushenwudi.llmp.domain.Lyric
import top.zhushenwudi.llmp.domain.Menu
import top.zhushenwudi.llmp.domain.Music
import top.zhushenwudi.llmp.util.PathResolver
private val json = Json { ignoreUnknownKeys = true }
data class LibraryLists(
val music: List<Music>,
val albums: List<Album>,
val artists: List<Artist>,
val loved: List<Music>,
val recent: List<Music>,
val menus: List<Menu>,
)
class LibraryRepository(private val db: LlmpDatabase) {
private val q get() = db.llmpDatabaseQueries
fun getAllMusic(): List<Music> =
q.selectAllMusic().executeAsList().map { it.toDomain() }
/**
* Flutter [DBLogic.findAllListByGroup]: group filter + local-only when HTTP off.
* Music/albums ordered by release [Music.date] / [Album.date] (then id), matching DAO ASC/DESC.
*/
fun findAllListByGroup(
group: String,
enableHttp: Boolean,
sortAsc: Boolean = true,
): LibraryLists {
val allGroup = group == "all"
fun Music.matches(): Boolean =
(allGroup || this.group == group) && (enableHttp || existFile == true)
val musicComparator = compareBy<Music>({ it.date.orEmpty() }, { it.musicId.orEmpty() })
val albumComparator = compareBy<Album>({ it.date.orEmpty() }, { it.albumId.orEmpty() })
val music = getAllMusic().filter { it.matches() }.let { list ->
if (sortAsc) list.sortedWith(musicComparator) else list.sortedWith(musicComparator.reversed())
}
// Visible songs already satisfy: export (import-time) + (HTTP or local existFile).
val musicIds = music.mapNotNull { it.musicId }.toSet()
val visibleAlbumIds = music.mapNotNull { it.albumId }.toSet()
val albums = getAllAlbums().filter { a ->
(allGroup || a.group == group) &&
(enableHttp || a.existFile == true || a.albumId in visibleAlbumIds)
}.let { list ->
if (sortAsc) list.sortedWith(albumComparator) else list.sortedWith(albumComparator.reversed())
}
// Artists: keep only songs that appear in the songs tab; hide if none left.
val artistsRaw = getAllArtists()
.filter { a -> allGroup || a.group == group }
.sortedWith(artistComparator)
val artistsMerged = if (allGroup) mergeArtists(artistsRaw) else artistsRaw
val artists = artistsMerged
.map { a -> a.copy(music = a.music.filter { id -> id in musicIds }.distinct()) }
.filter { it.music.isNotEmpty() }
val loved = getLovedMusic().filter { it.matches() }.let { list ->
// SQL is timestamp DESC; Flutter LoveDaoExt uses ASC/DESC with sortMode.
if (sortAsc) list.asReversed() else list
}
// Flutter recent is intersected with the already-filtered music list.
// selectAllHistory is DESC; Flutter HistoryDaoExt uses ASC/DESC with sortMode.
val recent = getHistoryMusic().let { hist ->
if (sortAsc) hist.asReversed() else hist
}.filter { m ->
m.musicId in musicIds && (allGroup || m.group == group)
}
return LibraryLists(
music = music,
albums = albums,
artists = artists,
loved = loved,
recent = recent,
menus = getAllMenus(),
)
}
private fun mergeArtists(artists: List<Artist>): List<Artist> {
// Preserve encounter order (Flutter Map insertion order after sortList).
val merged = linkedMapOf<String, Artist>()
for (artist in artists) {
val existing = merged[artist.uid]
if (existing == null) {
merged[artist.uid] = artist.copy(music = artist.music.toList())
} else {
merged[artist.uid] = existing.copy(
music = (existing.music + artist.music).distinct(),
)
}
}
return merged.values.toList()
}
/**
* Flutter [Artist.compare] / [ArtistModelExtension.sortList].
* Order: non-U first-digit → bit-count of uid[1..] (base36) → binary-as-decimal;
* uid starting with `U` sorted last.
*/
private val artistComparator = Comparator<Artist> { a, b ->
compareArtistUid(a.uid, b.uid)
}
companion object {
fun compareArtistUid(aUid: String, bUid: String): Int {
if (aUid.isEmpty() && bUid.isEmpty()) return 0
if (aUid.isEmpty()) return -1
if (bUid.isEmpty()) return 1
val a0 = aUid[0]
val b0 = bUid[0]
if (a0 == 'U') return 1
if (b0 == 'U') return -1
val aDigit = a0.digitToIntOrNull()
val bDigit = b0.digitToIntOrNull()
if (aDigit == null || bDigit == null) return aUid.compareTo(bUid)
if (aDigit != bDigit) return aDigit - bDigit
val aBin = aUid.substring(1).toLongOrNull(radix = 36) ?: 0L
val bBin = bUid.substring(1).toLongOrNull(radix = 36) ?: 0L
val aOnes = aBin.countOneBits()
val bOnes = bBin.countOneBits()
if (aOnes != bOnes) return aOnes - bOnes
// Flutter: int.parse(n.toRadixString(2)) — binary digits re-read as decimal
val aDec = aBin.toString(2).toLongOrNull() ?: 0L
val bDec = bBin.toString(2).toLongOrNull() ?: 0L
return aDec.compareTo(bDec)
}
}
/**
* Re-check local files and update [Music.existFile] / [Album.existFile].
* Needed after storage-root fixes or SD path changes without full re-import.
*/
fun rescanExistFiles(paths: PathResolver): Int {
var changed = 0
val albumHasFile = mutableMapOf<String, Boolean>()
getAllMusic().forEach { music ->
val updated = paths.refreshExistFile(music)
if (updated.existFile != music.existFile) {
upsertMusic(updated)
changed++
}
val albumId = updated.albumId ?: return@forEach
if (updated.existFile == true) {
albumHasFile[albumId] = true
} else {
albumHasFile.putIfAbsent(albumId, false)
}
}
getAllAlbums().forEach { album ->
val id = album.albumId ?: return@forEach
val shouldExist = albumHasFile[id] == true
if (album.existFile != shouldExist) {
upsertAlbum(album.copy(existFile = shouldExist))
changed++
}
}
return changed
}
fun getMusicById(id: String): Music? =
q.selectMusicById(id).executeAsOneOrNull()?.toDomain()
fun getMusicByAlbum(albumId: String): List<Music> =
q.selectMusicByAlbum(albumId).executeAsList().map { it.toDomain() }
fun getLovedMusic(): List<Music> =
q.selectLovedMusic().executeAsList().map { it.toDomain() }
fun getHistoryMusic(): List<Music> =
q.selectAllHistory().executeAsList().mapNotNull { h ->
getMusicById(h.musicId)?.copy(timestamp = h.timestamp)
}
fun upsertMusic(music: Music) {
val id = music.musicId ?: return
q.insertMusic(
musicId = id,
musicName = music.musicName,
artist = music.artist,
artistBin = music.artistBin,
albumId = music.albumId,
albumName = music.albumName,
coverPath = music.coverPath,
musicPath = music.musicPath,
time = music.time,
baseUrl = music.baseUrl,
category = music.category,
groupName = music.group,
isLove = if (music.isLove) 1L else 0L,
timestamp = music.timestamp,
neteaseId = music.neteaseId,
date = music.date,
existFile = if (music.existFile == true) 1L else 0L,
)
}
fun clearMusic() = q.deleteAllMusic()
fun countMusic(): Long = q.countMusic().executeAsOne()
fun getAllAlbums(): List<Album> =
q.selectAllAlbums().executeAsList().map {
Album(
albumId = it.albumId,
albumName = it.albumName,
date = it.date,
coverPath = it.coverPath,
category = it.category,
group = it.groupName,
existFile = (it.existFile ?: 0L) != 0L,
)
}
fun getAlbum(albumId: String): Album? =
q.selectAlbumById(albumId).executeAsOneOrNull()?.let {
Album(
albumId = it.albumId,
albumName = it.albumName,
date = it.date,
coverPath = it.coverPath,
category = it.category,
group = it.groupName,
existFile = (it.existFile ?: 0L) != 0L,
)
}
fun upsertAlbum(album: Album) {
val id = album.albumId ?: return
q.insertAlbum(
albumId = id,
albumName = album.albumName,
date = album.date,
coverPath = album.coverPath,
category = album.category,
groupName = album.group,
existFile = if (album.existFile == true) 1L else 0L,
)
}
fun clearAlbums() = q.deleteAllAlbums()
fun getAllArtists(): List<Artist> =
q.selectAllArtists().executeAsList().map { it.toDomain() }
fun getArtist(uid: String): Artist? =
q.selectArtistByUid(uid).executeAsOneOrNull()?.toDomain()
fun upsertArtist(artist: Artist) {
val existing = q.selectArtistByUid(artist.uid).executeAsOneOrNull()
val musicJson = json.encodeToString(artist.music)
if (existing == null) {
q.insertArtist(artist.uid, artist.name, artist.photo, artist.group, musicJson)
} else {
q.updateArtistMusic(musicJson, artist.uid)
}
}
fun clearArtists() = q.deleteAllArtists()
fun getAllMenus(): List<Menu> =
q.selectAllMenus().executeAsList().map { it.toDomain() }
fun getMenu(id: Long): Menu? =
q.selectMenuById(id).executeAsOneOrNull()?.toDomain()
fun upsertMenu(menu: Menu) {
q.insertMenu(
id = menu.id,
isPhone = if (menu.isPhone) 1L else 0L,
musicJson = json.encodeToString(menu.music),
date = menu.date,
name = menu.name,
)
}
fun deleteMenu(id: Long) = q.deleteMenuById(id)
fun deletePcMenus() = q.deletePcMenus()
fun clearMenus() = q.deleteAllMenus()
fun getAllLove(): List<Love> =
q.selectAllLove().executeAsList().map {
Love(id = it.id, musicId = it.musicId, timestamp = it.timestamp)
}
fun toggleLove(musicId: String): Boolean {
val loved = getAllLove().any { it.musicId == musicId }
if (loved) {
q.deleteLoveByMusicId(musicId)
q.updateMusicLove(0L, musicId)
return false
}
q.insertLove(musicId, currentTimeMs())
q.updateMusicLove(1L, musicId)
return true
}
fun replaceLove(list: List<Love>) {
q.deleteAllLove()
q.selectAllMusic().executeAsList().forEach {
q.updateMusicLove(0L, it.musicId)
}
list.forEach { love ->
q.insertLove(love.musicId, love.timestamp)
q.updateMusicLove(1L, love.musicId)
}
}
fun clearLove() = q.deleteAllLove()
fun addHistory(musicId: String) {
q.insertHistory(musicId, currentTimeMs())
}
fun clearHistory() = q.deleteAllHistory()
fun getLyric(uid: String): Lyric? =
q.selectLyricByUid(uid).executeAsOneOrNull()?.let {
Lyric(uid = it.uid, jp = it.jp, zh = it.zh, roma = it.roma)
}
/** Flutter lyricDao.insertLyric — first insert for uid. */
fun insertLyricNew(lyric: Lyric) {
val uid = lyric.uid ?: return
q.insertLyricNew(uid, lyric.jp, lyric.zh, lyric.roma)
}
/** Flutter lyricDao.updateLrc. */
fun updateLyric(lyric: Lyric) {
val uid = lyric.uid ?: return
q.updateLyricRow(lyric.jp, lyric.zh, lyric.roma, uid)
}
fun upsertLyric(lyric: Lyric) {
val uid = lyric.uid ?: return
q.insertLyric(uid, lyric.jp, lyric.zh, lyric.roma)
}
fun getPlayListIds(): List<String> =
q.selectPlayList().executeAsList().map { it.musicId }
/** Flutter playListMusicDao.findAllPlayListMusics — ordered rows. */
fun loadPlayListRows(): List<PlayListMusic> =
q.selectPlayList().executeAsList()
/**
* Flutter [DBLogic.findMusicByMusicIds]: keep [ids] order;
* skip missing / non-local when HTTP is off.
*/
fun getMusicByIdsPreservingOrder(ids: List<String>, enableHttp: Boolean): List<Music> =
ids.mapNotNull { id ->
val music = getMusicById(id) ?: return@mapNotNull null
if (!enableHttp && music.existFile != true) null else music
}
fun savePlayList(items: List<Music>, playingId: String?) {
q.deletePlayList()
items.forEachIndexed { index, music ->
val id = music.musicId ?: return@forEachIndexed
q.insertPlayListItem(
musicId = id,
musicName = music.musicName,
artist = music.artist,
isPlaying = if (id == playingId) 1L else 0L,
sortOrder = index.toLong(),
)
}
}
fun clearPlayList() = q.deletePlayList()
fun nextPhoneMenuId(): Long {
val used = getAllMenus().map { it.id }.filter { it > 100 }.toSet()
for (id in 101L..200L) {
if (id !in used) return id
}
return 101L
}
private fun top.zhushenwudi.llmp.db.Music.toDomain() = Music(
musicId = musicId,
musicName = musicName,
artist = artist,
artistBin = artistBin,
albumId = albumId,
albumName = albumName,
coverPath = coverPath,
musicPath = musicPath,
time = time,
baseUrl = baseUrl,
category = category,
group = groupName,
isLove = isLove != 0L,
timestamp = timestamp,
neteaseId = neteaseId,
date = date,
existFile = (existFile ?: 0L) != 0L,
)
private fun top.zhushenwudi.llmp.db.Artist.toDomain() = Artist(
id = id,
uid = uid,
name = name,
photo = photo,
group = groupName,
music = runCatching { json.decodeFromString<List<String>>(musicJson) }
.getOrDefault(emptyList()),
)
private fun top.zhushenwudi.llmp.db.Menu.toDomain() = Menu(
id = id,
isPhone = isPhone != 0L,
music = runCatching { json.decodeFromString<List<String>>(musicJson) }
.getOrDefault(emptyList()),
date = date,
name = name,
)
}
expect fun currentTimeMs(): Long
@@ -0,0 +1,57 @@
package top.zhushenwudi.llmp.db
import top.zhushenwudi.llmp.domain.Music as DomainMusic
class MusicRepository(private val db: LlmpDatabase) {
fun getAll(): List<DomainMusic> =
db.llmpDatabaseQueries.selectAllMusic().executeAsList().map { row ->
DomainMusic(
musicId = row.musicId,
musicName = row.musicName,
artist = row.artist,
artistBin = row.artistBin,
albumId = row.albumId,
albumName = row.albumName,
coverPath = row.coverPath,
musicPath = row.musicPath,
time = row.time,
baseUrl = row.baseUrl,
category = row.category,
group = row.groupName,
isLove = row.isLove != 0L,
timestamp = row.timestamp,
neteaseId = row.neteaseId,
date = row.date,
existFile = (row.existFile ?: 0L) != 0L,
)
}
fun count(): Long = db.llmpDatabaseQueries.countMusic().executeAsOne()
fun upsert(music: DomainMusic) {
val id = music.musicId ?: return
db.llmpDatabaseQueries.insertMusic(
musicId = id,
musicName = music.musicName,
artist = music.artist,
artistBin = music.artistBin,
albumId = music.albumId,
albumName = music.albumName,
coverPath = music.coverPath,
musicPath = music.musicPath,
time = music.time,
baseUrl = music.baseUrl,
category = music.category,
groupName = music.group,
isLove = if (music.isLove) 1L else 0L,
timestamp = music.timestamp,
neteaseId = music.neteaseId,
date = music.date,
existFile = if (music.existFile == true) 1L else 0L,
)
}
fun clear() {
db.llmpDatabaseQueries.deleteAllMusic()
}
}
@@ -0,0 +1,55 @@
package top.zhushenwudi.llmp.domain
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class CloudData(
val version: Int = 0,
val album: Map<String, List<InnerAlbum>> = emptyMap(),
val music: Map<String, List<InnerMusic>> = emptyMap(),
)
@Serializable
data class InnerAlbum(
@SerialName("_id") val uid: String = "",
val id: Int = 0,
val name: String = "",
val date: String = "",
@SerialName("cover_path") val coverPath: List<String> = emptyList(),
val category: String = "",
val music: List<Int> = emptyList(),
)
@Serializable
data class InnerMusic(
@SerialName("_id") val uid: String = "",
val id: Int = 0,
val name: String = "",
val album: Int = 0,
@SerialName("cover_path") val coverPath: String = "",
@SerialName("music_path") val musicPath: String = "",
val artist: String = "",
@SerialName("artist_bin") val artistBin: String = "",
val time: String = "",
val albumName: String? = null,
val export: Boolean = true,
@SerialName("base_url") val baseUrl: String = "",
val neteaseId: String? = null,
)
@Serializable
data class ArtistModel(
val k: String = "",
val v: String = "",
val m: String? = null,
)
@Serializable
data class VersionInfo(
val version: String = "",
val versionCode: Int = 0,
val force: Boolean = false,
val changelog: String = "",
val apkUrl: String = "",
)
@@ -0,0 +1,25 @@
package top.zhushenwudi.llmp.domain
import kotlinx.serialization.Serializable
/**
* Idol group keys aligned with Flutter `GroupKey`.
*/
@Serializable
enum class GroupKey(val displayName: String) {
GROUP_ALL("all"),
GROUP_US("μ's"),
GROUP_AQOURS("Aqours"),
GROUP_NIJIGASAKI("Nijigasaki"),
GROUP_LIELLA("Liella!"),
GROUP_HASUNOSORA("Hasunosora"),
GROUP_YOHANE("Yohane"),
GROUP_MUSICAL("Musical"),
GROUP_BLUEBIRD("BlueBird"),
GROUP_COMBINE("Combine");
companion object {
fun fromName(name: String): GroupKey =
entries.firstOrNull { it.displayName == name } ?: GROUP_ALL
}
}
@@ -0,0 +1,117 @@
package top.zhushenwudi.llmp.domain
import kotlinx.serialization.Serializable
@Serializable
data class Music(
val musicId: String? = null,
val musicName: String? = null,
val artist: String? = null,
val artistBin: String? = null,
val albumId: String? = null,
val albumName: String? = null,
val coverPath: String? = null,
val musicPath: String? = null,
val time: String? = null,
val baseUrl: String? = null,
val category: String? = null,
val group: String? = null,
val isLove: Boolean = false,
val timestamp: Long = 0,
val neteaseId: String? = null,
val date: String? = null,
val existFile: Boolean? = false,
)
@Serializable
data class Album(
val albumId: String? = null,
val albumName: String? = null,
val date: String? = null,
val coverPath: String? = null,
val category: String? = null,
val group: String? = null,
val existFile: Boolean? = false,
)
@Serializable
data class Artist(
val id: Long? = null,
val uid: String,
val name: String,
val photo: String,
val group: String,
val music: List<String> = emptyList(),
)
@Serializable
data class Menu(
val id: Long,
val isPhone: Boolean = true,
val music: List<String> = emptyList(),
val date: String,
val name: String,
)
@Serializable
data class Love(
val id: Long? = null,
val musicId: String,
val timestamp: Long,
)
@Serializable
data class History(
val musicId: String,
val timestamp: Long,
)
@Serializable
data class Lyric(
val uid: String? = null,
val jp: String? = null,
val zh: String? = null,
val roma: String? = null,
)
@Serializable
data class FtpCmd(
val cmd: String,
val body: String = "",
)
@Serializable
data class DownloadMusic(
val albumUId: String,
val albumId: Int,
val albumName: String,
val coverPath: String,
val date: String,
val category: String,
val group: String,
val musicUId: String,
val musicId: Int,
val musicName: String,
val musicPath: String,
val artist: String,
val artistBin: String,
val totalTime: String,
val baseUrl: String,
val neteaseId: String? = null,
val existFile: Boolean = true,
)
@Serializable
data class TransData(
val love: List<Love>,
val menu: List<TransMenu>,
val isCover: Boolean,
)
@Serializable
data class TransMenu(
val menuId: Int,
val name: String,
val date: String,
val musicList: List<String>,
)
@@ -0,0 +1,43 @@
package top.zhushenwudi.llmp.lyric
data class LyricLine(
val timeMs: Long,
val text: String,
)
object LrcParser {
private val lineRegex =
Regex("""\[(\d{1,2}):(\d{1,2})(?:[.:](\d{1,3}))?](.*)""")
fun parse(lrc: String?): List<LyricLine> {
if (lrc.isNullOrBlank()) return emptyList()
val lines = mutableListOf<LyricLine>()
for (raw in lrc.lineSequence()) {
val trimmed = raw.trim()
if (trimmed.isEmpty()) continue
val match = lineRegex.find(trimmed) ?: continue
val min = match.groupValues[1].toLongOrNull() ?: continue
val sec = match.groupValues[2].toLongOrNull() ?: continue
val frac = match.groupValues[3]
val millis = when {
frac.isEmpty() -> 0L
frac.length == 1 -> frac.toLong() * 100
frac.length == 2 -> frac.toLong() * 10
else -> frac.take(3).padEnd(3, '0').toLong()
}
val text = match.groupValues[4].trim()
if (text.isEmpty()) continue
lines += LyricLine(timeMs = min * 60_000 + sec * 1_000 + millis, text = text)
}
return lines.sortedBy { it.timeMs }
}
fun currentIndex(lines: List<LyricLine>, positionMs: Long): Int {
if (lines.isEmpty()) return -1
var idx = -1
for (i in lines.indices) {
if (lines[i].timeMs <= positionMs) idx = i else break
}
return idx
}
}
@@ -0,0 +1,177 @@
package top.zhushenwudi.llmp.lyric
import top.zhushenwudi.llmp.db.LibraryRepository
import top.zhushenwudi.llmp.domain.Lyric
import top.zhushenwudi.llmp.domain.Music
import top.zhushenwudi.llmp.network.OssApi
import top.zhushenwudi.llmp.platform.AppLog
/**
* Port of Flutter [LyricLogic]: getLrc + handleLRC (DB secondary cache).
*
* Cache semantics (Flutter comments):
* - row missing → storageLrc = null → fetch → insertLyric
* - row exists, field null/"" → storageLrc = "" → fetch → updateLrc
* - row exists, field non-empty → return DB (unless forceRefresh)
*/
class LyricService(
private val oss: OssApi,
private val library: LibraryRepository,
) {
/** Flutter: SDUtils.allowEULA ? 0 : 1 */
var lrcType: Int = if (PlatformInfo.allowEulaLyric) 0 else 1
/** Last loaded full lyric row (Flutter LyricLogic.fullLrc). */
var fullLrc: Lyric = Lyric()
private set
/**
* Flutter [LyricLogic.getLrc].
* Uses playing music's relative [Music.baseUrl] + [Music.musicPath].
*/
suspend fun getLrc(music: Music, forceRefresh: Boolean = false): LoadedLyric {
val uid = music.musicId ?: return LoadedLyric().also { fullLrc = Lyric() }
// Prefer DB music so relative paths survive playback URI resolution.
val source = library.getMusicById(uid) ?: music
val baseUrl = source.baseUrl
val musicPath = source.musicPath
if (baseUrl.isNullOrBlank() || musicPath.isNullOrBlank()) {
return LoadedLyric().also { fullLrc = Lyric(uid = uid) }
}
// Reject absolute / playback URIs — same as needing relative cloud keys.
if (musicPath.contains("://") || musicPath.startsWith("/")) {
return LoadedLyric().also { fullLrc = Lyric(uid = uid) }
}
// Flutter: musicPath.replaceAll("flac","lrc").replaceAll("wav","lrc")
val lyricPath = musicPath
.replace("flac", "lrc")
.replace("wav", "lrc")
var jpLrc = ""
var zhLrc = ""
var romaLrc = ""
// Flutter always loads ZH first
val zh = handleLrc("zh", "ZH/$baseUrl$lyricPath", uid, forceRefresh)
if (zh != null) zhLrc = zh
if (PlatformInfo.allowEulaLyric) {
val jp = handleLrc("jp", "JP/$baseUrl$lyricPath", uid, forceRefresh)
if (jp != null) jpLrc = jp
val roma = handleLrc("roma", "ROMA/$baseUrl$lyricPath", uid, forceRefresh)
if (roma != null) romaLrc = roma
}
fullLrc = Lyric(uid = uid, jp = jpLrc, zh = zhLrc, roma = romaLrc)
return toLoaded(fullLrc)
}
/** @deprecated Use [getLrc] — kept for call-site migration. */
suspend fun loadFor(music: Music, forceRefresh: Boolean = false): LoadedLyric =
getLrc(music, forceRefresh)
/**
* Flutter [LyricLogic.handleLRC] — per-language DB cache then network.
* @return lyric text, or null on failure / missing
*/
private suspend fun handleLrc(
type: String,
lrcUrl: String,
uid: String,
forceRefresh: Boolean,
): String? {
if (lrcUrl.isEmpty() || uid.isEmpty()) return null
var row = library.getLyric(uid)
// Flutter: null = never inserted; "" = inserted but empty
val storageLrc: String? = when {
row == null -> null
type == "jp" -> row.jp ?: ""
type == "zh" -> row.zh ?: ""
type == "roma" -> row.roma ?: ""
else -> null
}
if (storageLrc.isNullOrEmpty() || forceRefresh) {
AppLog.d(TAG, "handleLrc($type) miss uid=$uid path=$lrcUrl force=$forceRefresh")
return try {
val netLrc = oss.fetchLyricRelativePath(lrcUrl)
if (netLrc.isBlank()) {
AppLog.w(TAG, "handleLrc($type) empty body uid=$uid")
return null
}
if (row == null) {
row = Lyric(uid = uid, jp = null, zh = null, roma = null)
}
row = when (type) {
"jp" -> row.copy(jp = netLrc)
"zh" -> row.copy(zh = netLrc)
"roma" -> row.copy(roma = netLrc)
else -> row
}
// Flutter: insert when storageLrc == null, else update
if (storageLrc == null) {
library.insertLyricNew(row)
AppLog.d(TAG, "handleLrc($type) insertLyric uid=$uid len=${netLrc.length}")
} else {
library.updateLyric(row)
AppLog.d(TAG, "handleLrc($type) updateLrc uid=$uid len=${netLrc.length}")
}
netLrc
} catch (t: Throwable) {
AppLog.e(TAG, "handleLrc($type) network fail uid=$uid path=$lrcUrl", t)
null
}
}
AppLog.d(TAG, "handleLrc($type) cache hit uid=$uid len=${storageLrc.length}")
return storageLrc
}
fun toggleType() {
lrcType = (lrcType + 1) % 3
}
/** Flutter [LyricLogic.reloadLyricToController] → parse for UI. */
fun toLoaded(lyric: Lyric = fullLrc): LoadedLyric {
val main: String?
val translation: String?
when (lrcType % 3) {
0 -> {
main = lyric.jp
translation = null
}
1 -> {
if (PlatformInfo.allowEulaLyric) {
main = lyric.jp
translation = lyric.zh
} else {
main = lyric.zh
translation = lyric.roma
}
}
else -> {
main = lyric.jp
translation = lyric.roma
}
}
return LoadedLyric(
main = LrcParser.parse(main?.takeIf { it.isNotBlank() }),
translation = LrcParser.parse(translation?.takeIf { it.isNotBlank() }),
raw = lyric,
)
}
}
data class LoadedLyric(
val main: List<LyricLine> = emptyList(),
val translation: List<LyricLine> = emptyList(),
val raw: Lyric? = null,
)
expect object PlatformInfo {
val allowEulaLyric: Boolean
}
private const val TAG = "LLMP-Lyric"
@@ -0,0 +1,50 @@
package top.zhushenwudi.llmp.network
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import top.zhushenwudi.llmp.platform.AppLog
expect fun createPlatformHttpClient(): HttpClient
private const val HTTP_LOG_TAG = "LLMP-HTTP"
fun createHttpClient(): HttpClient =
createPlatformHttpClient().config {
// Align with Flutter Network headers; OSS returns 403 without User-Agent.
defaultRequest {
header(
HttpHeaders.UserAgent,
"LoveLiveMusicPlayer/0.2.0 (KMP; Android/iOS)",
)
header(HttpHeaders.Accept, "application/json,*/*")
header("Session-Access-Origin", "xxx")
header(HttpHeaders.CacheControl, "no-cache")
}
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
isLenient = true
},
)
}
install(WebSockets)
// Log request + response (headers & body) to Logcat / NSLog.
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
AppLog.d(HTTP_LOG_TAG, message)
}
}
level = LogLevel.ALL
}
}
@@ -0,0 +1,65 @@
package top.zhushenwudi.llmp.network
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.statement.bodyAsBytes
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpHeaders
import top.zhushenwudi.llmp.Const
class OssApi(
private val client: HttpClient,
private val env: String = "prod",
) {
suspend fun fetchDataJson(): String =
client.get(Const.dataUrl(env)).bodyAsText()
suspend fun fetchVersionJson(): String =
client.get(Const.updateUrl(env)).bodyAsText()
suspend fun fetchArtistJson(): String =
client.get(Const.artistModelUrl(env)).bodyAsText()
/**
* Flutter: `Network.getSync(Const.lyricUrl + Uri.encodeComponent(lrcUrl))`
* where [relativePath] is like `JP/$baseUrl$file.lrc`.
*
* Equivalent resolved path with per-segment encoding (avoids Ktor turning
* whole-string `%2F` into `%252F`).
*/
suspend fun fetchLyricRelativePath(relativePath: String): String {
val url = Const.LYRIC_URL.trimEnd('/') + "/" +
relativePath.split('/')
.filter { it.isNotEmpty() }
.joinToString("/") { encodeComponent(it) }
val response = client.get(url) {
header(HttpHeaders.Accept, "*/*")
}
return response.bodyAsText()
}
suspend fun fetchLyric(url: String): String {
val response = client.get(url) {
header(HttpHeaders.Accept, "*/*")
}
return response.bodyAsText()
}
suspend fun fetchText(url: String): String =
client.get(url).bodyAsText()
suspend fun downloadBytes(url: String): ByteArray =
client.get(url).bodyAsBytes()
/** Dart `Uri.encodeComponent` for one path segment. */
private fun encodeComponent(value: String): String = buildString(value.length * 2) {
for (ch in value) {
when {
ch.isLetterOrDigit() || ch in "-_.!~*'()" -> append(ch)
else -> append('%')
.append(ch.code.toString(16).uppercase().padStart(2, '0'))
}
}
}
}
@@ -0,0 +1,66 @@
package top.zhushenwudi.llmp.network
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocketSession
import io.ktor.client.request.url
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.close
import io.ktor.websocket.readText
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import top.zhushenwudi.llmp.Const
import top.zhushenwudi.llmp.domain.FtpCmd
import top.zhushenwudi.llmp.protocol.Handshake
import top.zhushenwudi.llmp.protocol.TransferChannel
class WsClient(
private val client: HttpClient,
private val json: Json = Json { ignoreUnknownKeys = true },
) {
private var session: WebSocketSession? = null
val isConnected: Boolean get() = session != null
suspend fun connect(host: String, channel: TransferChannel): Boolean {
val port = when (channel) {
TransferChannel.MUSIC -> Const.WS_MUSIC_PORT
TransferChannel.DATA -> Const.WS_DATA_PORT
}
return try {
close()
session = client.webSocketSession { url("ws://$host:$port") }
true
} catch (_: Exception) {
session = null
false
}
}
suspend fun send(cmd: FtpCmd) {
session?.send(Frame.Text(json.encodeToString(cmd)))
}
suspend fun sendVersion(localVersion: Int = Const.TRANS_VER) {
send(Handshake.buildVersionRequest(localVersion))
}
fun incoming(): Flow<FtpCmd> = flow {
val s = session ?: return@flow
for (frame in s.incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
runCatching {
emit(json.decodeFromString(FtpCmd.serializer(), text))
}
}
}
}
suspend fun close() {
runCatching { session?.close() }
session = null
}
}
@@ -0,0 +1,9 @@
package top.zhushenwudi.llmp.platform
/** Platform logger — Android → Logcat, iOS → NSLog/println. */
expect object AppLog {
fun d(tag: String, message: String)
fun i(tag: String, message: String)
fun w(tag: String, message: String)
fun e(tag: String, message: String, throwable: Throwable? = null)
}
@@ -0,0 +1,137 @@
package top.zhushenwudi.llmp.platform
import kotlinx.serialization.Serializable
import top.zhushenwudi.llmp.domain.Album
import top.zhushenwudi.llmp.domain.GroupKey
import top.zhushenwudi.llmp.domain.Menu
import top.zhushenwudi.llmp.domain.Music
/**
* CarPlay catalog mirroring Flutter carplay pages:
* Tab Music / Album / Mine with groups, love, menus, and play callbacks.
*
* UI is native (Swift CPTemplate); this model supplies data + handlers.
*/
@Serializable
data class CarPlayGroupNode(
val key: GroupKey,
val displayName: String,
val detail: String = "",
val logoPath: String? = null,
)
@Serializable
data class CarPlaySongItem(
val musicId: String,
val title: String,
val artist: String? = null,
val coverPath: String? = null,
)
@Serializable
data class CarPlayAlbumItem(
val albumId: String,
val title: String,
val group: String? = null,
val coverPath: String? = null,
)
@Serializable
data class CarPlayMenuItem(
val menuId: Long,
val name: String,
val count: Int,
)
class CarPlayCatalog {
/** Now-playing title for Music tab header. */
var nowPlayingTitle: String = "正在播放"
private set
var nowPlayingCover: String? = null
private set
var groups: List<CarPlayGroupNode> = defaultGroups()
private set
var loveSongs: List<CarPlaySongItem> = emptyList()
private set
var menus: List<CarPlayMenuItem> = emptyList()
private set
private var songsByGroup: Map<String, List<CarPlaySongItem>> = emptyMap()
private var albumsByGroup: Map<String, List<CarPlayAlbumItem>> = emptyMap()
private var songsByAlbum: Map<String, List<CarPlaySongItem>> = emptyMap()
private var songsByMenu: Map<Long, List<CarPlaySongItem>> = emptyMap()
var onPlay: ((musicId: String, queue: List<CarPlaySongItem>) -> Unit)? = null
fun updateNowPlaying(music: Music?) {
nowPlayingTitle = "正在播放:${music?.musicName ?: "无歌曲"}"
nowPlayingCover = music?.coverPath
}
fun setLibrary(
allMusic: List<Music>,
albums: List<Album>,
love: List<Music>,
menuList: List<Menu>,
) {
songsByGroup = allMusic
.filter { !it.musicId.isNullOrBlank() }
.groupBy { it.group.orEmpty() }
.mapValues { (_, list) -> list.map { it.toItem() } }
albumsByGroup = albums
.filter { !it.albumId.isNullOrBlank() }
.groupBy { it.group.orEmpty() }
.mapValues { (_, list) ->
list.map {
CarPlayAlbumItem(
albumId = it.albumId!!,
title = it.albumName.orEmpty(),
group = it.group,
coverPath = it.coverPath,
)
}
}
songsByAlbum = allMusic
.filter { !it.albumId.isNullOrBlank() && !it.musicId.isNullOrBlank() }
.groupBy { it.albumId!! }
.mapValues { (_, list) -> list.map { it.toItem() } }
loveSongs = love.map { it.toItem() }
menus = menuList.map { CarPlayMenuItem(it.id, it.name, it.music.size) }
songsByMenu = menuList.associate { menu ->
menu.id to menu.music.mapNotNull { id ->
allMusic.firstOrNull { it.musicId == id }?.toItem()
}
}
}
fun songsForGroup(group: String): List<CarPlaySongItem> = songsByGroup[group].orEmpty()
fun albumsForGroup(group: String): List<CarPlayAlbumItem> = albumsByGroup[group].orEmpty()
fun songsForAlbum(albumId: String): List<CarPlaySongItem> = songsByAlbum[albumId].orEmpty()
fun songsForMenu(menuId: Long): List<CarPlaySongItem> = songsByMenu[menuId].orEmpty()
fun play(item: CarPlaySongItem, queue: List<CarPlaySongItem>) {
onPlay?.invoke(item.musicId, queue)
}
private fun Music.toItem() = CarPlaySongItem(
musicId = musicId.orEmpty(),
title = musicName.orEmpty(),
artist = artist,
coverPath = coverPath,
)
companion object {
fun defaultGroups(): List<CarPlayGroupNode> = listOf(
CarPlayGroupNode(GroupKey.GROUP_US, "\u03bc's"),
CarPlayGroupNode(GroupKey.GROUP_AQOURS, "Aqours"),
CarPlayGroupNode(GroupKey.GROUP_NIJIGASAKI, "Nijigasaki"),
CarPlayGroupNode(GroupKey.GROUP_LIELLA, "Liella!"),
CarPlayGroupNode(GroupKey.GROUP_HASUNOSORA, "Hasunosora"),
CarPlayGroupNode(GroupKey.GROUP_YOHANE, "Yohane"),
CarPlayGroupNode(GroupKey.GROUP_MUSICAL, "Musical"),
CarPlayGroupNode(GroupKey.GROUP_BLUEBIRD, "BlueBird"),
CarPlayGroupNode(GroupKey.GROUP_COMBINE, "Combine"),
)
}
}
@@ -0,0 +1,31 @@
package top.zhushenwudi.llmp.platform
/**
* Local storage roots and file helpers.
*/
expect class FileSystem {
/**
* App files / documents root (ends with separator).
* Flutter default `SDUtils.path` — e.g. Android `…/files/`, iOS Documents.
*/
val filesRoot: String
/**
* Music / media root (ends with separator). Same as Flutter `SDUtils.path`.
* Cloud `base_url` already starts with `LoveLive/…`, so this must NOT append another `LoveLive/`.
*/
val musicRoot: String
fun exists(absolutePath: String): Boolean
fun ensureDir(absolutePath: String): Boolean
fun writeBytes(absolutePath: String, bytes: ByteArray): Boolean
/** Non-recursive absolute paths of files (not directories) under [absoluteDir]. */
fun listFiles(absoluteDir: String): List<String>
fun listUsbRoots(): List<String>
}
expect fun createFileSystem(): FileSystem
@@ -0,0 +1,230 @@
package top.zhushenwudi.llmp.platform
/**
* Platform system integrations used by AppContainer / UI.
* Android & iOS wire real implementations; NoOp is compile-time fallback only.
*/
/** Desktop / floating lyric overlay (Android WindowManager; iOS PiP lyric). */
interface DesktopLyricController {
var enabled: Boolean
fun show()
fun hide()
fun updateLines(line1: String?, line2: String?, currentLine: Int)
fun setPlaying(playing: Boolean)
}
/** Home screen widget data sync. */
interface HomeWidgetController {
fun update(
songName: String?,
artist: String?,
isPlaying: Boolean,
favorite: Boolean,
lyricLine1: String? = null,
lyricLine2: String? = null,
currentLine: Int = -1,
coverPath: String? = null,
playText: String = "Playing,Paused",
)
fun setShutdown(shutdown: Boolean)
fun refresh()
}
/** Deep link parser for llmp:// share URLs. */
interface DeepLinkHandler {
fun parse(url: String): DeepLinkPayload?
fun handle(url: String)
}
data class DeepLinkPayload(
val raw: String,
val type: Int? = null,
val musicId: String? = null,
val data: String? = null,
)
/** USB mount / unmount monitoring. */
interface UsbMountMonitor {
fun start()
fun stop()
fun listMountedVolumes(): List<String>
}
/** System share (Android Intent / iOS share sheet). */
interface ShareBridge {
fun shareText(text: String, title: String? = null)
fun shareUrl(url: String, title: String? = null)
}
interface MediaSessionHook {
fun updateSession(title: String?, artist: String?, isPlaying: Boolean)
}
interface AnalyticsHook {
fun initIfAgreed(agreed: Boolean)
fun event(name: String, props: Map<String, String> = emptyMap())
}
interface PictureInPictureHook {
fun enterPiP()
}
interface CarPlayHook {
fun refreshCatalog()
fun bind(catalog: CarPlayCatalog)
}
// --- NoOp fallbacks (last resort; not default once platform actuals register) ---
object NoOpDesktopLyric : DesktopLyricController {
override var enabled: Boolean = false
override fun show() = Unit
override fun hide() = Unit
override fun updateLines(line1: String?, line2: String?, currentLine: Int) = Unit
override fun setPlaying(playing: Boolean) = Unit
}
object NoOpHomeWidget : HomeWidgetController {
override fun update(
songName: String?,
artist: String?,
isPlaying: Boolean,
favorite: Boolean,
lyricLine1: String?,
lyricLine2: String?,
currentLine: Int,
coverPath: String?,
playText: String,
) = Unit
override fun setShutdown(shutdown: Boolean) = Unit
override fun refresh() = Unit
}
object NoOpDeepLink : DeepLinkHandler {
override fun parse(url: String): DeepLinkPayload? = DeepLinkParser.parse(url)
override fun handle(url: String) = Unit
}
object NoOpUsbMount : UsbMountMonitor {
override fun start() = Unit
override fun stop() = Unit
override fun listMountedVolumes(): List<String> = emptyList()
}
object NoOpShare : ShareBridge {
override fun shareText(text: String, title: String?) = Unit
override fun shareUrl(url: String, title: String?) = Unit
}
object NoOpMediaSession : MediaSessionHook {
override fun updateSession(title: String?, artist: String?, isPlaying: Boolean) = Unit
}
object NoOpAnalytics : AnalyticsHook {
override fun initIfAgreed(agreed: Boolean) = Unit
override fun event(name: String, props: Map<String, String>) = Unit
}
object NoOpCarPlay : CarPlayHook {
override fun refreshCatalog() = Unit
override fun bind(catalog: CarPlayCatalog) = Unit
}
object NoOpPiP : PictureInPictureHook {
override fun enterPiP() = Unit
}
/** Shared deep-link parsing (no platform deps). */
object DeepLinkParser {
private const val PREFIX = "llmp://"
fun parse(url: String): DeepLinkPayload? {
if (!url.startsWith(PREFIX) && !url.startsWith("llmp:")) return null
val query = url.substringAfter('?', missingDelimiterValue = "")
if (query.isEmpty()) return DeepLinkPayload(raw = url)
val params = query.split('&').mapNotNull { part ->
val i = part.indexOf('=')
if (i <= 0) null else part.substring(0, i) to part.substring(i + 1)
}.toMap()
return DeepLinkPayload(
raw = url,
type = params["type"]?.toIntOrNull(),
musicId = params["musicId"] ?: params["data"]?.takeIf { params["type"] == "1" },
data = params["data"],
)
}
}
/**
* Singleton registry filled by platform entry (MainActivity / MainViewController).
* Defaults to NoOp until [install] is called.
*/
object PlatformServices {
var desktopLyric: DesktopLyricController = NoOpDesktopLyric
var homeWidget: HomeWidgetController = NoOpHomeWidget
var deepLink: DeepLinkHandler = NoOpDeepLink
var usbMount: UsbMountMonitor = NoOpUsbMount
var share: ShareBridge = NoOpShare
var mediaSession: MediaSessionHook = NoOpMediaSession
var analytics: AnalyticsHook = NoOpAnalytics
var carPlay: CarPlayHook = NoOpCarPlay
var pip: PictureInPictureHook = NoOpPiP
var carPlayCatalog: CarPlayCatalog = CarPlayCatalog()
fun install(
desktopLyric: DesktopLyricController = this.desktopLyric,
homeWidget: HomeWidgetController = this.homeWidget,
deepLink: DeepLinkHandler = this.deepLink,
usbMount: UsbMountMonitor = this.usbMount,
share: ShareBridge = this.share,
mediaSession: MediaSessionHook = this.mediaSession,
analytics: AnalyticsHook = this.analytics,
carPlay: CarPlayHook = this.carPlay,
pip: PictureInPictureHook = this.pip,
carPlayCatalog: CarPlayCatalog = this.carPlayCatalog,
) {
this.desktopLyric = desktopLyric
this.homeWidget = homeWidget
this.deepLink = deepLink
this.usbMount = usbMount
this.share = share
this.mediaSession = mediaSession
this.analytics = analytics
this.carPlay = carPlay
this.pip = pip
this.carPlayCatalog = carPlayCatalog
this.carPlay.bind(carPlayCatalog)
}
}
/**
* Module index for full-feature checklist.
*/
object FeatureModuleIndex {
val modules: List<String> = listOf(
"[done] OSS metadata ingest",
"[done] Library browse (songs/albums/artists/love/menu/recent)",
"[done] Playback queue + Android MediaSession service (single ExoPlayer)",
"[done] Lyric parse + scrolling UI",
"[done] Love / Menu / History + sync merge",
"[done] IP connect transfer + QR camera (Android)",
"[done] Data sync client",
"[done] Settings (theme/HTTP/sleep/update/desktop lyric)",
"[done] Drive mode",
"[done] SD/USB list + USB mount monitor",
"[done] App update check",
"[done] Home widget (Android Glance; iOS WidgetKit sources)",
"[done] Desktop / floating lyric (Android overlay; iOS PiP sources)",
"[done] CarPlay catalog model + iOS SceneDelegate sources",
"[done] Share bridge + deep link llmp://",
"[done] Moegirl / Tachie WebView",
"[done] Daily push text",
"[done] Module index / logs",
"[done] Permissions screen",
"[done] Umeng analytics bridge (privacy-gated)",
"[done] iOS AVPlayer + FileSystem.writeBytes",
)
}
@@ -0,0 +1,16 @@
package top.zhushenwudi.llmp.platform
/**
* UI-facing platform actions (scan / webview / overlay / toast / image pick).
*/
expect object PlatformUi {
fun openQrScanner(onResult: (String) -> Unit)
fun openWebView(url: String, title: String? = null)
fun requestOverlayPermission()
fun moveTaskToBack()
fun showToast(message: String)
/** Pick an image from gallery; [onResult] receives absolute path or null. */
fun pickImage(onResult: (String?) -> Unit)
fun openExternalUrl(url: String)
fun exitApp()
}
@@ -0,0 +1,7 @@
package top.zhushenwudi.llmp.player
enum class LoopMode {
LIST,
SINGLE,
SHUFFLE,
}
@@ -0,0 +1,45 @@
package top.zhushenwudi.llmp.player
import kotlinx.coroutines.flow.StateFlow
import top.zhushenwudi.llmp.domain.Music
/**
* Platform player abstraction. UI depends only on this interface.
*/
expect class PlayerController {
val state: StateFlow<PlayerState>
/**
* Resolve a playable URI from library [Music] (relative musicPath + baseUrl).
* Must NOT mutate [Music.musicPath] — lyrics need the relative path.
*/
var resolvePlaybackUri: ((Music) -> String?)?
/**
* Resolve cover URI/path for media notification / Now Playing artwork.
* Android: file:// or https:// URI. iOS: absolute local filesystem path preferred.
*/
var resolveCoverUri: ((Music) -> String?)?
fun setQueue(items: List<Music>, startIndex: Int = 0)
fun play()
fun pause()
fun toggle()
fun seekTo(positionMs: Long)
fun next()
fun previous()
/** Jump to queue index (Flutter just_audio seek index). Keeps playing if already playing. */
fun playAtIndex(index: Int)
fun setLoopMode(mode: LoopMode)
/** Flutter removeMusic — remove one item without rebuilding the whole engine queue when possible. */
fun removeAt(index: Int)
/** Flutter removeAllMusics / clearPlayerStatus. */
fun clearQueue()
/** Play a local file path or content URI string. */
fun playUri(uri: String, title: String? = null)
fun release()
}
@@ -0,0 +1,14 @@
package top.zhushenwudi.llmp.player
import top.zhushenwudi.llmp.domain.Music
data class PlayerState(
val current: Music? = null,
val queue: List<Music> = emptyList(),
val index: Int = -1,
val isPlaying: Boolean = false,
val positionMs: Long = 0,
val durationMs: Long = 0,
val loopMode: LoopMode = LoopMode.LIST,
val error: String? = null,
)
@@ -0,0 +1,39 @@
package top.zhushenwudi.llmp.player
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class SleepTimer(
private val scope: CoroutineScope,
private val onFire: () -> Unit,
) {
private val _remainingMs = MutableStateFlow(0L)
val remainingMs: StateFlow<Long> = _remainingMs.asStateFlow()
private var job: Job? = null
fun schedule(minutes: Int) {
cancel()
if (minutes <= 0) return
var left = minutes * 60_000L
_remainingMs.value = left
job = scope.launch {
while (left > 0) {
delay(1_000)
left -= 1_000
_remainingMs.value = left.coerceAtLeast(0)
}
onFire()
}
}
fun cancel() {
job?.cancel()
job = null
_remainingMs.value = 0
}
}
@@ -0,0 +1,52 @@
package top.zhushenwudi.llmp.protocol
import top.zhushenwudi.llmp.Const
import top.zhushenwudi.llmp.domain.FtpCmd
/**
* Pure handshake logic for `transVer` negotiation.
*
* Mobile initiates with `{cmd:"version", body:"<transVer>"}`;
* peer replies with the same shape. Mismatch → disconnect (no silent continue).
*/
object Handshake {
data class Result(
val ok: Boolean,
val localVersion: Int,
val remoteVersion: Int?,
val reason: String? = null,
)
fun buildVersionRequest(localVersion: Int = Const.TRANS_VER): FtpCmd =
FtpCmd(cmd = ProtocolCmd.VERSION.wire, body = localVersion.toString())
fun parseRemoteVersion(cmd: FtpCmd): Int? {
if (cmd.cmd != ProtocolCmd.VERSION.wire) return null
return cmd.body.trim().toIntOrNull()
}
fun verify(
remoteCmd: FtpCmd,
localVersion: Int = Const.TRANS_VER,
): Result {
val remote = parseRemoteVersion(remoteCmd)
?: return Result(
ok = false,
localVersion = localVersion,
remoteVersion = null,
reason = "expected cmd=version, got=${remoteCmd.cmd}",
)
return if (remote == localVersion) {
Result(ok = true, localVersion = localVersion, remoteVersion = remote)
} else {
Result(
ok = false,
localVersion = localVersion,
remoteVersion = remote,
reason = "transVer mismatch: local=$localVersion remote=$remote",
)
}
}
fun verifyVersions(local: Int, remote: Int): Boolean = local == remote
}
@@ -0,0 +1,36 @@
package top.zhushenwudi.llmp.protocol
/**
* WiFi transfer / sync command words from Docs/03.
*/
enum class ProtocolCmd(val wire: String) {
VERSION("version"),
SYSTEM("system"),
PORT("port"),
PREPARE("prepare"),
MUSIC_LIST("musicList"),
READY("ready"),
DOWNLOAD("download"),
DOWNLOADING("downloading"),
DOWNLOAD_SUCCESS("download success"),
DOWNLOAD_FAIL("download fail"),
CONNECTED("connected"),
PHONE2PC("phone2pc"),
PC2PHONE("pc2phone"),
FINISH("finish"),
STOP("stop"),
BACK("back");
companion object {
fun fromWire(cmd: String): ProtocolCmd? =
entries.firstOrNull { it.wire.equals(cmd, ignoreCase = false) }
}
}
enum class TransferChannel {
/** Port 4388 – song file transfer. */
MUSIC,
/** Port 4389 – love/menu data sync. */
DATA,
}
@@ -0,0 +1,66 @@
package top.zhushenwudi.llmp.settings
import com.russhwolf.settings.Settings
import com.russhwolf.settings.set
import top.zhushenwudi.llmp.player.LoopMode
class AppSettings(private val settings: Settings) {
var isDark: Boolean
get() = settings.getBoolean(SettingsKeys.DARK, false)
set(value) = settings.set(SettingsKeys.DARK, value)
var isColorful: Boolean
get() = settings.getBoolean(SettingsKeys.COLORFUL, false)
set(value) = settings.set(SettingsKeys.COLORFUL, value)
var followSystemTheme: Boolean
get() = settings.getBoolean(SettingsKeys.WITH_SYSTEM_THEME, true)
set(value) = settings.set(SettingsKeys.WITH_SYSTEM_THEME, value)
var loopMode: LoopMode
get() {
val raw = settings.getStringOrNull(SettingsKeys.LOOP_MODE)
return LoopMode.entries.firstOrNull { it.name == raw } ?: LoopMode.LIST
}
set(value) = settings.set(SettingsKeys.LOOP_MODE, value.name)
var dataVersion: String
get() = settings.getString(SettingsKeys.DATA_VERSION, "")
set(value) = settings.set(SettingsKeys.DATA_VERSION, value)
var enableHttp: Boolean
get() = settings.getBoolean(SettingsKeys.ENABLE_HTTP, false)
set(value) = settings.set(SettingsKeys.ENABLE_HTTP, value)
var httpUrl: String
get() = settings.getString(SettingsKeys.HTTP_URL, "")
set(value) = settings.set(SettingsKeys.HTTP_URL, value)
var enableDesktopLyric: Boolean
get() = settings.getBoolean(SettingsKeys.ENABLE_DESKTOP_LYRIC, false)
set(value) = settings.set(SettingsKeys.ENABLE_DESKTOP_LYRIC, value)
var aiPicture: Boolean
get() = settings.getBoolean(SettingsKeys.AI_PICTURE, true)
set(value) = settings.set(SettingsKeys.AI_PICTURE, value)
var sortOrder: String
get() = settings.getString(SettingsKeys.SORT_ORDER, "ASC")
set(value) = settings.set(SettingsKeys.SORT_ORDER, value)
var sdPath: String
get() = settings.getString(SettingsKeys.SD_PATH, "")
set(value) = settings.set(SettingsKeys.SD_PATH, value)
var lastPcHost: String
get() = settings.getString("SP_LAST_PC_HOST", "")
set(value) = settings.set("SP_LAST_PC_HOST", value)
var enableBackground: Boolean
get() = settings.getBoolean(SettingsKeys.ENABLE_BG, false)
set(value) = settings.set(SettingsKeys.ENABLE_BG, value)
var backgroundPhoto: String
get() = settings.getString(SettingsKeys.BG_PHOTO, "")
set(value) = settings.set(SettingsKeys.BG_PHOTO, value)
}
@@ -0,0 +1,5 @@
package top.zhushenwudi.llmp.settings
import com.russhwolf.settings.Settings
expect fun createSettings(): Settings
@@ -0,0 +1,17 @@
package top.zhushenwudi.llmp.settings
object SettingsKeys {
const val DARK = "SP_IS_DARK"
const val COLORFUL = "SP_IS_COLORFUL"
const val WITH_SYSTEM_THEME = "SP_With_System_Theme"
const val LOOP_MODE = "SP_LOOP_MODE"
const val DATA_VERSION = "SP_DATA_VERSION"
const val ENABLE_HTTP = "SP_ENABLE_HTTP"
const val HTTP_URL = "SP_HTTP_URL"
const val ENABLE_DESKTOP_LYRIC = "SP_OPEN_DESKTOP_LYRIC"
const val AI_PICTURE = "SP_AI_PICTURE"
const val SORT_ORDER = "SP_SORT_ORDER"
const val SD_PATH = "SP_SD_PATH"
const val ENABLE_BG = "SP_ENABLE_BACKGROUND"
const val BG_PHOTO = "SP_BACKGROUND_PHOTO"
}
@@ -0,0 +1,123 @@
package top.zhushenwudi.llmp.splash
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import top.zhushenwudi.llmp.Const
import top.zhushenwudi.llmp.db.currentTimeMs
import top.zhushenwudi.llmp.network.OssApi
import top.zhushenwudi.llmp.platform.FileSystem
import kotlin.random.Random
/**
* Flutter `SplashPhoto` / `SDUtils` splash cache.
* Cache dir = `{filesRoot}splash/` (NOT under LoveLive), matching Flutter SDUtils.path + splash/.
*
* Splash UI only reads local cache; config fetch + missing downloads run after splash
* via [syncInBackground].
*/
class SplashPhotoService(
private val fs: FileSystem,
private val oss: OssApi,
private val scope: CoroutineScope,
) {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private val splashDir: String
get() = fs.filesRoot.trimEnd('/', '\\') + "/splash/"
/**
* Random local cached splash path, or null if cache is empty.
* Does **not** hit the network.
*/
suspend fun pickCachedDisplayPath(): String? = withContext(Dispatchers.Default) {
fs.ensureDir(splashDir)
val cached = listCachedPhotos()
if (cached.isEmpty()) null else cached[Random.nextInt(cached.size)]
}
/** Fetch splash config and download missing images after the splash screen. */
fun syncInBackground() {
scope.launch(Dispatchers.Default) {
runCatching { syncFromNetwork() }
}
}
private suspend fun syncFromNetwork() {
fs.ensureDir(splashDir)
val text = oss.fetchText(Const.SPLASH_CONFIG_URL)
val config = json.decodeFromString<SplashConfig>(text)
// forceChoose only affects next cold start once the file is already cached
enqueueMissingDownloads(config)
}
private fun listCachedPhotos(): List<String> =
fs.listFiles(splashDir).filter { path ->
val lower = path.lowercase()
lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") ||
lower.endsWith(".webp")
}
private fun localPathFor(singer: String, index: Int): String =
// Flat cache names match Flutter download: splash/bg_{singer}_{index}.png
splashDir + "bg_${singer}_$index.png"
private suspend fun enqueueMissingDownloads(config: SplashConfig) {
val jobs = mutableListOf<Pair<String, String>>()
config.bg.forEach { bg ->
for (index in 1..bg.size) {
val url = "${Const.SPLASH_URL}${bg.singer}/bg_${bg.singer}_$index.png"
val path = localPathFor(bg.singer, index)
if (!fs.exists(path)) {
jobs += url to path
}
}
}
// Also ensure forceChoose target is present when still active
config.forceChoose?.let { force ->
val endTime = (force["endTime"] as? JsonPrimitive)?.longOrNull
if (endTime != null && endTime >= currentTimeMs()) {
val uid = (force["uid"] as? JsonPrimitive)?.contentOrNull
val index = (force["index"] as? JsonPrimitive)?.intOrNull
if (uid != null && index != null) {
val bg = config.bg.firstOrNull { it.uid == uid }
if (bg != null && index in 1..bg.size) {
val path = localPathFor(bg.singer, index)
val url = "${Const.SPLASH_URL}${bg.singer}/bg_${bg.singer}_$index.png"
if (!fs.exists(path) && jobs.none { it.second == path }) {
jobs += url to path
}
}
}
}
}
if (jobs.isEmpty()) return
jobs.forEach { (url, path) ->
runCatching {
val bytes = oss.downloadBytes(url)
fs.writeBytes(path, bytes)
}
}
}
}
@Serializable
private data class SplashConfig(
val forceChoose: JsonObject? = null,
val bg: List<SplashBg> = emptyList(),
)
@Serializable
private data class SplashBg(
val uid: String,
val singer: String,
val size: Int,
)
@@ -0,0 +1,139 @@
package top.zhushenwudi.llmp.transfer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import top.zhushenwudi.llmp.Const
import top.zhushenwudi.llmp.db.LibraryRepository
import top.zhushenwudi.llmp.domain.FtpCmd
import top.zhushenwudi.llmp.domain.Menu
import top.zhushenwudi.llmp.domain.TransData
import top.zhushenwudi.llmp.domain.TransMenu
import top.zhushenwudi.llmp.network.WsClient
import top.zhushenwudi.llmp.protocol.Handshake
import top.zhushenwudi.llmp.protocol.ProtocolCmd
import top.zhushenwudi.llmp.protocol.TransferChannel
data class SyncUiState(
val host: String = "",
val connected: Boolean = false,
val verified: Boolean = false,
val status: String = "未连接",
val error: String? = null,
)
class DataSyncService(
private val ws: WsClient,
private val library: LibraryRepository,
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default),
) {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private val _state = MutableStateFlow(SyncUiState())
val state: StateFlow<SyncUiState> = _state.asStateFlow()
private var job: Job? = null
fun connect(host: String) {
job?.cancel()
_state.value = SyncUiState(host = host, status = "连接中…")
job = scope.launch {
if (!ws.connect(host, TransferChannel.DATA)) {
_state.update { it.copy(error = "连接失败", status = "失败") }
return@launch
}
_state.update { it.copy(connected = true, status = "握手中…") }
ws.sendVersion()
ws.incoming().collect { handle(it) }
}
}
fun pushPhoneToPc(isCover: Boolean) {
scope.launch {
if (!_state.value.verified) {
_state.update { it.copy(error = "未完成握手") }
return@launch
}
val menus = library.getAllMenus()
.filter { if (isCover) true else it.id > 100 }
.map {
TransMenu(
menuId = it.id.toInt(),
name = it.name,
date = it.date,
musicList = it.music,
)
}
val data = TransData(
love = library.getAllLove(),
menu = menus,
isCover = isCover,
)
ws.send(FtpCmd(ProtocolCmd.PHONE2PC.wire, json.encodeToString(data)))
_state.update { it.copy(status = "已发送 phone2pc") }
}
}
fun requestPcToPhone() {
scope.launch {
val data = TransData(love = library.getAllLove(), menu = emptyList(), isCover = false)
ws.send(FtpCmd(ProtocolCmd.PC2PHONE.wire, json.encodeToString(data)))
_state.update { it.copy(status = "已请求 pc2phone") }
}
}
fun stop() {
scope.launch { ws.close() }
job?.cancel()
_state.update { it.copy(connected = false, verified = false, status = "已断开") }
}
private suspend fun handle(cmd: FtpCmd) {
when (ProtocolCmd.fromWire(cmd.cmd)) {
ProtocolCmd.VERSION -> {
val result = Handshake.verify(cmd, Const.TRANS_VER)
if (!result.ok) {
_state.update { it.copy(error = result.reason, status = "版本不匹配") }
ws.close()
return
}
_state.update { it.copy(verified = true, status = "已验证") }
ws.send(FtpCmd(ProtocolCmd.CONNECTED.wire, ""))
}
ProtocolCmd.PHONE2PC -> {
val data = json.decodeFromString<TransData>(cmd.body)
library.replaceLove(data.love)
_state.update { it.copy(status = "已应用 PC 侧喜欢列表") }
ws.close()
}
ProtocolCmd.PC2PHONE -> {
val data = json.decodeFromString<TransData>(cmd.body)
library.replaceLove(data.love)
if (data.isCover) library.clearMenus() else library.deletePcMenus()
data.menu.forEach { tm ->
val existingIds = library.getAllMusic().mapNotNull { it.musicId }.toSet()
library.upsertMenu(
Menu(
id = tm.menuId.toLong(),
isPhone = tm.menuId > 100,
music = tm.musicList.filter { it in existingIds },
date = tm.date,
name = tm.name,
),
)
}
_state.update { it.copy(status = "已应用 PC 歌单/喜欢") }
}
ProtocolCmd.BACK, ProtocolCmd.STOP, ProtocolCmd.FINISH -> {
_state.update { it.copy(status = "会话结束") }
ws.close()
}
else -> Unit
}
}
}
@@ -0,0 +1,223 @@
package top.zhushenwudi.llmp.transfer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import top.zhushenwudi.llmp.Const
import top.zhushenwudi.llmp.db.LibraryRepository
import top.zhushenwudi.llmp.domain.Album
import top.zhushenwudi.llmp.domain.DownloadMusic
import top.zhushenwudi.llmp.domain.FtpCmd
import top.zhushenwudi.llmp.domain.Music
import top.zhushenwudi.llmp.network.OssApi
import top.zhushenwudi.llmp.network.WsClient
import top.zhushenwudi.llmp.platform.FileSystem
import top.zhushenwudi.llmp.protocol.Handshake
import top.zhushenwudi.llmp.protocol.ProtocolCmd
import top.zhushenwudi.llmp.protocol.TransferChannel
import top.zhushenwudi.llmp.util.PathResolver
data class TransferUiState(
val host: String = "",
val connected: Boolean = false,
val verified: Boolean = false,
val httpPort: String = "",
val status: String = "未连接",
val current: String = "",
val progress: Int = 0,
val total: Int = 0,
val error: String? = null,
)
/**
* PC → phone WiFi transfer client (WS 4388 + HTTP files).
*/
class MusicTransferService(
private val ws: WsClient,
private val http: OssApi,
private val library: LibraryRepository,
private val fs: FileSystem,
private val paths: PathResolver,
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default),
) {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private val _state = MutableStateFlow(TransferUiState())
val state: StateFlow<TransferUiState> = _state.asStateFlow()
private var job: Job? = null
private var pending: List<DownloadMusic> = emptyList()
private var systemBody: String = "android"
fun setPlatform(system: String) {
systemBody = system
}
fun connect(host: String) {
job?.cancel()
_state.value = TransferUiState(host = host, status = "连接中…")
job = scope.launch {
if (!ws.connect(host, TransferChannel.MUSIC)) {
_state.update { it.copy(status = "连接失败", error = "无法连接 $host:${Const.WS_MUSIC_PORT}") }
return@launch
}
_state.update { it.copy(connected = true, status = "握手中…") }
ws.sendVersion()
ws.incoming().collect { cmd -> handle(cmd) }
}
}
fun stop() {
scope.launch {
runCatching { ws.send(FtpCmd(ProtocolCmd.FINISH.wire, "")) }
ws.close()
_state.update { it.copy(connected = false, verified = false, status = "已断开") }
}
job?.cancel()
job = null
}
private suspend fun handle(cmd: FtpCmd) {
when (ProtocolCmd.fromWire(cmd.cmd)) {
ProtocolCmd.VERSION -> {
val result = Handshake.verify(cmd, Const.TRANS_VER)
if (!result.ok) {
_state.update {
it.copy(error = result.reason, status = "版本不匹配", verified = false)
}
ws.close()
return
}
_state.update { it.copy(verified = true, status = "已验证") }
ws.send(FtpCmd(ProtocolCmd.SYSTEM.wire, systemBody))
}
ProtocolCmd.PORT -> {
_state.update { it.copy(httpPort = cmd.body, status = "HTTP 端口 ${cmd.body}") }
}
ProtocolCmd.PREPARE -> {
val parts = cmd.body.split(" === ")
val listJson = parts.getOrNull(0).orEmpty()
val needAll = parts.getOrNull(1)?.toBooleanStrictOrNull() ?: false
val all = runCatching {
json.decodeFromString<List<DownloadMusic>>(listJson)
}.getOrDefault(emptyList())
val need = if (needAll) {
all
} else {
all.filter { item ->
val dest = paths.storageRoot() + item.baseUrl.trimStart('/') +
item.musicPath.trimStart('/')
!fs.exists(dest)
}
}
pending = need
_state.update {
it.copy(total = need.size, progress = 0, status = "待传 ${need.size} 首")
}
val ids = json.encodeToString(need.map { it.musicUId })
ws.send(FtpCmd(ProtocolCmd.MUSIC_LIST.wire, ids))
}
ProtocolCmd.READY -> {
pending = runCatching {
json.decodeFromString<List<DownloadMusic>>(cmd.body)
}.getOrDefault(emptyList())
_state.update {
it.copy(total = pending.size, progress = 0, status = "就绪 ${pending.size}")
}
}
ProtocolCmd.DOWNLOAD -> {
val parts = cmd.body.split(" === ")
val musicUId = parts.getOrNull(0).orEmpty()
val isLast = parts.getOrNull(1)?.trim().equals("true", ignoreCase = true)
val item = pending.find { it.musicUId == musicUId }
if (item == null) {
ws.send(FtpCmd(ProtocolCmd.DOWNLOAD_FAIL.wire, musicUId))
return
}
_state.update {
it.copy(
current = item.musicName,
status = "下载中 ${item.musicName}",
)
}
val ok = downloadOne(item)
if (ok) {
importOne(item)
ws.send(FtpCmd(ProtocolCmd.DOWNLOAD_SUCCESS.wire, musicUId))
_state.update {
it.copy(progress = it.progress + 1)
}
} else {
ws.send(FtpCmd(ProtocolCmd.DOWNLOAD_FAIL.wire, musicUId))
}
if (isLast) {
ws.send(FtpCmd(ProtocolCmd.FINISH.wire, ""))
_state.update { it.copy(status = "传输完成") }
}
}
ProtocolCmd.STOP, ProtocolCmd.BACK -> {
_state.update { it.copy(status = "对方中断") }
ws.close()
}
else -> Unit
}
}
private suspend fun downloadOne(item: DownloadMusic): Boolean {
val host = _state.value.host
val port = _state.value.httpPort.ifBlank { "10000" }
val baseHttp = "http://$host:$port/"
fun url(rel: String) = baseHttp + (item.baseUrl + rel).trimStart('/')
val root = paths.storageRoot()
val coverDest = root + item.baseUrl.trimStart('/') + item.coverPath.trimStart('/')
val musicDest = root + item.baseUrl.trimStart('/') + item.musicPath.trimStart('/')
return try {
if (item.coverPath.isNotBlank()) {
fs.writeBytes(coverDest, http.downloadBytes(url(item.coverPath)))
}
fs.writeBytes(musicDest, http.downloadBytes(url(item.musicPath)))
} catch (_: Throwable) {
false
}
}
private fun importOne(item: DownloadMusic) {
library.upsertAlbum(
Album(
albumId = item.albumUId,
albumName = item.albumName,
date = item.date,
coverPath = item.coverPath,
category = item.category,
group = item.group,
existFile = true,
),
)
library.upsertMusic(
Music(
musicId = item.musicUId,
musicName = item.musicName,
artist = item.artist,
artistBin = item.artistBin,
albumId = item.albumUId,
albumName = item.albumName,
coverPath = item.coverPath,
musicPath = item.musicPath,
time = item.totalTime,
baseUrl = item.baseUrl,
category = item.category,
group = item.group,
neteaseId = item.neteaseId,
date = item.date,
existFile = true,
),
)
}
}
@@ -0,0 +1,16 @@
package top.zhushenwudi.llmp.update
import kotlinx.serialization.json.Json
import top.zhushenwudi.llmp.domain.VersionInfo
import top.zhushenwudi.llmp.network.OssApi
class UpdateChecker(private val oss: OssApi) {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
suspend fun check(currentCode: Int = 1): VersionInfo? {
val raw = runCatching { oss.fetchVersionJson() }.getOrNull() ?: return null
val info = runCatching { json.decodeFromString<VersionInfo>(raw) }.getOrNull()
?: return null
return if (info.versionCode > currentCode) info else null
}
}
@@ -0,0 +1,91 @@
package top.zhushenwudi.llmp.util
import top.zhushenwudi.llmp.domain.Album
import top.zhushenwudi.llmp.domain.Music
import top.zhushenwudi.llmp.platform.FileSystem
import top.zhushenwudi.llmp.settings.AppSettings
class PathResolver(
private val fs: FileSystem,
private val settings: AppSettings,
) {
/**
* Flutter `SDUtils.path`: prefer user-selected [AppSettings.sdPath], else [FileSystem.musicRoot].
*/
fun storageRoot(): String {
val sd = settings.sdPath.trim()
if (sd.isNotEmpty()) {
return sd.trimEnd('/', '\\') + "/"
}
return fs.musicRoot
}
fun absoluteMusicPath(music: Music): String? {
val base = music.baseUrl ?: return null
val path = music.musicPath ?: return null
return storageRoot() + base.trimStart('/') + path.trimStart('/')
}
fun absoluteCoverPath(music: Music): String? {
val base = music.baseUrl ?: return null
val path = music.coverPath ?: return null
return storageRoot() + base.trimStart('/') + path.trimStart('/')
}
fun absoluteAlbumCoverPath(album: Album): String? {
val path = album.coverPath ?: return null
return storageRoot() + path.trimStart('/')
}
/**
* Flutter [SDUtils.getImgPathFromMusic]: local file URI, HTTP URL, or null → UI shows logo.
*/
fun coverDisplayUri(music: Music): String? {
if (music.musicId == null) return null
if (music.existFile == true) {
val path = absoluteCoverPath(music) ?: return null
return if (fs.exists(path)) "file://$path" else null
}
if (settings.enableHttp && settings.httpUrl.isNotBlank()) {
val rel = (music.baseUrl.orEmpty() + music.coverPath.orEmpty()).trimStart('/')
if (rel.isBlank()) return null
return settings.httpUrl.trimEnd('/') + "/" + rel
}
return null
}
/** Flutter [SDUtils.getImgPathFromAlbum]. */
fun coverDisplayUri(album: Album): String? {
val rel = album.coverPath ?: return null
if (album.existFile == true) {
val path = absoluteAlbumCoverPath(album) ?: return null
return if (fs.exists(path)) "file://$path" else null
}
if (settings.enableHttp && settings.httpUrl.isNotBlank()) {
return settings.httpUrl.trimEnd('/') + "/" + rel.trimStart('/')
}
return null
}
fun playbackUri(music: Music): String? {
if (settings.enableHttp && settings.httpUrl.isNotBlank()) {
val base = music.baseUrl.orEmpty()
var path = music.musicPath.orEmpty()
if (path.endsWith(".wav", ignoreCase = true)) {
path = path.dropLast(4) + ".flac"
}
return settings.httpUrl.trimEnd('/') + "/" +
(base + path).trimStart('/')
}
val local = absoluteMusicPath(music) ?: return null
if (fs.exists(local)) return "file://$local"
return music.musicPath?.takeIf {
it.startsWith("http") || it.startsWith("asset")
}
}
fun refreshExistFile(music: Music): Music {
val path = absoluteMusicPath(music)
return music.copy(existFile = path != null && fs.exists(path))
}
}