initial commit
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user