function adapt

This commit is contained in:
2026-08-30 19:36:25 +08:00
parent bea5ead33d
commit 560f3cbac3
450 changed files with 21193 additions and 8334 deletions
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.db
package com.zhushenwudi.lovelivemusicplayer.db
import android.content.Context
import app.cash.sqldelight.db.SqlDriver
@@ -0,0 +1,22 @@
package com.zhushenwudi.lovelivemusicplayer.db
import java.util.Calendar
actual fun currentTimeMs(): Long = System.currentTimeMillis()
actual fun formatLocalHm(epochMs: Long): String {
val cal = Calendar.getInstance().apply { timeInMillis = epochMs }
val hour = cal.get(Calendar.HOUR_OF_DAY)
val minute = cal.get(Calendar.MINUTE)
return hour.toString().padStart(2, '0') + ":" + minute.toString().padStart(2, '0')
}
actual fun formatLocalHms(epochMs: Long): String {
val cal = Calendar.getInstance().apply { timeInMillis = epochMs }
val hour = cal.get(Calendar.HOUR_OF_DAY)
val minute = cal.get(Calendar.MINUTE)
val second = cal.get(Calendar.SECOND)
return hour.toString().padStart(2, '0') + ":" +
minute.toString().padStart(2, '0') + ":" +
second.toString().padStart(2, '0')
}
@@ -0,0 +1,5 @@
package com.zhushenwudi.lovelivemusicplayer.i18n
import java.util.Locale
actual fun detectSystemLang(): AppLang = langFromTag(Locale.getDefault().toLanguageTag())
@@ -0,0 +1,7 @@
package com.zhushenwudi.lovelivemusicplayer.lyric
actual object PlatformInfo {
actual val allowEulaLyric: Boolean = true
actual val isIos: Boolean = false
actual val appVersion: String = ""
}
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.network
package com.zhushenwudi.lovelivemusicplayer.network
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
@@ -1,7 +1,7 @@
package top.zhushenwudi.llmp.platform
package com.zhushenwudi.lovelivemusicplayer.platform
/**
* Android MediaSession metadata bridge (PlaybackService owns the session player).
* Android MediaSession 元数据桥接(PlaybackService 拥有会话播放器)。
*/
class AndroidMediaSessionBridge : MediaSessionHook {
@Volatile
@@ -1,17 +1,28 @@
package top.zhushenwudi.llmp.platform
package com.zhushenwudi.lovelivemusicplayer.platform
import android.util.Log
actual object AppLog {
private const val MAX = 3500
actual fun d(tag: String, message: String) = log(Log.DEBUG, tag, message)
actual fun d(tag: String, message: String) {
AppLogStore.append("D", tag, message)
log(Log.DEBUG, tag, message)
}
actual fun i(tag: String, message: String) = log(Log.INFO, tag, message)
actual fun i(tag: String, message: String) {
AppLogStore.append("I", tag, message)
log(Log.INFO, tag, message)
}
actual fun w(tag: String, message: String) = log(Log.WARN, tag, message)
actual fun w(tag: String, message: String) {
AppLogStore.append("W", tag, message)
log(Log.WARN, tag, message)
}
actual fun e(tag: String, message: String, throwable: Throwable?) {
val full = if (throwable != null) "$message | ${throwable.message}" else message
AppLogStore.append("E", tag, full)
if (throwable != null) {
chunk(message).forEachIndexed { index, part ->
if (index == 0) Log.e(tag, part, throwable) else Log.e(tag, part)
@@ -1,8 +1,6 @@
package top.zhushenwudi.llmp.platform
package com.zhushenwudi.lovelivemusicplayer.platform
import android.content.Context
import android.os.Environment
import android.os.storage.StorageManager
import java.io.File
actual class FileSystem(private val context: Context) {
@@ -32,6 +30,26 @@ actual class FileSystem(private val context: Context) {
false
}
actual fun writeBegin(absolutePath: String): Boolean =
try {
val file = File(absolutePath)
file.parentFile?.mkdirs()
file.writeBytes(ByteArray(0))
true
} catch (_: Throwable) {
false
}
actual fun writeAppend(absolutePath: String, bytes: ByteArray, offset: Int, length: Int): Boolean =
try {
java.io.FileOutputStream(File(absolutePath), true).use { out ->
out.write(bytes, offset, length)
}
true
} catch (_: Throwable) {
false
}
actual fun listFiles(absoluteDir: String): List<String> {
val dir = File(absoluteDir)
if (!dir.isDirectory) return emptyList()
@@ -41,18 +59,17 @@ actual class FileSystem(private val context: Context) {
.orEmpty()
}
/**
* 对齐 Flutter `SDUtils.getUsbPathList`:
* `getExternalFilesDirs` 含内部存储 + 可移动卷的应用私有目录,
* 再按 package 过滤。
*/
actual fun listUsbRoots(): List<String> {
val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager
?: return emptyList()
return try {
sm.storageVolumes.mapNotNull { volume ->
if (volume.isRemovable) {
@Suppress("DEPRECATION")
volume.directory?.absolutePath
} else null
}
} catch (_: Throwable) {
emptyList()
val dirs = context.getExternalFilesDirs(null) ?: return emptyList()
val pkg = context.packageName
return dirs.mapNotNull { dir ->
val path = dir?.absolutePath ?: return@mapNotNull null
if (path.contains(pkg)) path else null
}
}
}
@@ -1,14 +1,16 @@
package top.zhushenwudi.llmp.platform
package com.zhushenwudi.lovelivemusicplayer.platform
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.view.WindowManager
import android.widget.Toast
import com.zhushenwudi.lovelivemusicplayer.i18n.I18n
/**
* Holds the current Activity for launching platform UI from common code.
* 保存当前 Activity,供 common 代码启动平台 UI。
*/
object AndroidUiHost {
@Volatile
@@ -17,15 +19,8 @@ object AndroidUiHost {
@Volatile
var qrResultHandler: ((String) -> Unit)? = null
@Volatile
var imagePickHandler: ((String?) -> Unit)? = null
/** Set from MainActivity after registering Activity Result launcher. */
@Volatile
var launchImagePicker: (() -> Unit)? = null
const val QR_SCAN_ACTIVITY = "top.zhushenwudi.llmp.native.QrScanActivity"
const val WEB_VIEW_ACTIVITY = "top.zhushenwudi.llmp.native.WebViewActivity"
const val QR_SCAN_ACTIVITY = "com.zhushenwudi.lovelivemusicplayer.native.QrScanActivity"
const val WEB_VIEW_ACTIVITY = "com.zhushenwudi.lovelivemusicplayer.native.WebViewActivity"
const val EXTRA_SCAN_RESULT = "scan_result"
const val EXTRA_URL = "url"
const val EXTRA_TITLE = "title"
@@ -39,7 +34,15 @@ actual object PlatformUi {
}
actual fun openWebView(url: String, title: String?) {
val act = AndroidUiHost.activity ?: return
val act = AndroidUiHost.activity
if (act == null) {
showToast(I18n.t("cannot_open_page"))
return
}
if (url.isBlank()) {
showToast(I18n.t("invalid_link"))
return
}
act.startActivity(
Intent().setClassName(act, AndroidUiHost.WEB_VIEW_ACTIVITY).apply {
putExtra(AndroidUiHost.EXTRA_URL, url)
@@ -66,22 +69,12 @@ actual object PlatformUi {
actual fun showToast(message: String) {
val act = AndroidUiHost.activity ?: return
val text = message.asLtrDisplay()
act.runOnUiThread {
Toast.makeText(act, message, Toast.LENGTH_SHORT).show()
Toast.makeText(act, text, Toast.LENGTH_SHORT).show()
}
}
actual fun pickImage(onResult: (String?) -> Unit) {
AndroidUiHost.imagePickHandler = onResult
val launcher = AndroidUiHost.launchImagePicker
if (launcher == null) {
onResult(null)
showToast("图片选择暂不可用")
return
}
launcher.invoke()
}
actual fun openExternalUrl(url: String) {
val act = AndroidUiHost.activity ?: return
runCatching {
@@ -93,4 +86,15 @@ actual object PlatformUi {
val act = AndroidUiHost.activity ?: return
act.finishAffinity()
}
actual fun setKeepScreenOn(enabled: Boolean) {
val act = AndroidUiHost.activity ?: return
act.runOnUiThread {
if (enabled) {
act.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
act.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
}
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.player
package com.zhushenwudi.lovelivemusicplayer.player
import android.content.Context
import androidx.media3.common.AudioAttributes
@@ -6,7 +6,7 @@ import androidx.media3.common.C
import androidx.media3.exoplayer.ExoPlayer
/**
* Single ExoPlayer shared by [PlayerController] and [PlaybackService] MediaSession.
* [com.zhushenwudi.lovelivemusicplayer.player.PlayerController] 与 [com.zhushenwudi.lovelivemusicplayer.player.PlaybackService] MediaSession 共享的单一 ExoPlayer。
*/
object AndroidPlayerHolder {
@Volatile
@@ -24,7 +24,7 @@ object AndroidPlayerHolder {
.build(),
/* handleAudioFocus = */ true,
)
// Pause when wired/BT headset is unplugged or disconnects.
// 有线/蓝牙耳机拔出或断开时暂停。
.setHandleAudioBecomingNoisy(true)
.build()
player = created
@@ -1,9 +1,10 @@
package top.zhushenwudi.llmp.player
package com.zhushenwudi.lovelivemusicplayer.player
import android.content.Context
import android.net.Uri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.CoroutineScope
@@ -16,8 +17,10 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import top.zhushenwudi.llmp.domain.Music
import top.zhushenwudi.llmp.platform.PlatformServices
import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.i18n.I18n
import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
import com.zhushenwudi.lovelivemusicplayer.platform.PlatformServices
import java.io.File
import kotlin.random.Random
@@ -34,9 +37,17 @@ actual class PlayerController(context: Context) {
actual var resolveCoverUri: ((Music) -> String?)? = null
private var queue: List<Music> = emptyList()
private var nowPlayingLyric: String? = null
private fun playbackPath(music: Music): String? =
resolvePlaybackUri?.invoke(music) ?: music.musicPath
private fun playbackPath(music: Music): String? {
resolvePlaybackUri?.invoke(music)?.let { return it }
val raw = music.musicPath ?: return null
// 与 iOS 一致:绝不将数据库相对路径当作可播放 URI。
return raw.takeIf {
it.startsWith("http") || it.startsWith("file") ||
it.startsWith("asset") || it.startsWith("content") || it.startsWith("/")
}
}
init {
player.addListener(
@@ -44,6 +55,7 @@ actual class PlayerController(context: Context) {
override fun onIsPlayingChanged(isPlaying: Boolean) {
_state.update { it.copy(isPlaying = isPlaying) }
if (isPlaying) startPositionUpdates() else positionJob?.cancel()
applyNowPlayingTitle()
syncPlatform(isPlaying = isPlaying)
}
@@ -58,14 +70,36 @@ actual class PlayerController(context: Context) {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
val idx = player.currentMediaItemIndex
val current = queue.getOrNull(idx) ?: _state.value.current
val sameSong = current?.musicId != null &&
current.musicId == _state.value.current?.musicId
if (!sameSong) nowPlayingLyric = null
_state.update {
it.copy(
index = idx,
current = queue.getOrNull(idx) ?: it.current,
current = current,
)
}
if (!sameSong) applyNowPlayingTitle()
syncPlatform(isPlaying = player.isPlaying)
}
override fun onPlayerError(error: PlaybackException) {
val uri = player.currentMediaItem?.localConfiguration?.uri
AppLog.e(
TAG,
"exo error code=${error.errorCode} name=${error.errorCodeName} " +
"msg=${error.message} uri=$uri " +
"id=${_state.value.current?.musicId} name=${_state.value.current?.musicName}",
error,
)
_state.update {
it.copy(
isPlaying = false,
error = error.message ?: error.errorCodeName,
)
}
}
},
)
}
@@ -93,13 +127,7 @@ actual class PlayerController(context: Context) {
isPlaying = isPlaying,
)
PlatformServices.desktopLyric.setPlaying(isPlaying)
PlatformServices.homeWidget.update(
songName = current?.musicName,
artist = current?.artist,
isPlaying = isPlaying,
favorite = current?.isLove == true,
coverPath = current?.coverPath,
)
// 小组件由 AppContainer.bindHomeWidget / pushHomeWidget 统一同步。
PlatformServices.carPlayCatalog.updateNowPlaying(current)
}
@@ -115,11 +143,11 @@ actual class PlayerController(context: Context) {
return toUri(raw)
}
/** Media3 notification / session metadata (title, artist, album, artwork). */
/** Media3 通知/会话元数据(标题、艺人、专辑、封面)。 */
private fun toMediaItem(music: Music, playbackPath: String): MediaItem {
val uri = toUri(playbackPath)
val metadata = MediaMetadata.Builder()
.setTitle(music.musicName ?: "未知歌曲")
.setTitle(music.musicName ?: I18n.t("unknown_song"))
.setArtist(music.artist.orEmpty())
.setAlbumTitle(music.albumName.orEmpty())
.setArtworkUri(artworkUri(music))
@@ -132,36 +160,65 @@ actual class PlayerController(context: Context) {
}
actual fun setQueue(items: List<Music>, startIndex: Int) {
player.stop()
player.clearMediaItems()
val playable = items.mapNotNull { music ->
val path = playbackPath(music) ?: return@mapNotNull null
val path = playbackPath(music)
if (path == null) {
AppLog.w(
TAG,
"setQueue drop id=${music.musicId} name=${music.musicName} reason=nullUri",
)
return@mapNotNull null
}
music to toMediaItem(music, path)
}
if (playable.isEmpty()) {
AppLog.w(TAG, "setQueue emptyPlayable startIndex=$startIndex")
player.stop()
player.clearMediaItems()
queue = items
_state.update {
it.copy(queue = items, index = -1, current = null, isPlaying = false)
it.copy(
queue = items,
index = -1,
current = null,
isPlaying = false,
error = "No playable URI in queue",
)
}
return
}
// Prefer original startIndex among playable entries.
val startMusic = items.getOrNull(startIndex)
val mediaStart = playable.indexOfFirst { it.first.musicId == startMusic?.musicId }
.takeIf { it >= 0 }
?: startIndex.coerceIn(0, playable.lastIndex)
val sameCurrent = startMusic?.musicId != null &&
startMusic.musicId == _state.value.current?.musicId &&
player.mediaItemCount > 0
val resumePos = if (sameCurrent) player.currentPosition.coerceAtLeast(0) else 0L
val wasPlaying = player.isPlaying
queue = playable.map { it.first }
val mediaItems = playable.map { it.second }
player.setMediaItems(mediaItems, mediaStart.coerceIn(0, mediaItems.lastIndex), 0)
// Preserve timeline when only splicing the queue (e.g. 「下一首播放」).
player.setMediaItems(
mediaItems,
mediaStart.coerceIn(0, mediaItems.lastIndex),
resumePos,
)
player.prepare()
if (wasPlaying && sameCurrent) {
player.play()
}
_state.update {
it.copy(
queue = queue,
index = mediaStart.coerceIn(0, queue.lastIndex),
current = queue.getOrNull(mediaStart),
positionMs = resumePos,
error = null,
isPlaying = wasPlaying && sameCurrent,
)
}
syncPlatform(isPlaying = false)
syncPlatform(isPlaying = wasPlaying && sameCurrent)
}
actual fun play() {
@@ -186,7 +243,33 @@ actual class PlayerController(context: Context) {
}
actual fun previous() {
if (player.hasPreviousMediaItem()) player.seekToPreviousMediaItem()
if (queue.isEmpty() || player.mediaItemCount == 0) return
val positionMs = player.currentPosition.coerceAtLeast(0)
if (positionMs > 3000) {
player.seekTo(player.currentMediaItemIndex, 0L)
_state.update { it.copy(positionMs = 0L) }
return
}
when (_state.value.loopMode) {
LoopMode.SHUFFLE -> player.seekToPrevious()
LoopMode.LIST -> {
if (queue.size <= 1) {
player.seekTo(0, 0L)
return
}
val currentIndex = player.currentMediaItemIndex.coerceIn(0, queue.lastIndex)
val prevIndex = if (currentIndex > 0) currentIndex - 1 else queue.lastIndex
player.seekTo(prevIndex, 0L)
}
LoopMode.SINGLE -> {
val currentIndex = player.currentMediaItemIndex.coerceIn(0, queue.lastIndex)
if (currentIndex > 0) {
player.seekTo(currentIndex - 1, 0L)
} else {
player.seekTo(0, 0L)
}
}
}
}
actual fun playAtIndex(index: Int) {
@@ -214,6 +297,52 @@ actual class PlayerController(context: Context) {
_state.update { it.copy(loopMode = mode) }
}
actual fun syncLoveState(musicId: String, isLove: Boolean) {
queue = queue.map { music ->
if (music.musicId == musicId) music.copy(isLove = isLove) else music
}
_state.update { st ->
val current = st.current?.let { music ->
if (music.musicId == musicId) music.copy(isLove = isLove) else music
}
st.copy(queue = queue, current = current)
}
}
actual fun setNowPlayingLyric(line: String?) {
val next = line?.trim()?.takeIf { it.isNotEmpty() }
if (nowPlayingLyric == next) return
nowPlayingLyric = next
// ExoPlayer 必须在主线程;歌词流在 Default 上推送。
scope.launch { applyNowPlayingTitle() }
}
/**
* 蓝牙 / 车机 / 媒体通知读的是当前 MediaItem 标题。
* 播放中有歌词则用当前句,暂停或暂无歌词则用歌名。
*/
private fun applyNowPlayingTitle() {
val current = _state.value.current ?: return
val songName = current.musicName ?: I18n.t("unknown_song")
val title = if (player.isPlaying) {
nowPlayingLyric?.takeIf { it.isNotBlank() } ?: songName
} else {
songName
}
val index = player.currentMediaItemIndex
if (index < 0 || index >= player.mediaItemCount) return
val item = player.getMediaItemAt(index)
if (item.mediaMetadata.title?.toString() == title) return
val metadata = item.mediaMetadata.buildUpon()
.setTitle(title)
.setDisplayTitle(title)
.build()
player.replaceMediaItem(
index,
item.buildUpon().setMediaMetadata(metadata).build(),
)
}
actual fun removeAt(index: Int) {
if (index !in queue.indices) return
if (queue.size == 1) {
@@ -307,6 +436,10 @@ actual class PlayerController(context: Context) {
actual fun release() {
positionJob?.cancel()
// Shared ExoPlayer is released with PlaybackService / process teardown.
// 共享 ExoPlayer 随 PlaybackService/进程销毁而释放。
}
private companion object {
const val TAG = "LLMP-Player"
}
}
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.settings
package com.zhushenwudi.lovelivemusicplayer.settings
import android.content.Context
import com.russhwolf.settings.Settings
@@ -0,0 +1,10 @@
package com.zhushenwudi.lovelivemusicplayer.util
import java.security.MessageDigest
actual fun sha512Hex(text: String): String {
val bytes = MessageDigest.getInstance("SHA-512").digest(text.encodeToByteArray())
return bytes.joinToString("") { b ->
(b.toInt() and 0xff).toString(16).padStart(2, '0')
}
}
@@ -1,3 +0,0 @@
package top.zhushenwudi.llmp.db
actual fun currentTimeMs(): Long = System.currentTimeMillis()
@@ -1,5 +0,0 @@
package top.zhushenwudi.llmp.lyric
actual object PlatformInfo {
actual val allowEulaLyric: Boolean = true
}
@@ -0,0 +1,77 @@
package com.zhushenwudi.lovelivemusicplayer
import io.ktor.http.encodeURLPathPart
/** 应用全局常量。 */
object Const {
/** 双端传输协议版本,须与 PC Electron 一致。 */
const val TRANS_VER: Int = 1
/** 设计稿基准尺寸(UI_WIDTH / UI_HEIGHT)。 */
const val UI_WIDTH: Float = 375f
const val UI_HEIGHT: Float = 812f
const val PACKAGE_ID: String = "com.zhushenwudi.lovelivemusicplayer"
/** App Store 上架 bundleId(与原 Flutter `Const.packageId` 一致)。 */
const val APPSTORE_BUNDLE_ID: String = "com.zhushenwudi.lovelivemusicplayer"
const val APPSTORE_ID: String = "1641625393"
const val APPSTORE_LOOKUP_URL: String =
"https://itunes.apple.com/lookup?bundleId=$APPSTORE_BUNDLE_ID"
const val APPSTORE_PAGE_URL: String = "https://apps.apple.com/app/id$APPSTORE_ID"
// Cloudflare R2 对象存储
const val R2_OSS_URL: String = "https://llmp-oss.zhushenwudi.top/"
// 阿里云 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"
/** QQ 互联开放平台 AppId(与原 Flutter / ShareSDK 一致)。 */
const val QQ_APP_ID: String = "102039234"
/**
* QQ 网页分享落地页。
* H5 `urlParse.getUrlKey` 从整段 href 读 `type`/`data`;
* 参数必须放在 hash 前的 query,否则 QQ 丢掉 `#/...` 后会进下载页。
*/
const val SHARE_QQ_PAGE: String = "https://shareqq.zhushenwudi.top/"
/** 歌单分享 KV(阿里 Serverless)。 */
const val SHARE_KV_URL: String =
"http://fc-mp-92601a5b-3adb-44ca-b6f6-fc4e8b3edbca.next.bspapp.com/saveShareKV"
/** WiFi 传输命令 WebSocket 端口(PC)。 */
const val WS_MUSIC_PORT: Int = 4388
/** 数据同步命令 WebSocket 端口(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"
/** 萌娘百科条目/搜索 URL(路径段 UTF-8 编码,对齐 Flutter Const.moeGirlUrl + 歌名)。 */
fun buildMoeGirlSearchUrl(query: String): String {
val q = query.trim()
if (q.isEmpty()) return MOE_GIRL_URL
return MOE_GIRL_URL + q.encodeURLPathPart()
}
}
@@ -1,16 +1,17 @@
package top.zhushenwudi.llmp.data
package com.zhushenwudi.lovelivemusicplayer.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
import com.zhushenwudi.lovelivemusicplayer.Const
import com.zhushenwudi.lovelivemusicplayer.db.LibraryRepository
import com.zhushenwudi.lovelivemusicplayer.domain.Album
import com.zhushenwudi.lovelivemusicplayer.domain.Artist
import com.zhushenwudi.lovelivemusicplayer.domain.ArtistModel
import com.zhushenwudi.lovelivemusicplayer.domain.CloudData
import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.network.OssApi
import com.zhushenwudi.lovelivemusicplayer.settings.AppSettings
import com.zhushenwudi.lovelivemusicplayer.util.AudioPathUtils
import com.zhushenwudi.lovelivemusicplayer.util.PathResolver
class DataImportService(
private val oss: OssApi,
@@ -27,7 +28,7 @@ class DataImportService(
val artistCount: Int,
)
/** Remote data.json version, or null on network/parse failure. */
/** 远程 data.json 版本号,网络/解析失败时返回 null。 */
suspend fun fetchRemoteVersion(): Int? = runCatching {
val raw = oss.fetchDataJson()
json.decodeFromString<CloudData>(raw).version
@@ -49,20 +50,23 @@ class DataImportService(
}.orEmpty()
val artistByName = artistModels.associateBy { it.k }
library.clearMusic()
library.clearAlbums()
library.clearArtists()
var musicCount = 0
var albumCount = 0
// 先在内存构建,解析/路径失败不会清空已有曲库。
data class Pending(
val music: Music,
val album: Album?,
val upgradeAlbumExist: Album?,
)
val pendingMusic = mutableListOf<Pending>()
val pendingArtists = linkedMapOf<String, Artist>()
val artistMusic = mutableMapOf<String, MutableList<String>>()
val seenAlbums = mutableSetOf<String>()
var albumCount = 0
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.
// 移动端曲库:仅允许 export=true 的曲目。
if (!m.export) continue
val album = albumByCloudId[m.album] ?: continue
@@ -75,7 +79,8 @@ class DataImportService(
albumId = album.uid,
albumName = m.albumName ?: album.name,
coverPath = m.coverPath,
musicPath = m.musicPath,
// 导入时将 musicPath 转为 wav(iOS 本地格式)
musicPath = AudioPathUtils.flac2wav(m.musicPath),
time = m.time,
baseUrl = m.baseUrl,
category = album.category,
@@ -85,40 +90,35 @@ class DataImportService(
existFile = false,
),
)
library.upsertMusic(domain)
musicCount++
// Insert / upgrade album only for exportable tracks (Flutter importMusic).
var firstAlbum: Album? = null
var upgradeAlbum: Album? = null
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,
),
firstAlbum = 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,
),
upgradeAlbum = Album(
albumId = album.uid,
albumName = album.name,
date = album.date,
coverPath = album.coverPath.firstOrNull(),
category = album.category,
group = groupName,
existFile = true,
)
}
pendingMusic += Pending(domain, firstAlbum, upgradeAlbum)
// Link artists by display-name split (simplified vs Flutter bitmask).
m.artist.split('/', '/')
.map { it.trim() }
.filter { it.isNotEmpty() }
@@ -126,32 +126,46 @@ class DataImportService(
val model = artistByName[name]
val uid = model?.v ?: "name:$name"
artistMusic.getOrPut(uid) { mutableListOf() }.add(m.uid)
if (library.getArtist(uid) == null) {
if (uid !in pendingArtists) {
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),
),
pendingArtists[uid] = 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()))
library.transaction {
library.clearMusic()
library.clearAlbums()
library.clearArtists()
pendingMusic.forEach { row ->
library.upsertMusic(row.music)
row.album?.let { library.upsertAlbum(it) }
row.upgradeAlbumExist?.let { library.upsertAlbum(it) }
}
pendingArtists.values.forEach { library.upsertArtist(it) }
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)
return ImportResult(
cloud.version,
pendingMusic.size,
albumCount,
library.getAllArtists().size,
)
}
}
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.db
package com.zhushenwudi.lovelivemusicplayer.db
import app.cash.sqldelight.db.SqlDriver
@@ -1,14 +1,13 @@
package top.zhushenwudi.llmp.db
package com.zhushenwudi.lovelivemusicplayer.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
import com.zhushenwudi.lovelivemusicplayer.domain.Album
import com.zhushenwudi.lovelivemusicplayer.domain.Artist
import com.zhushenwudi.lovelivemusicplayer.domain.Love
import com.zhushenwudi.lovelivemusicplayer.domain.Lyric
import com.zhushenwudi.lovelivemusicplayer.domain.Menu
import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.util.PathResolver
private val json = Json { ignoreUnknownKeys = true }
@@ -24,17 +23,19 @@ data class LibraryLists(
class LibraryRepository(private val db: LlmpDatabase) {
private val q get() = db.llmpDatabaseQueries
/** 原子性执行曲库重写(避免导入中途失败导致数据库为空)。 */
fun <R> transaction(body: () -> R): R = db.transactionWithResult { body() }
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.
* 按团组筛选曲库;HTTP 关闭时仅显示本地文件。
* 歌曲/专辑按发行日期 `Music.date` / `Album.date`(再按 id)排序。
*/
fun findAllListByGroup(
group: String,
enableHttp: Boolean,
sortAsc: Boolean = true,
): LibraryLists {
val allGroup = group == "all"
fun Music.matches(): Boolean =
@@ -43,19 +44,16 @@ class LibraryRepository(private val db: LlmpDatabase) {
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).
// 各页正倒序由 UI 独立计算;此处只返回规范顺序。
val music = getAllMusic().filter { it.matches() }.sortedWith(musicComparator)
// 可见歌曲已满足:导出筛选 +(HTTP 或本地 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.
}.sortedWith(albumComparator)
// 艺人:仅保留歌曲页中出现的曲目;无曲目则隐藏。
val artistsRaw = getAllArtists()
.filter { a -> allGroup || a.group == group }
.sortedWith(artistComparator)
@@ -63,15 +61,11 @@ class LibraryRepository(private val db: LlmpDatabase) {
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 ->
// SQL 默认 timestamp DESC;规范顺序为最早收藏在前。
val loved = getLovedMusic().filter { it.matches() }.asReversed()
// 最近播放与已筛选歌曲列表取交集。
// selectAllHistory 已按 timestamp DESC(最新在队首),规范顺序保持最新在前。
val recent = getHistoryMusic().filter { m ->
m.musicId in musicIds && (allGroup || m.group == group)
}
return LibraryLists(
@@ -85,7 +79,7 @@ class LibraryRepository(private val db: LlmpDatabase) {
}
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]
@@ -101,9 +95,9 @@ class LibraryRepository(private val db: LlmpDatabase) {
}
/**
* 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.
* 艺人排序比较器。
* 顺序:非 U 首字符 → uid[1..] 的 1 位计数(base36)→ 二进制转十进制;
* 以 U 开头的 uid 排最后。
*/
private val artistComparator = Comparator<Artist> { a, b ->
compareArtistUid(a.uid, b.uid)
@@ -129,7 +123,7 @@ class LibraryRepository(private val db: LlmpDatabase) {
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)
@@ -137,8 +131,8 @@ class LibraryRepository(private val db: LlmpDatabase) {
}
/**
* Re-check local files and update [Music.existFile] / [Album.existFile].
* Needed after storage-root fixes or SD path changes without full re-import.
* 重新检查本地文件并更新 `Music.existFile` / `Album.existFile`。
* 存储根路径或 SD 路径变更后、无需全量重导入时使用。
*/
fun rescanExistFiles(paths: PathResolver): Int {
var changed = 0
@@ -153,7 +147,9 @@ class LibraryRepository(private val db: LlmpDatabase) {
if (updated.existFile == true) {
albumHasFile[albumId] = true
} else {
albumHasFile.putIfAbsent(albumId, false)
if (albumId !in albumHasFile) {
albumHasFile[albumId] = false
}
}
}
getAllAlbums().forEach { album ->
@@ -330,13 +326,13 @@ class LibraryRepository(private val db: LlmpDatabase) {
Lyric(uid = it.uid, jp = it.jp, zh = it.zh, roma = it.roma)
}
/** Flutter lyricDao.insertLyric — first insert for uid. */
/** 首次为 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)
@@ -350,13 +346,13 @@ class LibraryRepository(private val db: LlmpDatabase) {
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.
* 按 `ids` 顺序获取曲目;
* HTTP 关闭时跳过缺失或非本地文件。
*/
fun getMusicByIdsPreservingOrder(ids: List<String>, enableHttp: Boolean): List<Music> =
ids.mapNotNull { id ->
@@ -380,15 +376,16 @@ class LibraryRepository(private val db: LlmpDatabase) {
fun clearPlayList() = q.deletePlayList()
/** 取 101…200 中未被占用的最小 id;已满时返回 -1。对齐 Flutter [calcSmallAtIntArr]。 */
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
return -1L
}
private fun top.zhushenwudi.llmp.db.Music.toDomain() = Music(
private fun com.zhushenwudi.lovelivemusicplayer.db.Music.toDomain() = Music(
musicId = musicId,
musicName = musicName,
artist = artist,
@@ -408,7 +405,7 @@ class LibraryRepository(private val db: LlmpDatabase) {
existFile = (existFile ?: 0L) != 0L,
)
private fun top.zhushenwudi.llmp.db.Artist.toDomain() = Artist(
private fun com.zhushenwudi.lovelivemusicplayer.db.Artist.toDomain() = Artist(
id = id,
uid = uid,
name = name,
@@ -418,7 +415,7 @@ class LibraryRepository(private val db: LlmpDatabase) {
.getOrDefault(emptyList()),
)
private fun top.zhushenwudi.llmp.db.Menu.toDomain() = Menu(
private fun com.zhushenwudi.lovelivemusicplayer.db.Menu.toDomain() = Menu(
id = id,
isPhone = isPhone != 0L,
music = runCatching { json.decodeFromString<List<String>>(musicJson) }
@@ -429,3 +426,9 @@ class LibraryRepository(private val db: LlmpDatabase) {
}
expect fun currentTimeMs(): Long
/** 本地时区 `HH:mm`,给定时关闭弹窗显示结束时刻。 */
expect fun formatLocalHm(epochMs: Long): String
/** 本地时区 `HH:mm:ss`,给 Talker 日志卡片用。 */
expect fun formatLocalHms(epochMs: Long): String
@@ -1,6 +1,6 @@
package top.zhushenwudi.llmp.db
package com.zhushenwudi.lovelivemusicplayer.db
import top.zhushenwudi.llmp.domain.Music as DomainMusic
import com.zhushenwudi.lovelivemusicplayer.domain.Music as DomainMusic
class MusicRepository(private val db: LlmpDatabase) {
fun getAll(): List<DomainMusic> =
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.domain
package com.zhushenwudi.lovelivemusicplayer.domain
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -1,9 +1,9 @@
package top.zhushenwudi.llmp.domain
package com.zhushenwudi.lovelivemusicplayer.domain
import kotlinx.serialization.Serializable
/**
* Idol group keys aligned with Flutter `GroupKey`.
* 偶像团体分组键。
*/
@Serializable
enum class GroupKey(val displayName: String) {
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.domain
package com.zhushenwudi.lovelivemusicplayer.domain
import kotlinx.serialization.Serializable
@@ -0,0 +1,331 @@
package com.zhushenwudi.lovelivemusicplayer.i18n
internal val DeStrings: Map<String, String> = mapOf(
"music" to "Die Musik",
"album" to "Das Album",
"music_name" to "Der Liedtitel",
"singer" to "Der Sänger",
"iLove" to "Ich mag",
"songMenu" to "Lieder Liste",
"history" to "Letztes Spiel",
"update_fail" to "Update fehlgeschlagen",
"update" to "Versionsaktualisierung",
"update_yes" to "Ja",
"update_no" to "Nein",
"no_need_update" to "Die aktuelle Version ist die neuste",
"music_trans" to "Lieder übertragen",
"data_sync" to "Datensynchronisierung",
"theme_with_system" to "Mit System",
"colorful_mode" to "Bunter Modus",
"splash_photo" to "Splash-Foto",
"enable_background_photo" to "Hintergrundfoto aktivieren",
"choose_background_photo" to "Hintergrundbild auswählen",
"fetch_songs" to "Daten aktualisieren",
"clear_database" to "Daten löschen",
"will_shutdown" to "Die App wird gerade geschlossen, bitte starten Sie sie manuell neu",
"clean_success" to "Erfolgreich gelöscht",
"view_log" to "Protokoll ansehen",
"privacy_agreement" to "Datenschutzvereinbarung",
"system_settings" to "Systemeinstellungen",
"drive_mode" to "Fahren ist",
"night_mode" to "Nacht-Modus",
"downloading" to "Wird heruntergeladen",
"now_is_latest" to "Bereits die neuste Version, überschreiben?",
"data_error" to "Datenausnahme",
"fetch_songs_fail" to "Datenquelle konnte nicht abgerufen werden",
"importing" to "Importieren...",
"transferring" to "Unterwegs...",
"loading" to "Wird geladen...",
"song_lib" to "Songbibliothek",
"mine" to "Mein",
"requesting" to "Netzwerk anfordern...",
"please_check_network" to "Bitte prüfen Sie das Netzwerk",
"keep_same_lan" to "Bitte bewahren Sie Ihr Mobiltelefon und Ihren Computer im dasselbe lokales Netzwerk auf",
"keep_screen_and_scan_qr" to "Lassen Sie den Bildschirm immer eingeschaltet und scannen Sie den QR-Code auf der PC-Seite",
"phone2pc" to "Handy ≫ Computer",
"pc2phone" to "Computer ≫ Handy",
"device_pair" to "Gerätepaarung",
"terminate_trans" to "Nach dem Beenden wird die Verbindung und Übertragung unterbrochen, Möchten Sie weiter machen?",
"confirm" to "Bestätigen",
"connect_fail" to "Verbindung fehlgeschlagen",
"album_info" to "Albumdetails",
"add_success" to "Erfolgreich hinzugefügt",
"add_fail" to "Hinzufügen fehlgeschlagen",
"add_to_playlist" to "Zur Wiedergabeliste hinzufügen",
"add_to_menu" to "Zum Liedmenü hinzufügen",
"create_menu" to "Songmenü erstellen",
"create_success" to "Erfolg schaffen",
"create_over_max" to "Über Höchstmenge",
"add_to_iLove" to "Erfolgreich zu \"Ich mag\" hinzugefügt",
"remove_from_iLove" to "Erfolgreich aus \"Ich mag\" entfernt",
"rename_menu" to "Menü umbenennen",
"delete_menu" to "Menü entfernen",
"music_info" to "Musikinfo",
"view_album" to "Album ansehen",
"remove_music" to "Lied löschen",
"shuffle_play" to "Shuffle spielen",
"order_play" to "Reihenfolge spielen",
"single_play" to "Einzelspiel",
"total_number_unit" to "Lied(er)",
"total_album_number_unit" to "Ablum(Alben)",
"duration" to "Dauer",
"position" to "Position",
"release_date" to "Veröffentlichungsdatum",
"classification" to "Einstufung",
"unknown" to "Unbekannt",
"select_items" to "Ausgewählt",
"cancel" to "Absagen",
"click_again_to_back" to "Klicken Sie erneut, um zum Desktop zurückzukehren",
"data_parsing" to "Datenanalyse...",
"github_url" to "Link zu diesem Software-Open-Source-Projekt",
"no_songs" to "Keine Lieder",
"no_lyrics" to "Keine Texte",
"title" to "Der Titel",
"message" to "Die Message",
"input_menu_name" to "Bitte geben Sie einen Menu-Namen ein",
"privacy_policy" to "Datenschutz-Bestimmungen",
"permission_show_full" to "Vollversion anzeigen",
"copy_success" to "Erfolg kopieren",
"need_enable_bg" to "Bitte aktivieren Sie zuerst Hintergrundbilder",
"permission_main_message" to "Willkommen bei LoveLiveMusicPlayer!\n\nWir werden die \"Nutzungsvereinbarung und Datenschutzrichtlinie“ verwenden, um Ihnen zu helfen, die von dieser Software für Sie bereitgestellten Dienste und die Art und Weise der Erfassung und Verarbeitung Ihrer personenbezogenen Daten zu verstehen.\nDurch Klicken auf die Schaltfläche \"Zustimmen und fortfahren“ haben Sie zugestimmt.",
"permission_external_link" to "\"Nutzungsvereinbarung und Datenschutzrichtlinie\"",
"permission_agree" to "Zustimmen und weitermachen",
"permission_disagree" to "Nicht zustimmen",
"no_data" to "Keine Daten",
"choose_your_clean_data" to "Bitte wählen Sie die zu löschenden Daten aus",
"remove_songs_data" to "Songdaten löschen",
"remove_user_data" to "Benutzerdaten löschen",
"explain_songs_data" to "Songdaten: Song-Cache-Daten",
"explain_user_data" to "Benutzerdaten: Playerkonfiguration, Favoriten, Songliste",
"resetting" to "zurücksetzen...",
"warning_choose" to "Achtung: Wählen Sie sorgfältig aus",
"confirm_full_trans" to "Sind Sie sicher, dass Sie die Daten vollständig überschreiben?",
"cover_full_data" to "Vollständige Datenabdeckung",
"file_path_error" to "Dateipfadfehler",
"crop_background_image" to "Hintergrundbild zuschneiden",
"search_lyric" to "Songtexte noch mal downloaden",
"search_lyric_success" to "Songtexte erfolgreich abrufen",
"search_lyric_failed" to "Liedtext konnte nicht abgerufen werden",
"share_app" to "Er/Sie hat LLMP mit Ihnen geteilt",
"outer_share_music" to "Er/Sie hat ein Lied mit dir geteilt",
"outer_share_menu" to "Er/Sie hat eine Playlist mit dir geteilt",
"share_music" to "Lied teilen",
"share_menu" to "Wiedergabeliste teilen",
"no_qq" to "TIM/QQ ist nicht installiert",
"version_incompatible" to "Die APP-Version stimmt nicht mit der PC-Version überein, bitte gehen Sie zum Aktualisieren zum Blog",
"no_found_music1" to "Lied wurde nicht gefunden",
"need_play_share_music" to "Ob geteilte Songs abgespielt werden sollen",
"no_found_music2" to "Keine verfügbaren Songs gefunden",
"need_import_share_menu" to "Ob freigegebene Wiedergabelisten importiert werden sollen",
"delete_from_menu" to "Aus Wiedergabeliste löschen",
"confirm_delete_from_menu" to "Möchten Sie den ausgewählten Titel wirklich aus der Wiedergabeliste löschen?",
"cancel_i_like" to "Abbrechen gefällt mir",
"confirm_to_delete_music" to "Möchten Sie den ausgewählten Song wirklich aus „Meine Favoriten“ löschen?",
"share" to "App teilen",
"skip" to "überspringen",
"net_error" to "Netzwerkfehler",
"hint" to "Tipp.",
"use_http_music" to "Spielen Sie Musik von Http",
"input_http_url" to "Geben Sie die http-Adresse ein",
"support_http_characters" to "Unterstützen Sie Charaktere: a-z A-Z 0-9 .:/_-",
"now_can_not_play" to "Kann nicht spielen",
"moe_girl_wiki" to "Moe-girl Wiki",
"need_delete_menu" to "Möchten Sie die Songliste löschen?",
"select_time" to "Bitte wählen Sie die Zeit aus (Einheit: Minuten)",
"upgrading" to "Upgrade durchführen...",
"upgrade_on_next_time" to "Das Update wird beim nächsten Start wirksam",
"end_time" to "Endzeit: ",
"timed_to_stop" to "Zeit abgelaufen",
"timed_to_stop_remain" to "Auszeit, verbleibend",
"minutes" to "Minuten",
"please_give_install_permission_manual" to "Bitte erteilen Sie manuell die Berechtigung zur Installation von APK",
"please_give_install_permission" to "Für das Upgrade ist eine Berechtigung zur Installation von APK erforderlich. Ist diese autorisiert?",
"privacy_detail1" to "Lieber LLer:\n\nBitte stellen Sie sicher, dass Sie alle Rechte und Einschränkungen, die in der „Softwarelizenzvereinbarung“ (im Folgenden als diese Vereinbarung bezeichnet) festgelegt sind, sorgfältig lesen und verstehen, es sei denn, Sie akzeptieren die Bedingungen dieser Vereinbarung, andernfalls haben Sie kein Recht, die Software und damit verbundene zu verwenden Dienstleistungen. Sobald Sie diese Software verwenden, wird davon ausgegangen, dass Sie diese Vereinbarung akzeptiert haben.\n\n1.Erklärung der Rechte\nDiese Software ist ein Open-Source-Projekt, alle Rechte sind dem Autor \"zhushenwudi\" vorbehalten.\nEin Teil des Informationsinhalts stammt aus Netzwerkressourcen, einschließlich, aber nicht beschränkt auf: Textausdrücke und ihre Kombinationen, Symbole, Bilder, Farben und zugehörige Daten. Bei Konflikten und Streitigkeiten, die durch Urheberrechtsprobleme verursacht werden, wenden Sie sich bitte an den Autor unter \"55681140@163.com\", um ihn zu löschen oder zu ersetzen.\nWenn Sie sich entscheiden, die Software zu verteilen und die zugehörige Dokumentation zu vervielfältigen, wenden Sie sich bitte an den Autor, um eine Genehmigung einzuholen. Wenn Sie bei der Verwendung auf Probleme stoßen, können Sie sich an den Autor wenden, um Hilfe zu erhalten. Der Autor behält sich das Recht vor, Benutzern modifizierte und aktualisierte Versionen dieser Software zur Verfügung zu stellen.\n\n2.Umfang der Lizenz\nHerunterladen, Installieren und Verwenden: Benutzer können diese Software uneingeschränkt für nicht-kommerzielle Zwecke herunterladen, installieren und verwenden.\nKopieren, Verteilen und Verbreiten: Benutzer dürfen dieses Softwareprodukt unbegrenzt zu nichtkommerziellen Zwecken kopieren, verteilen und verteilen, müssen jedoch sicherstellen, dass alle kopierten, verteilten und verteilten Inhalte vollständig und authentisch sind, einschließlich jeglicher Software, die sich auf diese Software bezieht. Elektronische Dateien, Urheberrechte und Warenzeichen, einschließlich Vereinbarungen.\n\n3.Privatsphäre und Sicherheit\nDiese Software enthält keine bösartigen Codes, die dazu bestimmt sind, Benutzerterminaldaten zu zerstören und Benutzerdaten zu erhalten, mit Ausnahme von Absturzinformationen und Hochladedaten, die in der Software verwendet werden.\nEs enthält keine Codes zum Verfolgen und Überwachen des Verhaltens von Benutzerterminals, überwacht kein Online- und Offline-Verhalten von Benutzern, sammelt keine persönlichen Informationen wie Benutzer, die andere Software und Dokumente verwenden, und gibt keine Benutzerdaten weiter.\nDie gesammelten Daten werden verwendet, um die Software besser zu verbessern, indem Fehlermeldungen bei Abstürzen analysiert werden.\nVerwenden Sie die folgenden statistischen Entwicklungstools:\n Ⅰ.SDK-Namen verwenden: U-App SDK\n Sammlung personenbezogener informationen: ausrüstung informationen (IMEI/Mac/android-plattform ausweis/IDFA/OPENUDID/GUID/sim-karte IMSI/geografischer)\n Art der Dienstleistung: Datenerhebung und statistische Analyse",
"privacy_umeng" to "《Link zur Datenschutzrichtlinie》",
"privacy_detail2" to "\n\nⅡ.SDK-Namen verwenden: MobTech ShareSDK\n Art der Dienstleistung: Sharing realisieren und pushen",
"privacy_share" to "《Link zur Datenschutzerklärung》",
"privacy_detail3" to "\n\nⅢ.SDK-Namen verwenden: 360 gehärtetes SDK\n Geräteberechtigungen: Keine\n Verwirklichte funktionale Zwecke: Wird verwendet, um App-Fehler zu erkennen und zu diagnostizieren, App-Sicherheitsverstärkungsschutz, Piraterieerkennungsdienste, Erkennungsdienste für Bedrohungsumgebungen, um Benutzern zu helfen, anormale Situationen schnell zu lösen\n Berechtigungsautorisierungsmethode: Die Autorisierungsmethode wird vom App-Entwickler bestimmt und aktiviert, wenn der Endbenutzer zustimmt, die Berechtigung für die Entwickler-App zu autorisieren",
"privacy_360" to "《Link zur Datenschutzerklärung》",
"privacy_detail4" to "\n\n\n4.Hinweis für Benutzer\nDiese Software wird spontan von LoveLive!-Fans verwendet und enthält keine urheberrechtlich geschützten Songs. Copyright-Songs werden vom Benutzer extrahiert und von der CD importiert. Die Importverzeichnisstruktur ist in der Github-Projektdokumentation dargestellt.\nBenutzer sollten die Software unter der Prämisse verwenden, Gesetze und Vereinbarungen einzuhalten, und Benutzer haben kein Recht, Maßnahmen zu ergreifen, einschließlich, aber nicht beschränkt auf die folgenden:\nⅠ. Löschen oder ändern Sie nicht alle elektronischen Informationen zur Rechteverwaltung in der Software;\nⅡ. Verwenden Sie diese Software nicht, um andere irrezuführen oder zu täuschen;\nⅢ. Löschen, Ändern, Erweitern oder Reduzieren der Funktionen des Computerinformationssystems unter Verstoß gegen behördliche Vorschriften, wodurch eine Fehlfunktion des Computerinformationssystems verursacht wird;;\nⅣ. Unbefugter Zugriff auf oder Nutzung von Computerinformationsnetzressourcen;\nⅤ. Löschen, Ändern oder Hinzufügen von Computerinformationen zu Netzwerkfunktionen ohne Erlaubnis。\n ",
"now_playing" to "Läuft gerade: ",
"group_list" to "Gruppenliste",
"back" to "Zurück",
"phone" to "Telefon",
"pc" to "Computer",
"add_favorite_menu" to "Lieblings-Playlist",
"music_blind_box" to "Musik-Blindbox",
"throbbing" to "Ich weiß, Sie werden aufgeregt sein, aber Sie sollten auch auf die Verkehrssicherheit achten",
"exit" to "aufhören",
"finish" to "Beenden",
"search_in_moe_girl" to "Suche auf Moegirlpedia",
"share_error" to "Teilen fehlgeschlagen, Fehlercode: ",
"share_success" to "Teilen erfolge",
"share_cancel" to "Teilen Abbrechen",
"daily_news" to "Daily news",
"recent_trends" to "Die jüngsten trends",
"live_notice" to "Live-Mitteilung",
"today_in_previous_years" to "Heute in den vergangenen Jahren",
"input_song_main_char" to "Bitte geben Sie das Schlüsselwort für den Songnamen ein",
"playing" to "Spielen",
"paused" to "Pausierte",
"storage_settings" to "Speichereinstellungen",
"change_storage_warning" to "Wählen Sie das zu verwendende Speichergerät aus.\nBeim Wechseln des Geräts werden alle Daten gelöscht.",
"internal_storage" to "Interner Speicher",
"storage_not_found" to "Das Speichergerät existiert nicht",
"need_play_music_first" to "Bitte spielen Sie zuerst das Lied",
"desktop_lyric" to "Desktop-Songtext",
"choose" to "Wählen",
"choose_album_category" to "Albumkategorie auswählen",
"people_unit" to "Person(en)",
"menu_unit" to "Stück",
"confirm_unlove" to "Aus Favoriten entfernen?",
"got_it" to "OK",
"close" to "Schließen",
"check_update" to "Updates prüfen…",
"data_update_done" to "Datenupdate abgeschlossen",
"no_download_url" to "Keine Download-URL",
"scan_pair" to "Scannen zum Koppeln",
"tap_to_scan" to "Tippen zum Scannen",
"or_input_pc_ip" to "Oder PC-IP eingeben",
"confirm_pair" to "Kopplung bestätigen",
"disconnect" to "Trennen",
"data_parsing_short" to "Daten werden analysiert",
"history_short" to "Verlauf",
"today" to "Heute",
"cannot_load" to "Laden fehlgeschlagen: ",
"daily_timezone_hint" to "Hinweis: Sofern nicht anders angegeben, gilt Peking-Zeit (UTC+8).",
"select_storage" to "Musikspeicherort wählen",
"no_usb" to "Kein Wechseldatenträger (USB) gefunden.",
"http_url_example" to "z. B. https://host/path",
"timer_cancelled" to "Timer abgebrochen",
"will_stop_in" to "Wiedergabe stoppt in %s Minute(n)",
"exiting" to "Beenden…",
"user_data_cleared" to "Benutzerdaten gelöscht",
"new_version" to "Neue Version v%s",
"upgrade_prompt" to "Neue Version verfügbar. Jetzt aktualisieren?",
"add_to_playlist_short" to "Zur Wiedergabeliste",
"new_menu" to "Neue Playlist",
"menu_name" to "Playlist-Name",
"unknown_song" to "Unbekanntes Lied",
"input_song_keyword" to "Song-Schlüsselwort",
"not_playing" to "Keine Wiedergabe",
"collapse" to "Einklappen",
"more" to "Mehr",
"jump_play" to "Zum Abspielen",
"lyric_orig" to "Orig",
"lyric_trans" to "Übers",
"lyric_roma" to "Roma",
"play_all" to "Alle abspielen",
"no_album_info" to "Keine Albuminfo",
"song_label" to "Lied: ",
"album_label" to "Album: ",
"opening" to "Öffnen: ",
"tachie" to "Tachie",
"opening_tachie" to "Tachie wird geöffnet…",
"dark_mode" to "Dunkler Modus",
"web_page" to "Webseite",
"scan_qr_prompt" to "QR-Code scannen",
"opening_app" to "App wird geöffnet...",
"no_permission" to "Berechtigung nicht erteilt",
"ready_to_play" to "Bereit zum Abspielen",
"forward" to "Vor",
"refresh" to "Aktualisieren",
"backward" to "Zurück",
"play" to "Play",
"pause" to "Pause",
"playlist" to "Wiedergabeliste",
"cannot_open_page" to "Seite kann nicht geöffnet werden",
"invalid_link" to "Ungültiger Link",
"share_chooser" to "Teilen",
"no_activity" to "no activity",
"logs_error" to "Fehler",
"logs_warning" to "Warnung",
"logs_info" to "Info",
"logs_debug" to "Debug",
"logs_verbose" to "Ausführlich",
"logs_monitor" to "Monitor",
"logs_settings" to "Einstellungen",
"logs_actions" to "Aktionen",
"logs_copied_one" to "Protokoll kopiert",
"logs_copied_all" to "Alle Protokolle kopiert",
"logs_copied_filter" to "Gefilterte Protokolle kopiert",
"logs_search" to "Suchen…",
"logs_copy" to "Kopieren",
"logs_actions_title" to "Protokollaktionen",
"logs_reverse" to "Protokolle umkehren",
"logs_copy_all" to "Alle kopieren",
"logs_copy_filtered" to "Gefilterte kopieren",
"logs_expand" to "Protokolle ausklappen",
"logs_collapse" to "Protokolle einklappen",
"logs_clear" to "Verlauf löschen",
"logs_share_file" to "Protokolldatei teilen",
"logs_settings_title" to "Protokolleinstellungen",
"logs_on" to "An",
"logs_off" to "Aus",
"logs_monitor_title" to "Protokollmonitor",
"logs_unhandled_errors" to "App hat %s unbehandelte Fehler",
"logs_warning_count" to "App hat %s Warnungen",
"logs_info_count" to "Info-Protokolle: %s",
"logs_debug_count" to "Ausführliche/Debug-Protokolle: %s",
"logs_verbose_debug" to "Ausführlich & Debug",
"selected_count" to "%s ausgewählt",
"count_songs" to "%s Lied(er)",
"count_albums" to "%s Album(Alben)",
"count_artists" to "%s Person(en)",
"count_menus" to "%s Stück",
"menu_meta_phone" to "%s Lied(er) · Telefon",
"menu_meta_pc" to "%s Lied(er) · PC",
"loop_songs" to "%s - %s Lied(er)",
"skip_n" to "Überspringen %ss",
"end_time_value" to "Endzeit: %s",
"timed_remain_n" to "Auszeit, verbleibend %s Minuten",
"keep_screen_scan_qr" to "Bildschirm anlassen und QR-Code scannen",
"now_playing_empty" to "Läuft gerade: Keine Lieder",
"now_playing_song" to "Läuft gerade: %s",
"fetch_songs_fail_or_net" to "Datenfehler oder Netzwerkfehler",
"explain_songs_data_kmp" to "Songdaten: Bibliotheksmetadaten, Version",
"explain_user_data_kmp" to "Benutzerdaten: Likes/Verlauf/Playlists/Cache, dann beenden",
"remove_songs_data_kmp" to "Songdaten löschen",
"remove_user_data_kmp" to "Benutzerdaten löschen",
"confirm_delete_from_menu_short" to "Aus Playlist löschen?",
"play_text" to "Spielen,Pausierte",
"connected" to "Getrennt",
"connecting" to "Verbinden…",
"handshaking" to "Handshake…",
"disconnected" to "Getrennt",
"version_mismatch" to "Versionskonflikt",
"verified" to "Verifiziert",
"transfer_done" to "Übertragung abgeschlossen",
"peer_abort" to "Gegenstelle abgebrochen",
"cannot_connect_host" to "Verbindung zu %s fehlgeschlagen",
"failed" to "Fehlgeschlagen",
"handshake_incomplete" to "Handshake unvollständig",
"sent_phone2pc" to "phone2pc gesendet",
"requested_pc2phone" to "pc2phone angefordert",
"applied_pc_love" to "PC-Favoriten übernommen",
"applied_pc_menus" to "PC-Playlists/Likes übernommen",
"session_end" to "Sitzung beendet",
"pending_n" to "%s Lied(er) ausstehend",
"ready_n" to "Bereit %s",
"downloading_song" to "Lädt %s",
"http_port" to "HTTP-Port %s",
"widget_white" to "LLMP Musik-Widget (Weiß)",
"widget_black" to "LLMP Musik-Widget (Schwarz)",
"widget_desc" to "LoveLive! Musikplayer",
"duration_label" to "Dauer: ",
"position_label" to "Position: ",
"release_date_label" to "Erscheinungsdatum: ",
"classification_label" to "Kategorie: ",
"data_update_done_ver" to "Datenupdate fertig v%s · %s Lied(er)",
"downloading_short" to "Lädt…",
"share_error_n" to "Teilen fehlgeschlagen, Fehler: %s",
"album_abbr" to "A",
"music_abbr" to "L",
"menu_abbr" to "P",
"lyric_close" to "Desktop-Lyrics schließen",
"lyric_pip" to "Bild-in-Bild",
"lyric_translate" to "Übersetzte Lyrics",
"playback_channel" to "Wird abgespielt",
"camera_permission" to "Kamerazugriff ist zum Scannen von QR-Codes erforderlich",
"photo_permission" to "Fotobibliothek-Zugriff ist zum Auswählen von Bildern erforderlich",
)
@@ -0,0 +1,331 @@
package com.zhushenwudi.lovelivemusicplayer.i18n
internal val EnStrings: Map<String, String> = mapOf(
"music" to "Music",
"album" to "Album",
"music_name" to "Music name",
"singer" to "Singer",
"iLove" to "Love",
"songMenu" to "Menu",
"history" to "History",
"update_fail" to "Update failed",
"update" to "Update",
"update_yes" to "Yes",
"update_no" to "No",
"no_need_update" to "The current version is the latest",
"music_trans" to "Transfer songs",
"data_sync" to "Data sync",
"theme_with_system" to "Theme with os",
"colorful_mode" to "Colorful mode",
"splash_photo" to "Splash photo",
"enable_background_photo" to "Enable background photo",
"choose_background_photo" to "Choose background photo",
"fetch_songs" to "Fetch songs",
"clear_database" to "Clear databases",
"will_shutdown" to "App will shutdown, please turn it on by yourself",
"clean_success" to "Clean up successfully",
"view_log" to "View logs",
"privacy_agreement" to "Privacy agreement",
"system_settings" to "System settings",
"drive_mode" to "Drive mode",
"night_mode" to "Night mode",
"downloading" to "Downloading data",
"now_is_latest" to "It is the latest version. Do you want to overwrite it?",
"data_error" to "Data error",
"fetch_songs_fail" to "Fetch songs error",
"importing" to "Importing...",
"transferring" to "Transferring...",
"loading" to "Loading...",
"song_lib" to "SongLib",
"mine" to "Mine",
"requesting" to "Requesting...",
"please_check_network" to "Please check network",
"keep_same_lan" to "Keep your phone and computer on the same LAN",
"keep_screen_and_scan_qr" to "Keep the screen steady on and scan the QR code on the PC",
"phone2pc" to "Phone >> PC",
"pc2phone" to "PC >> Phone",
"device_pair" to "Device pairing",
"terminate_trans" to "The connection and transmission will be interrupted. Do you want to continue?",
"confirm" to "Confirm",
"connect_fail" to "Connection failed",
"album_info" to "Album info",
"add_success" to "Add successfully",
"add_fail" to "Add failed",
"add_to_playlist" to "Add to a playlist",
"add_to_menu" to "Add to a song menu",
"create_menu" to "Create a song menu",
"create_success" to "Create successfully",
"create_over_max" to "Exceeding the maximum quantity",
"add_to_iLove" to "Add to i love successfully",
"remove_from_iLove" to "Remove from i love successfully",
"rename_menu" to "Rename menu",
"delete_menu" to "Delete menu",
"music_info" to "Music info",
"view_album" to "View album",
"remove_music" to "remove this song",
"shuffle_play" to "Shuffle play",
"order_play" to "Order play",
"single_play" to "Single play",
"total_number_unit" to "song(s)",
"total_album_number_unit" to "album(s)",
"duration" to "Duration",
"position" to "Position",
"release_date" to "Release date",
"classification" to "Classification",
"unknown" to "unknown",
"select_items" to "Selected",
"cancel" to "Cancel",
"click_again_to_back" to "Click again to return to the desktop",
"data_parsing" to "Data parsinging...",
"github_url" to "Open source project link of this software",
"no_songs" to "No songs",
"no_lyrics" to "No lyrics",
"title" to "Title",
"message" to "Message",
"input_menu_name" to "Please input menu name",
"privacy_policy" to "Privacy policy",
"permission_show_full" to "View full version",
"copy_success" to "Copy success",
"need_enable_bg" to "Please enable background images first",
"search_lyric" to "Search lyric again",
"search_lyric_success" to "search lyric success",
"search_lyric_failed" to "search lyric failed",
"share_app" to "(S)He shared a LLMP to you",
"outer_share_music" to "(S)He shared a song to you",
"outer_share_menu" to "(S)He shared a playlist to you",
"share_music" to "Share a song",
"share_menu" to "Share a playlist",
"no_qq" to "TIM/QQ is not installed",
"version_incompatible" to "The APP version does not match the PC version, please go to the blog to update",
"no_found_music1" to "Song not found",
"need_play_share_music" to "Whether to play shared songs",
"no_found_music2" to "No available songs found",
"need_import_share_menu" to "Whether to import shared playlist",
"permission_main_message" to "Welcome to LoveLiveMusicPlayer!\n\nWe will use the User Agreement and Privacy Policy to help you understand the services provided by the software for you and the way you collect and process your personal data.\nClick the \"Agree and Continue\" button to indicate that you have agreed.",
"permission_external_link" to "User Agreement and Privacy Policy",
"permission_agree" to "Agree and continue",
"permission_disagree" to "Disagree and quit",
"no_data" to "No data",
"choose_your_clean_data" to "Please select the data to be cleaned",
"remove_songs_data" to "Remove songs data",
"remove_user_data" to "Remove user data",
"explain_songs_data" to "Song data: cached songs data",
"explain_user_data" to "User data: player configuration, love, playlist",
"resetting" to "Resetting",
"warning_choose" to "Warning: Please choose carefully!",
"confirm_full_trans" to "Are you sure to completely cover the data?",
"cover_full_data" to "Completely cover the data",
"file_path_error" to "File path error",
"crop_background_image" to "Crop background image",
"delete_from_menu" to "Delete from playlist",
"confirm_delete_from_menu" to "Are you sure you want to delete the selected song from playlist?",
"cancel_i_like" to "Cancel I like",
"confirm_to_delete_music" to "Are you sure you want to delete the selected song from My Likes?",
"share" to "Share application",
"skip" to "Skip",
"net_error" to "Network error",
"hint" to "Hint",
"use_http_music" to "Play music by Http",
"input_http_url" to "Enter Http address",
"support_http_characters" to "Support characters: a-z A-Z 0-9 .:/_-",
"now_can_not_play" to "Currently unplayable",
"moe_girl_wiki" to "Moe-girl Wiki",
"need_delete_menu" to "Delete the menu?",
"select_time" to "Please select time(unit:minutes)",
"upgrading" to "Upgrading...",
"upgrade_on_next_time" to "The update will take effect on the next startup",
"end_time" to "End time: ",
"timed_to_stop" to "Timed off",
"timed_to_stop_remain" to "Timed off, remaining",
"minutes" to "minute(s)",
"please_give_install_permission_manual" to "Please manually grant permission to install APK",
"please_give_install_permission" to "Upgrading requires permission to install APK, is it authorized?",
"privacy_detail1" to "Dear LLer,\n\nPlease be sure to carefully read and understand all rights and restrictions set forth in the Software License Agreement (hereinafter referred to as the Agreement). Unless you accept the terms of the Agreement, you are not entitled to use the Software and related Services. Once you use the software, you will be deemed to accept the agreement.\n\n1. Statement of Rights\nThis software is an open source project, owned by author zhushenwudi.\nSome of the information content is obtained from the network resources, including but not limited to: text expression and combination, ICONS, images, colors, and related data. If there is any contradiction or dispute due to copyright issues, please contact the author's mailbox 55681140@163.com to delete or replace it.\nIf the software publicity, related documents reprint please contact the author himself for authorization. Please contact the author for support if you have any problem. The author reserves the right to provide users with modified or upgraded versions of the software.\n\n2. Scope of license\nDownload, Install and Use: Users may download, install and use the Software in non-commercial and unlimited quantities.\nReproduction, Distribution and Dissemination: User may non-commercially reproduce, distribute and disseminate the Software Products in unlimited quantities, provided that each copy, distribution and dissemination is complete and true, including all software, electronic documents, Copyrights and trademarks relating to the Software, as well as agreements.\n\n3. Privacy security\nThe software does not contain any malicious code aimed at destroying user terminal data and obtaining user privacy information except reporting crash information and uploading the use data in the software.\nIt does not contain any function code for tracking and monitoring user terminal behaviors, monitoring users' online and offline behaviors, collecting users' personal information such as other software and documents, and disclosing users' privacy.\nThe collected data can be used to better improve the software by analyzing the error information in the crash.\nHere are the statistical development tools used:\n Ⅰ. Use the SDK name: U-App SDK\n Personal information collection types: equipment information (IMEI/Mac/android ID/IDFA OPENUDID/GUID/SIM card IMSI/location)\n Service type: Data collection and statistical analysis",
"privacy_umeng" to "《Umeng privacy policy links》",
"privacy_detail2" to "\n\nⅡ. Use SDK name: ShareSDK of MobTech\n Service type: Share and push",
"privacy_share" to "《MobTech privacy policy links》",
"privacy_detail3" to "\n\nⅢ. Use SDK name: 360 Hardening SDK (Applicable to APK/HAP/APK plug-in /SDK hardening)\n Device permission: None\n Function purpose: Used for App fault detection and diagnosis,App security reinforcement protection, piracy detection services, threat environment detection services, so as to help users quickly solve abnormal situations\n Authorization mode: The authorization mode is determined by the App developer and is enabled when the end user agrees to authorize the permission to the developer App",
"privacy_360" to "《360 privacy policy links》",
"privacy_detail4" to "\n\n\n4. User instructions\nThis software is LoveLive! Users in the circle spontaneously use, not including any copyrighted songs. Copyright songs are extracted and imported from CDS by users themselves. See the Github project documentation for details about the import directory structure.\nUsers shall use the Software in compliance with laws and agreements. Users are not entitled to perform any of the following actions, including but not limited to:\nⅠ. All rights management electronic information on the software shall not be deleted or changed;\nⅡ. shall not use this software to mislead and deceive others;\nⅢ. In violation of national regulations, delete, modify, increase or interfere with the functions of the computer information system, resulting in the failure of the computer information system to operate normally;\nⅣ. accessing computer information networks or using computer information network resources without permission;\nⅤ. Deleting, modifying or adding functions of computer information networks without permission.\n ",
"now_playing" to "Now Playing: ",
"group_list" to "Group list",
"back" to "Back",
"phone" to "Phone",
"pc" to "Computer",
"add_favorite_menu" to "Favorite playlist",
"music_blind_box" to "Music blind box",
"throbbing" to "I know you will be throbbing, but pay attention to traffic safety",
"exit" to "Exit",
"finish" to "Finish",
"search_in_moe_girl" to "Search in MoeGirl",
"share_error" to "Share failed, error code: ",
"share_success" to "Share success",
"share_cancel" to "Cancel share",
"daily_news" to "Daily news",
"recent_trends" to "Recent trends",
"live_notice" to "Live notice",
"today_in_previous_years" to "Today in previous years",
"input_song_main_char" to "The song name keyword",
"playing" to "Playing",
"paused" to "Paused",
"storage_settings" to "Storage Settings",
"change_storage_warning" to "Select the storage device you want to use.\nSwitching devices will clear all data",
"internal_storage" to "Internal storage",
"storage_not_found" to "Storage not found",
"need_play_music_first" to "Need play music first",
"desktop_lyric" to "Desktop lyric",
"choose" to "Choose",
"choose_album_category" to "Choose album category",
"people_unit" to "artist(s)",
"menu_unit" to "menu(s)",
"confirm_unlove" to "Remove from My Likes?",
"got_it" to "OK",
"close" to "Close",
"check_update" to "Checking updates…",
"data_update_done" to "Data update complete",
"no_download_url" to "No download URL",
"scan_pair" to "Scan to pair",
"tap_to_scan" to "Tap to scan QR code",
"or_input_pc_ip" to "Or enter PC IP",
"confirm_pair" to "Confirm pairing",
"disconnect" to "Disconnect",
"data_parsing_short" to "Parsing data",
"history_short" to "History",
"today" to "Today",
"cannot_load" to "Failed to load: ",
"daily_timezone_hint" to "Note: Unless otherwise specified, times on this page are Beijing time (UTC+8).",
"select_storage" to "Select music storage location",
"no_usb" to "No removable storage (USB) found.",
"http_url_example" to "e.g. https://host/path",
"timer_cancelled" to "Sleep timer cancelled",
"will_stop_in" to "Playback will stop in %s minute(s)",
"exiting" to "Exiting…",
"user_data_cleared" to "User data cleared",
"new_version" to "New version v%s",
"upgrade_prompt" to "A new version is available. Upgrade now?",
"add_to_playlist_short" to "Add to playlist",
"new_menu" to "New playlist",
"menu_name" to "Playlist name",
"unknown_song" to "Unknown song",
"input_song_keyword" to "Enter song keyword",
"not_playing" to "Not playing",
"collapse" to "Collapse",
"more" to "More",
"jump_play" to "Jump to play",
"lyric_orig" to "Orig",
"lyric_trans" to "Trans",
"lyric_roma" to "Roma",
"play_all" to "Play all",
"no_album_info" to "No album info",
"song_label" to "Song: ",
"album_label" to "Album: ",
"opening" to "Opening: ",
"tachie" to "Tachie",
"opening_tachie" to "Opening tachie…",
"dark_mode" to "Dark mode",
"web_page" to "Web page",
"scan_qr_prompt" to "Scan QR code",
"opening_app" to "Opening app...",
"no_permission" to "Permission not granted",
"ready_to_play" to "Ready to play",
"forward" to "Forward",
"refresh" to "Refresh",
"backward" to "Back",
"play" to "Play",
"pause" to "Pause",
"playlist" to "Playlist",
"cannot_open_page" to "Cannot open page",
"invalid_link" to "Invalid link",
"share_chooser" to "Share",
"no_activity" to "no activity",
"logs_error" to "Error",
"logs_warning" to "Warning",
"logs_info" to "Info",
"logs_debug" to "Debug",
"logs_verbose" to "Verbose",
"logs_monitor" to "Monitor",
"logs_settings" to "Settings",
"logs_actions" to "Actions",
"logs_copied_one" to "Log copied",
"logs_copied_all" to "All logs copied",
"logs_copied_filter" to "Filtered logs copied",
"logs_search" to "Search…",
"logs_copy" to "Copy",
"logs_actions_title" to "Log actions",
"logs_reverse" to "Reverse logs",
"logs_copy_all" to "Copy all logs",
"logs_copy_filtered" to "Copy filtered logs",
"logs_expand" to "Expand logs",
"logs_collapse" to "Collapse logs",
"logs_clear" to "Clear history",
"logs_share_file" to "Share log file",
"logs_settings_title" to "Log settings",
"logs_on" to "On",
"logs_off" to "Off",
"logs_monitor_title" to "Log monitor",
"logs_unhandled_errors" to "App has %s unhandled error(s)",
"logs_warning_count" to "App has %s warning(s)",
"logs_info_count" to "Info logs: %s",
"logs_debug_count" to "Verbose/debug logs: %s",
"logs_verbose_debug" to "Verbose & debug",
"selected_count" to "Selected %s",
"count_songs" to "%s song(s)",
"count_albums" to "%s album(s)",
"count_artists" to "%s artist(s)",
"count_menus" to "%s menu(s)",
"menu_meta_phone" to "%s song(s) · Phone",
"menu_meta_pc" to "%s song(s) · PC",
"loop_songs" to "%s - %s song(s)",
"skip_n" to "Skip %ss",
"end_time_value" to "End time: %s",
"timed_remain_n" to "Timed off, remaining %s minute(s)",
"keep_screen_scan_qr" to "Keep the screen on and scan the QR code",
"now_playing_empty" to "Now Playing: No songs",
"now_playing_song" to "Now Playing: %s",
"fetch_songs_fail_or_net" to "Data error or network failure",
"explain_songs_data_kmp" to "Song data: library metadata, version",
"explain_user_data_kmp" to "User data: likes/history/playlists/cache, then quit",
"remove_songs_data_kmp" to "Clear song data",
"remove_user_data_kmp" to "Clear user data",
"confirm_delete_from_menu_short" to "Delete from playlist?",
"play_text" to "Playing,Paused",
"connected" to "Disconnected",
"connecting" to "Connecting…",
"handshaking" to "Handshaking…",
"disconnected" to "Disconnected",
"version_mismatch" to "Version mismatch",
"verified" to "Verified",
"transfer_done" to "Transfer complete",
"peer_abort" to "Peer aborted",
"cannot_connect_host" to "Cannot connect %s",
"failed" to "Failed",
"handshake_incomplete" to "Handshake incomplete",
"sent_phone2pc" to "Sent phone2pc",
"requested_pc2phone" to "Requested pc2phone",
"applied_pc_love" to "Applied PC love list",
"applied_pc_menus" to "Applied PC playlists/likes",
"session_end" to "Session ended",
"pending_n" to "%s song(s) pending",
"ready_n" to "Ready %s",
"downloading_song" to "Downloading %s",
"http_port" to "HTTP port %s",
"widget_white" to "LLMP Music Widget (White)",
"widget_black" to "LLMP Music Widget (Black)",
"widget_desc" to "LoveLive! music player",
"duration_label" to "Duration: ",
"position_label" to "Position: ",
"release_date_label" to "Release date: ",
"classification_label" to "Category: ",
"data_update_done_ver" to "Data update complete v%s · %s song(s)",
"downloading_short" to "Downloading…",
"share_error_n" to "Share failed, error: %s",
"album_abbr" to "A",
"music_abbr" to "S",
"menu_abbr" to "P",
"lyric_close" to "Close desktop lyrics",
"lyric_pip" to "Picture in Picture",
"lyric_translate" to "Translated lyrics",
"playback_channel" to "Now Playing",
"camera_permission" to "Camera access is required to scan QR codes",
"photo_permission" to "Photo library access is required to choose images",
)
@@ -0,0 +1,62 @@
package com.zhushenwudi.lovelivemusicplayer.i18n
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
enum class AppLang { ZH, EN, JA, DE }
/**
* 对齐 Flutter GetX `Translation`:zh_CN / en_US / ja_JP / de_DE,系统语言,缺省中文。
*/
object I18n {
private val _lang = MutableStateFlow(detectSystemLang())
val lang: StateFlow<AppLang> = _lang.asStateFlow()
fun setLanguage(lang: AppLang) {
if (_lang.value != lang) _lang.value = lang
}
fun syncFromSystem() {
setLanguage(detectSystemLang())
}
fun setLanguageTag(tag: String) {
setLanguage(langFromTag(tag))
}
/** 无占位:Swift 侧 `I18n.shared.t(key:)`,vararg 在 ObjC/Swift 必须传 args。 */
fun t(key: String): String {
val table = tableFor(_lang.value)
return table[key] ?: ZhStrings[key] ?: key
}
fun t(key: String, vararg args: Any?): String {
var value = t(key)
for (arg in args) {
value = value.replaceFirst("%s", arg?.toString().orEmpty())
}
return value
}
private fun tableFor(lang: AppLang) = when (lang) {
AppLang.ZH -> ZhStrings
AppLang.EN -> EnStrings
AppLang.JA -> JaStrings
AppLang.DE -> DeStrings
}
}
fun String.tr(vararg args: Any?): String = I18n.t(this, *args)
fun langFromTag(tag: String): AppLang {
val lower = tag.lowercase()
return when {
lower.startsWith("ja") -> AppLang.JA
lower.startsWith("de") -> AppLang.DE
lower.startsWith("en") -> AppLang.EN
else -> AppLang.ZH
}
}
expect fun detectSystemLang(): AppLang
@@ -0,0 +1,331 @@
package com.zhushenwudi.lovelivemusicplayer.i18n
internal val JaStrings: Map<String, String> = mapOf(
"music" to "曲",
"album" to "アルバム",
"music_name" to "曲名",
"singer" to "歌手",
"iLove" to "私の好き",
"songMenu" to "プレイリスト",
"history" to "最近の再生",
"update_fail" to "更新に失敗しました",
"update" to "バージョンアップ",
"update_yes" to "アップグレード",
"update_no" to "アップグレードしない",
"no_need_update" to "現在最新バージョンです",
"music_trans" to "曲の転送",
"data_sync" to "データ同期",
"theme_with_system" to "システムに従う",
"colorful_mode" to "カラフルモード",
"splash_photo" to "スプラッシュ画像",
"enable_background_photo" to "背景画像を有効にする",
"choose_background_photo" to "背景画像を選択",
"fetch_songs" to "データ更新",
"clear_database" to "データをクリア",
"will_shutdown" to "アプリがもうすぐ終了します。手動で再起動してください",
"clean_success" to "クリア成功",
"view_log" to "ログを見る",
"privacy_agreement" to "プライバシー契約",
"system_settings" to "システム設定",
"drive_mode" to "ドライブモード",
"night_mode" to "ナイトモード",
"downloading" to "データをダウンロード中",
"now_is_latest" to "最新バージョンです。上書きしますか?",
"data_error" to "データ異常",
"fetch_songs_fail" to "データソースの取得に失敗しました",
"importing" to "インポート中...",
"transferring" to "転送中...",
"loading" to "読み込み中...",
"song_lib" to "曲ライブラリ",
"mine" to "マイ",
"requesting" to "ネットワークをリクエスト中...",
"please_check_network" to "ネットワークを確認してください",
"keep_same_lan" to "スマートフォンとPCが同じローカルネットワークにいることを確認してください",
"keep_screen_and_scan_qr" to "画面を常にオンにして、PCのQRコードをスキャンしてください",
"phone2pc" to "スマホ ≫ PC",
"pc2phone" to "PC ≫ スマホ",
"device_pair" to "デバイスペアリング",
"terminate_trans" to "終了すると接続と転送が中断されます。続行しますか?",
"confirm" to "確認",
"connect_fail" to "接続に失敗しました",
"album_info" to "アルバム情報",
"add_success" to "追加成功",
"add_fail" to "追加失敗",
"add_to_playlist" to "プレイリストに追加",
"add_to_menu" to "プレイリストに追加",
"create_menu" to "プレイリストを作成",
"create_success" to "作成成功",
"create_over_max" to "最大数を超えました",
"add_to_iLove" to "私の好きに追加しました",
"remove_from_iLove" to "私の好きから削除しました",
"rename_menu" to "プレイリストの名前を変更",
"delete_menu" to "プレイリストを削除",
"music_info" to "曲情報",
"view_album" to "アルバムを見る",
"remove_music" to "曲を削除",
"shuffle_play" to "シャッフル再生",
"order_play" to "順番再生",
"single_play" to "単曲リピート",
"total_number_unit" to "曲",
"total_album_number_unit" to "アルバム",
"duration" to "長さ",
"position" to "位置",
"release_date" to "発売日",
"classification" to "分類",
"unknown" to "不明",
"select_items" to "選択済み",
"cancel" to "キャンセル",
"click_again_to_back" to "もう一度クリックしてデスクトップに戻る",
"data_parsing" to "データ解析中...",
"github_url" to "このソフトウェアのオープンソースプロジェクトリンク",
"no_songs" to "曲がありません",
"no_lyrics" to "歌詞がありません",
"title" to "タイトル",
"message" to "メッセージ",
"input_menu_name" to "プレイリスト名を入力してください",
"privacy_policy" to "プライバシー保護方針",
"permission_show_full" to "完全版を表示",
"permission_main_message" to "LoveLiveMusicPlayerをご利用いただきありがとうございます!\n\nユーザー契約及びプライバシーポリシーを通じて、本ソフトウェアが提供するサービスや、個人データの収集・処理方法についてご理解いただけます。\n「同意して続行」ボタンをクリックすることで、同意したことになります。",
"permission_external_link" to "《ユーザー契約及びプライバシーポリシー》",
"permission_agree" to "同意して続行",
"permission_disagree" to "同意しない",
"no_data" to "データがありません",
"choose_your_clean_data" to "クリアするデータを選択してください",
"remove_songs_data" to "曲データを削除",
"remove_user_data" to "ユーザーデータを削除",
"explain_songs_data" to "曲データ:曲のキャッシュデータ",
"explain_user_data" to "ユーザーデータ:プレイヤー設定、私の好き、プレイリスト",
"resetting" to "リセット中...",
"warning_choose" to "警告: 注意して選択してください",
"confirm_full_trans" to "データを完全に上書きしますか",
"cover_full_data" to "データを完全に上書き",
"file_path_error" to "ファイルパスエラー",
"crop_background_image" to "背景画像をトリミング",
"copy_success" to "コピー成功",
"need_enable_bg" to "まず背景画像を有効にしてください",
"search_lyric" to "歌詞を再検索",
"search_lyric_success" to "歌詞取得成功",
"search_lyric_failed" to "歌詞取得失敗",
"share_app" to "あなたにLLMPを共有しました",
"outer_share_music" to "あなたに曲を共有しました",
"outer_share_menu" to "あなたにプレイリストを共有しました",
"share_music" to "曲を共有",
"share_menu" to "プレイリストを共有",
"no_qq" to "TIM/QQがインストールされていません",
"version_incompatible" to "APPとPCバージョンが互換性がありません。ブログに行って更新してください",
"no_found_music1" to "この曲が見つかりません",
"need_play_share_music" to "共有曲を再生しますか",
"no_found_music2" to "利用可能な曲が見つかりません",
"need_import_share_menu" to "共有プレイリストをインポートしますか",
"delete_from_menu" to "プレイリストから削除",
"confirm_delete_from_menu" to "選択した曲をプレイリストから削除しますか?",
"cancel_i_like" to "私の好きをキャンセル",
"confirm_to_delete_music" to "選択した曲を私の好きから削除しますか?",
"share" to "アプリを共有",
"skip" to "スキップ",
"net_error" to "ネットワークエラー",
"hint" to "ヒント",
"use_http_music" to "HTTP音楽ライブラリを使用",
"input_http_url" to "HTTPアドレスを入力",
"support_http_characters" to "サポートされている文字: a-z A-Z 0-9 .:/_-",
"now_can_not_play" to "現在再生できません",
"moe_girl_wiki" to "萌娘百科",
"need_delete_menu" to "プレイリストを削除しますか",
"select_time" to "時間を選択してください(単位:分)",
"upgrading" to "アップグレード中...",
"upgrade_on_next_time" to "更新は次回起動時に有効になります",
"end_time" to "終了時間:",
"timed_to_stop" to "定時停止",
"timed_to_stop_remain" to "定時停止まで残り",
"minutes" to "分",
"please_give_install_permission_manual" to "APKのインストール権限を手動で付与してください",
"please_give_install_permission" to "アップグレードにはAPKのインストール権限を付与する必要があります。権限を付与しますか?",
"privacy_detail1" to "親愛なるLLer:\n\n「ソフトウェア使用許諾契約」(以下「本契約」)に記載されたすべての権利および制限を必ずお読みください。契約条件に同意しない限り、本ソフトウェアおよびその関連サービスを使用する権利はありません。本ソフトウェアを使用することにより、契約に同意したものとみなされます。\n\n1. 権利声明\n本ソフトウェアはオープンソースプロジェクトであり、著作権は作者zhushenwudiに帰属します。\n一部の情報はネットワークリソースから取得されており、テキスト表現やその組み合わせ、アイコン、画像、色、関連データなどが含まれます。著作権の問題が発生した場合は、作者のメール55681140@163.comに連絡して削除または置き換えを依頼してください。\n本ソフトウェアの宣伝や関連ファイルの転載については、作者に連絡して許可を得てください。使用中に発生した問題については、作者に連絡してサポートを受けることができます。作者はユーザーに本ソフトウェアの修正やアップグレード版を提供する権利を留保します。\n\n2. 许可范围\nダウンロード、インストール、使用:ユーザーは非商業目的で無制限に本ソフトウェアをダウンロード、インストール、使用できます。\nコピー、配布、伝播:ユーザーは非商業目的で無制限に本ソフトウェアをコピー、配布、伝播できますが、各コピー、配布、伝播が本ソフトウェアのすべてのソフトウェア、電子文書、著作権、商標を含む完全かつ真実であることを保証しなければなりません。\n\n3. プライバシーセキュリティ\n本ソフトウェアには、クラッシュ情報の報告や本ソフトウェア内の使用データのアップロードを除いて、ユーザーの端末データを破壊したり、ユーザーのプライバシー情報を取得したりする悪意のあるコードは含まれていません。\nユーザーの端末の行動を追跡、監視する機能コードは含まれておらず、ユーザーのオンラインまたはオフラインの行動を監視することはありません。他のソフトウェアや文書などの個人情報を収集することはなく、ユーザーのプライバシーを漏洩することもありません。\n収集されたデータは、ユーザーがクラッシュ中のエラー情報を分析し、本ソフトウェアを改善するために使用されます。\n以下は使用している統計データ開発ツールです:\n Ⅰ. 使用SDK名:友盟SDK\n 収集される個人情報の種類:デバイス情報(IMEI/Mac/android ID/IDFA/OPENUDID/GUID/SIMカードIMSI/地理位置情報)\n サービスの種類:データ収集と統計分析",
"privacy_umeng" to "《友盟プライバシーポリシーリンク》",
"privacy_detail2" to "\n\nⅡ. 使用SDK名:MobTechのShareSDK\n サービスの種類:シェアとプッシュを実現",
"privacy_share" to "《MobTechプライバシーポリシーリンク》",
"privacy_detail3" to "\n\nⅢ. 使用SDK名:360加固SDK(APK/HAP/APKプラグイン/SDK加固に適用)\n デバイス権限:なし\n 実現する機能目的:アプリの故障検出と診断、アプリのセキュリティ強化保護、海賊版検出サービス、脅威環境検出サービスを提供し、ユーザーが異常な状況を迅速に解決できるようにする\n 権限付与方法:権限付与方法はアプリ開発者が決定します。端末ユーザーが開発者のアプリに権限を付与することに同意した場合に有効になります",
"privacy_360" to "《360加固プライバシーポリシーリンク》",
"privacy_detail4" to "\n\n\n4. ユーザーへの注意\n本ソフトウェアはLoveLive!ファンによって自主的に使用されており、著作権のある曲は含まれていません。著作権のある曲はユーザーが自らCDから抽出してインポートする必要があります。インポートのディレクトリ構造はGithubプロジェクトの文書を参照してください。\nユーザーは法律および契約に従って本ソフトウェアを使用する必要があります。ユーザーは以下の行為を行う権利を有しません:\nⅠ. 本ソフトウェアのすべての権利管理電子情報を削除または変更してはなりません;\nⅡ. 本ソフトウェアを利用して他人を誤解させ、欺いてはなりません;\nⅢ. 国家の規定に違反して、コンピュータ情報システムの機能を削除、変更、追加、干渉し、コンピュータ情報システムが正常に動作できなくなる原因を作ってはなりません;\nⅣ. 許可なくコンピュータ情報ネットワークにアクセスしたり、コンピュータ情報ネットワーク資源を使用したりしてはなりません;\nⅤ. 許可なくコンピュータ情報ネットワークの機能を削除、変更、または追加してはなりません。\n ",
"now_playing" to "再生中:",
"group_list" to "グループリスト",
"back" to "戻る",
"phone" to "スマートフォン",
"pc" to "PC",
"add_favorite_menu" to "お気に入りのプレイリスト",
"music_blind_box" to "音楽ブラインドボックス",
"throbbing" to "あなたがドキドキするのはわかりますが、交通安全にも注意してください",
"exit" to "終了",
"finish" to "完了",
"search_in_moe_girl" to "萌娘百科で検索",
"share_error" to "共有に失敗しました。エラーコード:",
"share_success" to "成功を分かち合います",
"share_cancel" to "キャンセルを共有します",
"daily_news" to "日々のニュース",
"recent_trends" to "最近のトレンド",
"live_notice" to "放送予告",
"today_in_previous_years" to "過去の今日",
"input_song_main_char" to "曲名のキーワードを入力してください",
"playing" to "放送中",
"paused" to "停止しました",
"storage_settings" to "記憶場所設定です",
"change_storage_warning" to "使用するストレージを選択します。\nデバイスを切り替えると、全てのデータが消去されます。",
"internal_storage" to "内部ストレージです",
"storage_not_found" to "記憶装置は存在しません",
"need_play_music_first" to "曲の再生をお願いします",
"desktop_lyric" to "デスクトップの歌詞",
"choose" to "選ぶ",
"choose_album_category" to "アルバムカテゴリーを選択",
"people_unit" to "人",
"menu_unit" to "個",
"confirm_unlove" to "私の好きから削除しますか?",
"got_it" to "了解",
"close" to "閉じる",
"check_update" to "更新を確認中…",
"data_update_done" to "データ更新完了",
"no_download_url" to "ダウンロード先がありません",
"scan_pair" to "QRでペアリング",
"tap_to_scan" to "カメラでQRをスキャン",
"or_input_pc_ip" to "またはPCのIPを入力",
"confirm_pair" to "ペアリングを確認",
"disconnect" to "切断",
"data_parsing_short" to "データ解析中",
"history_short" to "履歴",
"today" to "今日",
"cannot_load" to "読み込めません:",
"daily_timezone_hint" to "注:特記なき場合、このページの時刻は北京時間(UTC+8)です。",
"select_storage" to "音楽ファイルの保存先を選択",
"no_usb" to "リムーバブルストレージ(USB)が見つかりません。",
"http_url_example" to "例: https://host/path",
"timer_cancelled" to "定時停止をキャンセルしました",
"will_stop_in" to "%s 分後に再生を停止します",
"exiting" to "終了します…",
"user_data_cleared" to "ユーザーデータを削除しました",
"new_version" to "新しいバージョン v%s",
"upgrade_prompt" to "新しいバージョンがあります。今すぐアップグレードしますか?",
"add_to_playlist_short" to "再生リストに追加",
"new_menu" to "新しいプレイリスト",
"menu_name" to "プレイリスト名",
"unknown_song" to "不明な曲",
"input_song_keyword" to "曲名キーワードを入力",
"not_playing" to "未再生",
"collapse" to "折りたたむ",
"more" to "その他",
"jump_play" to "再生へ",
"lyric_orig" to "原",
"lyric_trans" to "訳",
"lyric_roma" to "音",
"play_all" to "すべて再生",
"no_album_info" to "アルバム情報がありません",
"song_label" to "曲: ",
"album_label" to "アルバム: ",
"opening" to "開いています:",
"tachie" to "立ち絵",
"opening_tachie" to "立ち絵を開いています…",
"dark_mode" to "ダークモード",
"web_page" to "ウェブ",
"scan_qr_prompt" to "QRコードをスキャン",
"opening_app" to "アプリを開いています...",
"no_permission" to "権限がありません",
"ready_to_play" to "再生準備",
"forward" to "進む",
"refresh" to "更新",
"backward" to "戻る",
"play" to "再生",
"pause" to "一時停止",
"playlist" to "再生リスト",
"cannot_open_page" to "ページを開けません",
"invalid_link" to "無効なリンク",
"share_chooser" to "共有",
"no_activity" to "no activity",
"logs_error" to "エラー",
"logs_warning" to "警告",
"logs_info" to "情報",
"logs_debug" to "デバッグ",
"logs_verbose" to "詳細",
"logs_monitor" to "モニター",
"logs_settings" to "設定",
"logs_actions" to "操作",
"logs_copied_one" to "このログをコピーしました",
"logs_copied_all" to "すべてのログをコピーしました",
"logs_copied_filter" to "絞り込んだログをコピーしました",
"logs_search" to "検索…",
"logs_copy" to "コピー",
"logs_actions_title" to "ログ操作",
"logs_reverse" to "ログを逆順",
"logs_copy_all" to "すべてのログをコピー",
"logs_copy_filtered" to "絞り込んだログをコピー",
"logs_expand" to "ログを展開",
"logs_collapse" to "ログを折りたたむ",
"logs_clear" to "履歴を消去",
"logs_share_file" to "ログファイルを共有",
"logs_settings_title" to "ログ設定",
"logs_on" to "オン",
"logs_off" to "オフ",
"logs_monitor_title" to "ログモニター",
"logs_unhandled_errors" to "未処理エラーが %s 件あります",
"logs_warning_count" to "警告が %s 件あります",
"logs_info_count" to "情報ログ数:%s",
"logs_debug_count" to "詳細/デバッグログ数:%s",
"logs_verbose_debug" to "詳細とデバッグ",
"selected_count" to "選択済み %s 曲",
"count_songs" to "%s 曲",
"count_albums" to "%s アルバム",
"count_artists" to "%s 人",
"count_menus" to "%s 個",
"menu_meta_phone" to "%s 曲 · スマホ",
"menu_meta_pc" to "%s 曲 · PC",
"loop_songs" to "%s - %s 曲",
"skip_n" to "スキップ %ss",
"end_time_value" to "終了時間:%s",
"timed_remain_n" to "定時停止まで残り%s分",
"keep_screen_scan_qr" to "画面をオンのままQRコードをスキャンしてください",
"now_playing_empty" to "再生中:曲がありません",
"now_playing_song" to "再生中:%s",
"fetch_songs_fail_or_net" to "データ異常またはネットワーク失敗",
"explain_songs_data_kmp" to "曲データ:ライブラリメタデータ、バージョン",
"explain_user_data_kmp" to "ユーザーデータ:好き/履歴/プレイリスト/キャッシュを削除して終了",
"remove_songs_data_kmp" to "曲データを削除",
"remove_user_data_kmp" to "ユーザーデータを削除",
"confirm_delete_from_menu_short" to "プレイリストから削除しますか?",
"play_text" to "放送中,停止しました",
"connected" to "未接続",
"connecting" to "接続中…",
"handshaking" to "ハンドシェイク中…",
"disconnected" to "切断しました",
"version_mismatch" to "バージョン不一致",
"verified" to "検証済み",
"transfer_done" to "転送完了",
"peer_abort" to "相手が中断",
"cannot_connect_host" to "%s に接続できません",
"failed" to "失敗",
"handshake_incomplete" to "ハンドシェイク未完了",
"sent_phone2pc" to "phone2pc を送信しました",
"requested_pc2phone" to "pc2phone を要求しました",
"applied_pc_love" to "PCの好きリストを適用しました",
"applied_pc_menus" to "PCのプレイリスト/好きを適用しました",
"session_end" to "セッション終了",
"pending_n" to "送信待ち %s 曲",
"ready_n" to "準備完了 %s",
"downloading_song" to "ダウンロード中 %s",
"http_port" to "HTTPポート %s",
"widget_white" to "LLMP 音楽ウィジェット (白)",
"widget_black" to "LLMP 音楽ウィジェット (黒)",
"widget_desc" to "LoveLive!メディアプレーヤー",
"duration_label" to "長さ: ",
"position_label" to "位置: ",
"release_date_label" to "発売日: ",
"classification_label" to "分類: ",
"data_update_done_ver" to "データ更新完了 v%s · %s曲",
"downloading_short" to "ダウンロード中…",
"share_error_n" to "共有失敗、エラーコード: %s",
"album_abbr" to "ア",
"music_abbr" to "曲",
"menu_abbr" to "リ",
"lyric_close" to "デスクトップ歌詞を閉じる",
"lyric_pip" to "ピクチャインピクチャ",
"lyric_translate" to "翻訳歌詞",
"playback_channel" to "再生中",
"camera_permission" to "QRコードをスキャンするためにカメラへのアクセスが必要です",
"photo_permission" to "画像を取得するために写真へのアクセスが必要です",
)
@@ -0,0 +1,331 @@
package com.zhushenwudi.lovelivemusicplayer.i18n
internal val ZhStrings: Map<String, String> = mapOf(
"music" to "歌曲",
"album" to "专辑",
"music_name" to "歌名",
"singer" to "歌手",
"iLove" to "我喜欢",
"songMenu" to "歌单",
"history" to "最近播放",
"update_fail" to "更新失败",
"update" to "版本升级",
"update_yes" to "升级",
"update_no" to "不升级",
"no_need_update" to "当前已是最新版本",
"music_trans" to "歌曲快传",
"data_sync" to "数据同步",
"theme_with_system" to "跟随系统",
"colorful_mode" to "炫彩模式",
"splash_photo" to "开屏图片",
"enable_background_photo" to "启用背景图片",
"choose_background_photo" to "选择背景图片",
"fetch_songs" to "数据更新",
"clear_database" to "清理数据",
"will_shutdown" to "应用即将关闭,请手动重启",
"clean_success" to "清理成功",
"view_log" to "查看日志",
"privacy_agreement" to "隐私协议",
"system_settings" to "系统设置",
"drive_mode" to "驾驶模式",
"night_mode" to "夜间模式",
"downloading" to "下载数据中",
"now_is_latest" to "已是最新版本,是否覆盖?",
"data_error" to "数据异常",
"fetch_songs_fail" to "获取数据源失败",
"importing" to "导入中...",
"transferring" to "正在传输中...",
"loading" to "加载中...",
"song_lib" to "歌库",
"mine" to "我的",
"requesting" to "请求网络中...",
"please_check_network" to "请检查网络",
"keep_same_lan" to "请保持手机和电脑处于同一局域网内",
"keep_screen_and_scan_qr" to "保持屏幕常亮,并扫描PC端二维码",
"phone2pc" to "手机 ≫ 电脑",
"pc2phone" to "电脑 ≫ 手机",
"device_pair" to "设备配对",
"terminate_trans" to "退出后会中断连接及传输,是否继续?",
"confirm" to "确定",
"connect_fail" to "连接失败",
"album_info" to "专辑详情",
"add_success" to "添加成功",
"add_fail" to "添加失败",
"add_to_playlist" to "加入播放列表",
"add_to_menu" to "添加到歌单",
"create_menu" to "创建歌单",
"create_success" to "创建成功",
"create_over_max" to "超出最大数量",
"add_to_iLove" to "已加入我喜欢",
"remove_from_iLove" to "已取消我喜欢",
"rename_menu" to "重命名歌单",
"delete_menu" to "删除歌单",
"music_info" to "歌曲信息",
"view_album" to "查看专辑",
"remove_music" to "删除歌曲",
"shuffle_play" to "随机播放",
"order_play" to "顺序播放",
"single_play" to "单曲循环",
"total_number_unit" to "首",
"total_album_number_unit" to "张",
"duration" to "时长",
"position" to "位置",
"release_date" to "发行日期",
"classification" to "分类",
"unknown" to "未知",
"select_items" to "已选",
"cancel" to "取消",
"click_again_to_back" to "再次点击回到桌面",
"data_parsing" to "数据解析中...",
"github_url" to "本软件开源项目链接",
"no_songs" to "暂无歌曲",
"no_lyrics" to "暂无歌词",
"title" to "标题",
"message" to "消息",
"input_menu_name" to "请输入歌单名",
"privacy_policy" to "隐私保护政策",
"permission_show_full" to "查看完整版",
"permission_main_message" to "欢迎使用LoveLiveMusicPlayer!\n\n我们将通过《用户协议及隐私政策》帮助您了解本软件为您提供的服务,及收集、处理您个人数据的方式。\n点击【同意并继续】按钮代表您已同意。",
"permission_external_link" to "《用户协议及隐私政策》",
"permission_agree" to "同意并继续",
"permission_disagree" to "不同意",
"no_data" to "暂无数据",
"choose_your_clean_data" to "请选择要清理的数据",
"remove_songs_data" to "删除歌曲数据",
"remove_user_data" to "删除用户数据",
"explain_songs_data" to "歌曲数据:歌曲缓存数据",
"explain_user_data" to "用户数据:播放器配置、我喜欢、歌单",
"resetting" to "重置中...",
"warning_choose" to "警告: 请谨慎选择",
"confirm_full_trans" to "是否确定完全覆盖数据",
"cover_full_data" to "完全覆盖数据",
"file_path_error" to "文件路径错误",
"crop_background_image" to "裁切背景图片",
"copy_success" to "复制成功",
"need_enable_bg" to "请先启用背景图片",
"search_lyric" to "重新搜索歌词",
"search_lyric_success" to "获取歌词成功",
"search_lyric_failed" to "获取歌词失败",
"share_app" to "Ta向你分享了LLMP",
"outer_share_music" to "Ta向你分享了一首歌曲",
"outer_share_menu" to "Ta向你分享了一个歌单",
"share_music" to "分享歌曲",
"share_menu" to "分享歌单",
"no_qq" to "TIM/QQ未安装",
"version_incompatible" to "APP与PC版本不匹配,请前往博客更新",
"no_found_music1" to "未找到此歌曲",
"need_play_share_music" to "是否播放分享歌曲",
"no_found_music2" to "未找到可用歌曲",
"need_import_share_menu" to "是否导入分享歌单",
"delete_from_menu" to "从歌单中删除",
"confirm_delete_from_menu" to "确认要从歌单删除所选歌曲?",
"cancel_i_like" to "取消我喜欢",
"confirm_to_delete_music" to "确认要从我喜欢删除所选歌曲?",
"share" to "应用分享",
"skip" to "跳过",
"net_error" to "网络错误",
"hint" to "提示",
"use_http_music" to "使用HTTP曲库",
"input_http_url" to "输入HTTP地址",
"support_http_characters" to "支持字符: a-z A-Z 0-9 .:/_-",
"now_can_not_play" to "当前无法播放",
"moe_girl_wiki" to "萌娘百科",
"need_delete_menu" to "是否刪除歌单",
"select_time" to "请选择时间(单位:分钟)",
"upgrading" to "升级中...",
"upgrade_on_next_time" to "更新将于下次启动生效",
"end_time" to "结束时间:",
"timed_to_stop" to "定时关闭",
"timed_to_stop_remain" to "定时关闭,还剩",
"minutes" to "分钟",
"please_give_install_permission_manual" to "请手动赋予安装APK权限",
"please_give_install_permission" to "升级需要授予安装APK权限,是否授权?",
"privacy_detail1" to "亲爱的LLer:\n\n请务必认真阅读和理解《软件许可使用协议》(以下简称:本协议)中规定的所有权利和限制,除非您接受协议条款,否则您无权使用本软件及其相关服务。您一旦使用本软件,将视为对协议的接受。\n\n1.权利声明\n本软件为开源项目,由作者zhushenwudi权利所有。\n其中部分信息内容从网络资源获取,包含但不限于:文字表述及其组合、图标、图像、色彩、有关数据,若因版权问题产生任何矛盾和纠纷,请联系作者本人邮箱55681140@163.com予以删除或替换。\n若对软件的宣传、相关文件的转载请联系作者本人进行授权。使用中出现的任何问题均可联系作者获取支持。作者保留为用户提供本软件的修改、升级版本的权利。\n\n2.许可范围\n下载、安装和使用:用户可以非商业性、无限制数量的下载、安装及使用本软件。\n复制、分发和传播:用户可以非商业性、无限制数量的复制、分发和传播本软件产品,但必须保证每一份复制、分发和传播都是完整和真实的,包括所有有关本软件的软件、电子文档、版权和商标,亦包括协议。\n\n3.隐私安全\n本软件不含有除上报崩溃信息、上传本软件内使用数据外的任何旨在破坏用户终端数据和获取用户隐私信息的恶意代码。\n不含有任何跟踪、监视用户终端行为的功能代码,不会监控用户网上、网下的行为,不会收集用户使用其他软件、文档等个人信息,不会泄露用户隐私。\n收集到的数据将用户分析崩溃中的错误信息用于更好的完善本软件。\n下面是使用的统计数据开发工具:\n Ⅰ.使用SDK名称:友盟SDK\n 收集个人信息类型:设备信息(IMEI/Mac/android ID/IDFA/OPENUDID/GUID/SIM卡IMSI/地理位置信息)\n 服务类型:数据采集与统计分析",
"privacy_umeng" to "《友盟隐私权政策链接》",
"privacy_detail2" to "\n\nⅡ.使用SDK名称:MobTech的ShareSDK\n 服务类型:实现分享和推送",
"privacy_share" to "《MobTech隐私权政策链接》",
"privacy_detail3" to "\n\nⅢ.使用SDK名称:360加固SDK (适用于APK/HAP/APK插件/SDK加固)\n 设备权限: 无\n 实现的功能目的: 用于检测App故障和诊断,App安全加固保护,盗版检测服务,威胁环境检测服务,以便帮助用户快速解决异常情况\n 权限授权方式: 授权方式由App开发者决定,当终端用户同意向开发者App授权该权限时开启",
"privacy_360" to "《360加固隐私权政策链接》",
"privacy_detail4" to "\n\n\n4.用户须知\n本软件为LoveLive!圈内用户自发使用,不包含任何版权歌曲在内。版权歌曲均由用户本人自行从CD提取导入,导入目录结构详见Github项目文档。\n用户应在遵守法律及协议的前提下使用本软件,用户无权实施包括但不限于下列行为:\nⅠ. 不得删除或改变本软件上的所有权利管理电子信息;\nⅡ. 不得利用本软件误导、欺骗他人;\nⅢ. 违反国家规定,对计算机信息系统功能进行删除、修改、增加、干扰,造成计算机信息系统不能正常运行;\nⅣ. 未经允许,进入计算机信息网络或者使用计算机信息网络资源;\nⅤ. 未经允许,对计算机信息网络功能进行删除、修改或者增加的。\n ",
"now_playing" to "正在播放:",
"group_list" to "团组列表",
"back" to "返回",
"phone" to "手机",
"pc" to "电脑",
"add_favorite_menu" to "收藏歌单",
"music_blind_box" to "音乐盲盒",
"throbbing" to "我知道你会悸动,但也要注意交通安全",
"exit" to "退出",
"finish" to "完成",
"search_in_moe_girl" to "在萌娘百科搜索",
"share_error" to "分享失败,错误代码:",
"share_success" to "分享成功",
"share_cancel" to "分享取消",
"daily_news" to "每日资讯",
"recent_trends" to "近期动态",
"live_notice" to "放送预告",
"today_in_previous_years" to "往年今日",
"input_song_main_char" to "请输入歌曲名关键字",
"playing" to "播放中",
"paused" to "已暂停",
"storage_settings" to "存储设置",
"change_storage_warning" to "选择要使用的存储设备\n切换设备将会清空全部数据",
"internal_storage" to "内部存储",
"storage_not_found" to "存储设备不存在",
"need_play_music_first" to "请先播放歌曲",
"desktop_lyric" to "桌面歌词",
"choose" to "选择",
"choose_album_category" to "选择专辑分类",
"people_unit" to "位",
"menu_unit" to "个",
"confirm_unlove" to "确认取消喜欢?",
"got_it" to "知道了",
"close" to "关闭",
"check_update" to "检查更新…",
"data_update_done" to "数据更新完成",
"no_download_url" to "未提供下载地址",
"scan_pair" to "扫码配对",
"tap_to_scan" to "点击打开相机扫码",
"or_input_pc_ip" to "或手动输入 PC IP",
"confirm_pair" to "确认配对",
"disconnect" to "断开连接",
"data_parsing_short" to "数据解析中",
"history_short" to "历史",
"today" to "今天",
"cannot_load" to "无法加载:",
"daily_timezone_hint" to "提示:除特别注明外,此页面或章节的时间均以北京时间(UTC+8)为准。",
"select_storage" to "请选择音乐文件存储位置",
"no_usb" to "未检测到可移动存储(USB)。",
"http_url_example" to "如 https://host/path",
"timer_cancelled" to "已取消定时关闭",
"will_stop_in" to "将在 %s 分钟后停止播放",
"exiting" to "即将退出…",
"user_data_cleared" to "用户数据已清理",
"new_version" to "发现新版本 v%s",
"upgrade_prompt" to "检测到新版本,是否立即升级?",
"add_to_playlist_short" to "添加到播放列表",
"new_menu" to "新建歌单",
"menu_name" to "歌单名称",
"unknown_song" to "未知歌曲",
"input_song_keyword" to "输入歌曲关键字",
"not_playing" to "未播放",
"collapse" to "收起",
"more" to "更多",
"jump_play" to "跳转播放",
"lyric_orig" to "原",
"lyric_trans" to "译",
"lyric_roma" to "音",
"play_all" to "播放全部",
"no_album_info" to "暂无专辑信息",
"song_label" to "歌曲: ",
"album_label" to "专辑: ",
"opening" to "正在打开:",
"tachie" to "立绘",
"opening_tachie" to "正在打开立绘…",
"dark_mode" to "深色模式",
"web_page" to "网页",
"scan_qr_prompt" to "扫描二维码",
"opening_app" to "正在打开应用...",
"no_permission" to "权限未被授予",
"ready_to_play" to "准备播放",
"forward" to "前进",
"refresh" to "刷新",
"backward" to "后退",
"play" to "播放",
"pause" to "暂停",
"playlist" to "播放列表",
"cannot_open_page" to "无法打开页面",
"invalid_link" to "链接无效",
"share_chooser" to "分享",
"no_activity" to "no activity",
"logs_error" to "错误",
"logs_warning" to "警告",
"logs_info" to "信息",
"logs_debug" to "调试",
"logs_verbose" to "详细",
"logs_monitor" to "监控",
"logs_settings" to "设置",
"logs_actions" to "操作",
"logs_copied_one" to "已复制该条日志",
"logs_copied_all" to "已复制全部日志",
"logs_copied_filter" to "已复制筛选后的日志",
"logs_search" to "搜索…",
"logs_copy" to "复制",
"logs_actions_title" to "日志操作",
"logs_reverse" to "倒序日志",
"logs_copy_all" to "复制全部日志",
"logs_copy_filtered" to "复制筛选日志",
"logs_expand" to "展开日志",
"logs_collapse" to "收起日志",
"logs_clear" to "清空历史",
"logs_share_file" to "分享日志文件",
"logs_settings_title" to "日志设置",
"logs_on" to "开",
"logs_off" to "关",
"logs_monitor_title" to "日志监控",
"logs_unhandled_errors" to "应用有 %s 条未处理错误",
"logs_warning_count" to "应用有 %s 条警告",
"logs_info_count" to "信息日志数量:%s",
"logs_debug_count" to "详细/调试日志数量:%s",
"logs_verbose_debug" to "详细与调试",
"selected_count" to "已选择 %s 首",
"count_songs" to "%s 首",
"count_albums" to "%s 张",
"count_artists" to "%s 位",
"count_menus" to "%s 个",
"menu_meta_phone" to "%s 首 · 手机",
"menu_meta_pc" to "%s 首 · PC",
"loop_songs" to "%s - %s 首",
"skip_n" to "跳过 %ss",
"end_time_value" to "结束时间:%s",
"timed_remain_n" to "定时关闭,还剩%s分钟",
"keep_screen_scan_qr" to "请保持屏幕常亮并扫描二维码",
"now_playing_empty" to "正在播放:暂无歌曲",
"now_playing_song" to "正在播放:%s",
"fetch_songs_fail_or_net" to "数据异常或网络失败",
"explain_songs_data_kmp" to "歌曲数据:曲库元数据、版本号",
"explain_user_data_kmp" to "用户数据:喜欢/历史/歌单/缓存,并退出应用",
"remove_songs_data_kmp" to "清除歌曲数据",
"remove_user_data_kmp" to "清除用户数据",
"confirm_delete_from_menu_short" to "确认从歌单删除?",
"play_text" to "播放中,已暂停",
"connected" to "未连接",
"connecting" to "连接中…",
"handshaking" to "握手中…",
"disconnected" to "已断开",
"version_mismatch" to "版本不匹配",
"verified" to "已验证",
"transfer_done" to "传输完成",
"peer_abort" to "对方中断",
"cannot_connect_host" to "无法连接 %s",
"failed" to "失败",
"handshake_incomplete" to "未完成握手",
"sent_phone2pc" to "已发送 phone2pc",
"requested_pc2phone" to "已请求 pc2phone",
"applied_pc_love" to "已应用 PC 侧喜欢列表",
"applied_pc_menus" to "已应用 PC 歌单/喜欢",
"session_end" to "会话结束",
"pending_n" to "待传 %s 首",
"ready_n" to "就绪 %s",
"downloading_song" to "下载中 %s",
"http_port" to "HTTP 端口 %s",
"widget_white" to "LLMP 音乐组件 (白)",
"widget_black" to "LLMP 音乐组件 (黑)",
"widget_desc" to "LoveLive!媒体播放器",
"duration_label" to "时长: ",
"position_label" to "位置: ",
"release_date_label" to "发行日期: ",
"classification_label" to "分类: ",
"data_update_done_ver" to "数据更新完成 v%s · %s首",
"downloading_short" to "下载中…",
"share_error_n" to "分享失败,错误代码: %s",
"album_abbr" to "专",
"music_abbr" to "歌",
"menu_abbr" to "单",
"lyric_close" to "关闭桌面歌词",
"lyric_pip" to "画中画",
"lyric_translate" to "翻译歌词",
"playback_channel" to "正在播放",
"camera_permission" to "需要获取摄像头权限扫描二维码",
"photo_permission" to "需要读取相册获取图片",
)
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.lyric
package com.zhushenwudi.lovelivemusicplayer.lyric
data class LyricLine(
val timeMs: Long,
@@ -1,49 +1,51 @@
package top.zhushenwudi.llmp.lyric
package com.zhushenwudi.lovelivemusicplayer.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
import com.zhushenwudi.lovelivemusicplayer.db.LibraryRepository
import com.zhushenwudi.lovelivemusicplayer.domain.Lyric
import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.network.OssApi
import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
/**
* Port of Flutter [LyricLogic]: getLrc + handleLRC (DB secondary cache).
* 歌词加载服务:getLrc + handleLRC(数据库二级缓存)。
*
* 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)
* 缓存语义:
* - 行不存在 → storageLrc = null → 拉取 → insertLyric
* - 行存在但字段为 null/"" → storageLrc = "" → 拉取 → updateLrc
* - 行存在且字段非空 → 返回数据库内容(除非 forceRefresh)
*/
class LyricService(
private val oss: OssApi,
private val library: LibraryRepository,
) {
/** Flutter: SDUtils.allowEULA ? 0 : 1 */
/** EULA 允许时为 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].
* 加载歌词,使用曲目的相对 `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.
suspend fun getLrc(music: Music, forceRefresh: Boolean = false): LoadedLyric =
fetchLrc(music, forceRefresh).loaded
/**
* 对齐原 Flutter `LyricLogic.getLrc`:任一路 handleLRC 为 null 则搜索失败。
*/
suspend fun fetchLrc(music: Music, forceRefresh: Boolean = false): LyricFetchResult {
val uid = music.musicId ?: return LyricFetchResult(LoadedLyric().also { fullLrc = Lyric() }, false)
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) }
return LyricFetchResult(LoadedLyric().also { fullLrc = Lyric(uid = uid) }, false)
}
// Reject absolute / playback URIs — same as needing relative cloud keys.
if (musicPath.contains("://") || musicPath.startsWith("/")) {
return LoadedLyric().also { fullLrc = Lyric(uid = uid) }
return LyricFetchResult(LoadedLyric().also { fullLrc = Lyric(uid = uid) }, false)
}
// Flutter: musicPath.replaceAll("flac","lrc").replaceAll("wav","lrc")
val lyricPath = musicPath
.replace("flac", "lrc")
.replace("wav", "lrc")
@@ -51,29 +53,29 @@ class LyricService(
var jpLrc = ""
var zhLrc = ""
var romaLrc = ""
var ok = true
// Flutter always loads ZH first
val zh = handleLrc("zh", "ZH/$baseUrl$lyricPath", uid, forceRefresh)
if (zh != null) zhLrc = zh
if (zh == null) ok = false else zhLrc = zh
if (PlatformInfo.allowEulaLyric) {
val jp = handleLrc("jp", "JP/$baseUrl$lyricPath", uid, forceRefresh)
if (jp != null) jpLrc = jp
if (jp == null) ok = false else jpLrc = jp
val roma = handleLrc("roma", "ROMA/$baseUrl$lyricPath", uid, forceRefresh)
if (roma != null) romaLrc = roma
if (roma == null) ok = false else romaLrc = roma
}
fullLrc = Lyric(uid = uid, jp = jpLrc, zh = zhLrc, roma = romaLrc)
return toLoaded(fullLrc)
return LyricFetchResult(toLoaded(fullLrc), ok)
}
/** @deprecated Use [getLrc] — kept for call-site migration. */
/** @deprecated 请使用 `getLrc`,保留用于迁移旧调用点。 */
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
* 按语言先查数据库缓存再请求网络。
* @return 歌词文本,失败或缺失时返回 null
*/
private suspend fun handleLrc(
type: String,
@@ -84,7 +86,7 @@ class LyricService(
if (lrcUrl.isEmpty() || uid.isEmpty()) return null
var row = library.getLyric(uid)
// Flutter: null = never inserted; "" = inserted but empty
// null 表示从未插入;"" 表示已插入但为空
val storageLrc: String? = when {
row == null -> null
type == "jp" -> row.jp ?: ""
@@ -94,7 +96,6 @@ class LyricService(
}
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()) {
@@ -111,13 +112,11 @@ class LyricService(
"roma" -> row.copy(roma = netLrc)
else -> row
}
// Flutter: insert when storageLrc == null, else update
// storageLrc 为 null 时插入,否则更新
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) {
@@ -125,7 +124,6 @@ class LyricService(
null
}
}
AppLog.d(TAG, "handleLrc($type) cache hit uid=$uid len=${storageLrc.length}")
return storageLrc
}
@@ -133,7 +131,7 @@ class LyricService(
lrcType = (lrcType + 1) % 3
}
/** Flutter [LyricLogic.reloadLyricToController] → parse for UI. */
/** 将 fullLrc 解析为 UI 可用的 LoadedLyric。 */
fun toLoaded(lyric: Lyric = fullLrc): LoadedLyric {
val main: String?
val translation: String?
@@ -170,8 +168,17 @@ data class LoadedLyric(
val raw: Lyric? = null,
)
data class LyricFetchResult(
val loaded: LoadedLyric,
val ok: Boolean,
)
expect object PlatformInfo {
val allowEulaLyric: Boolean
/** 是否为 iOS 平台,本地音频使用 wav 而非 flac。 */
val isIos: Boolean
/** 对外展示版本号(iOS `CFBundleShortVersionString`)。 */
val appVersion: String
}
private const val TAG = "LLMP-Lyric"
@@ -1,25 +1,19 @@
package top.zhushenwudi.llmp.network
package com.zhushenwudi.lovelivemusicplayer.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.
// 设置 User-Agent 请求头;OSS 无此头会返回 403。
defaultRequest {
header(
HttpHeaders.UserAgent,
@@ -38,13 +32,4 @@ fun createHttpClient(): HttpClient =
)
}
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,100 @@
package com.zhushenwudi.lovelivemusicplayer.network
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsBytes
import io.ktor.client.statement.bodyAsChannel
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.utils.io.exhausted
import io.ktor.utils.io.readAvailable
import com.zhushenwudi.lovelivemusicplayer.Const
import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem
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()
/**
* 请求歌词:Const.LYRIC_URL + 整段路径 URL 编码
* `relativePath` 如 `JP/$baseUrl$file.lrc`(整段编码,`/` → `%2F`)。
*/
suspend fun fetchLyricRelativePath(relativePath: String): String {
val url = Const.LYRIC_URL + encodeComponent(relativePath)
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 postJson(url: String, jsonBody: String): String =
client.post(url) {
contentType(ContentType.Application.Json)
setBody(jsonBody)
}.bodyAsText()
suspend fun downloadBytes(url: String): ByteArray =
client.get(url).bodyAsBytes()
/** 流式落盘,避免大 flac 整包进内存 OOM。 */
suspend fun downloadToFile(url: String, dest: String, fs: FileSystem): Boolean {
val response = client.get(url) {
header(HttpHeaders.Accept, "*/*")
}
if (response.status.value !in 200..299) return false
if (!fs.writeBegin(dest)) return false
val channel = response.bodyAsChannel()
val buf = ByteArray(64 * 1024)
while (!channel.exhausted()) {
val n = channel.readAvailable(buf)
if (n <= 0) break
if (!fs.writeAppend(dest, buf, 0, n)) return false
}
return fs.exists(dest)
}
/**
* 整段字符串 UTF-8 百分号编码。
* 旧实现用 `Char.code` 作 `%XX`(如 ♡→`%2661`)无效,
* 且通过 `Char.isLetterOrDigit` 未转义 CJK。
*/
private fun encodeComponent(value: String): String {
val bytes = value.encodeToByteArray()
return buildString(bytes.size * 3) {
for (b in bytes) {
val ub = b.toInt() and 0xFF
val c = ub.toChar()
if (c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c in "-._~") {
append(c)
} else {
append('%')
append(ub.toString(16).uppercase().padStart(2, '0'))
}
}
}
}
}
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.network
package com.zhushenwudi.lovelivemusicplayer.network
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocketSession
@@ -11,10 +11,10 @@ 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
import com.zhushenwudi.lovelivemusicplayer.Const
import com.zhushenwudi.lovelivemusicplayer.domain.FtpCmd
import com.zhushenwudi.lovelivemusicplayer.protocol.Handshake
import com.zhushenwudi.lovelivemusicplayer.protocol.TransferChannel
class WsClient(
private val client: HttpClient,
@@ -1,6 +1,6 @@
package top.zhushenwudi.llmp.platform
package com.zhushenwudi.lovelivemusicplayer.platform
/** Platform logger — Android → Logcat, iOS → NSLog/println. */
/** 平台日志 — Android → Logcat,iOS → NSLog/println。 */
expect object AppLog {
fun d(tag: String, message: String)
fun i(tag: String, message: String)
@@ -0,0 +1,56 @@
package com.zhushenwudi.lovelivemusicplayer.platform
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.zhushenwudi.lovelivemusicplayer.db.currentTimeMs
data class AppLogEntry(
val level: String,
val tag: String,
val message: String,
val timeMs: Long,
) {
val key: String
get() = when (level) {
"E" -> "error"
"W" -> "warning"
"I" -> "info"
"D" -> "debug"
else -> "verbose"
}
fun asLine(): String = "$level/$tag: $message"
}
/**
* 内存环形缓冲区,保存最近 [AppLog] 行,供应用内「查看日志」页面
* 对齐原项目 TalkerScreen。
*/
object AppLogStore {
private const val MAX = 800
private val entries = ArrayDeque<AppLogEntry>(MAX)
private val _version = MutableStateFlow(0)
val version: StateFlow<Int> = _version.asStateFlow()
fun append(level: String, tag: String, message: String) {
val entry = AppLogEntry(
level = level,
tag = tag,
message = message,
timeMs = currentTimeMs(),
)
if (entries.size >= MAX) entries.removeFirst()
entries.addLast(entry)
_version.value = _version.value + 1
}
fun snapshot(): List<AppLogEntry> = entries.toList()
fun snapshotLines(): List<String> = entries.map { it.asLine() }
fun clear() {
entries.clear()
_version.value = _version.value + 1
}
}
@@ -0,0 +1,259 @@
package com.zhushenwudi.lovelivemusicplayer.platform
import kotlinx.serialization.Serializable
import com.zhushenwudi.lovelivemusicplayer.domain.Album
import com.zhushenwudi.lovelivemusicplayer.i18n.I18n
import com.zhushenwudi.lovelivemusicplayer.domain.GroupKey
import com.zhushenwudi.lovelivemusicplayer.domain.Menu
import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.player.LoopMode
/**
* CarPlay 曲库目录。
* UI 由原生 Swift `CPTemplate` 渲染;此模型提供快照数据与播放回调。
*/
@Serializable
data class CarPlayGroupNode(
val group: String,
val displayName: String,
val detail: String = "",
val logoAsset: String = "",
)
@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 date: String? = null,
val group: String? = null,
val coverPath: String? = null,
)
@Serializable
data class CarPlayMenuItem(
val menuId: Long,
val name: String,
val count: Int,
val isPhone: Boolean = true,
val coverPath: String? = null,
)
@Serializable
data class CarPlaySnapshot(
val nowPlayingTitle: String,
val nowPlayingCover: String? = null,
val nowPlayingId: String? = null,
val isPlaying: Boolean = false,
val isFavorite: Boolean = false,
val loopMode: String = "LIST",
val groups: List<CarPlayGroupNode>,
val songsByGroup: Map<String, List<CarPlaySongItem>>,
val albumsByGroup: Map<String, List<CarPlayAlbumItem>>,
val songsByAlbum: Map<String, List<CarPlaySongItem>>,
val loveSongs: List<CarPlaySongItem>,
val recentSongs: List<CarPlaySongItem> = emptyList(),
val menus: List<CarPlayMenuItem>,
val songsByMenu: Map<String, List<CarPlaySongItem>>,
val allSongIds: List<String>,
)
class CarPlayCatalog {
var nowPlayingTitle: String = I18n.t("now_playing_empty")
private set
var nowPlayingCover: String? = null
private set
var nowPlayingId: String? = null
private set
var isPlaying: Boolean = false
private set
var isFavorite: Boolean = false
private set
var loopMode: String = LoopMode.LIST.name
private set
var groups: List<CarPlayGroupNode> = defaultGroups()
private set
var loveSongs: List<CarPlaySongItem> = emptyList()
private set
var recentSongs: 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<String, List<CarPlaySongItem>> = emptyMap()
private var allSongIds: List<String> = emptyList()
var onPlay: ((musicId: String, queue: List<CarPlaySongItem>) -> Unit)? = null
fun updateNowPlaying(
music: Music?,
coverUri: String? = null,
playing: Boolean = false,
favorite: Boolean = false,
loopMode: LoopMode = LoopMode.LIST,
) {
val name = music?.musicName
nowPlayingTitle = if (name.isNullOrBlank()) {
I18n.t("now_playing_empty")
} else {
I18n.t("now_playing_song", name)
}
nowPlayingCover = coverUri ?: music?.coverPath
nowPlayingId = music?.musicId
isPlaying = playing && music != null
isFavorite = favorite
this.loopMode = loopMode.name
}
fun updateRecent(recent: List<Music>, musicCover: (Music) -> String? = { it.coverPath }) {
recentSongs = recent.map { it.toItem(musicCover(it)) }
}
fun setLibrary(
allMusic: List<Music>,
albums: List<Album>,
love: List<Music>,
recent: List<Music> = emptyList(),
menuList: List<Menu>,
musicCover: (Music) -> String? = { it.coverPath },
albumCover: (Album) -> String? = { it.coverPath },
) {
groups = defaultGroups()
val songItems = allMusic.mapNotNull { music ->
val id = music.musicId ?: return@mapNotNull null
if (id.isBlank()) return@mapNotNull null
music.toItem(musicCover(music))
}
allSongIds = songItems.map { it.musicId }
songsByGroup = allMusic
.filter { !it.musicId.isNullOrBlank() }
.groupBy { it.group.orEmpty() }
.mapValues { (_, list) -> list.map { it.toItem(musicCover(it)) } }
albumsByGroup = albums
.filter { !it.albumId.isNullOrBlank() }
.groupBy { it.group.orEmpty() }
.mapValues { (_, list) ->
list.map {
CarPlayAlbumItem(
albumId = it.albumId!!,
title = it.albumName.orEmpty(),
date = it.date,
group = it.group,
coverPath = albumCover(it),
)
}
}
songsByAlbum = allMusic
.filter { !it.albumId.isNullOrBlank() && !it.musicId.isNullOrBlank() }
.groupBy { it.albumId!! }
.mapValues { (_, list) -> list.map { it.toItem(musicCover(it)) } }
loveSongs = love.map { it.toItem(musicCover(it)) }
recentSongs = recent.map { it.toItem(musicCover(it)) }
val musicById = allMusic.associateBy { it.musicId }
menus = menuList.map { menu ->
val first = menu.music.firstOrNull()?.let { musicById[it] }
CarPlayMenuItem(
menuId = menu.id,
name = menu.name,
count = menu.music.size,
isPhone = menu.isPhone,
coverPath = first?.let { musicCover(it) },
)
}
songsByMenu = menuList.associate { menu ->
menu.id.toString() to menu.music.mapNotNull { id ->
val music = musicById[id] ?: return@mapNotNull null
music.toItem(musicCover(music))
}
}
}
fun snapshot(): CarPlaySnapshot = CarPlaySnapshot(
nowPlayingTitle = nowPlayingTitle,
nowPlayingCover = nowPlayingCover,
nowPlayingId = nowPlayingId,
isPlaying = isPlaying,
isFavorite = isFavorite,
loopMode = loopMode,
groups = groups,
songsByGroup = songsByGroup,
albumsByGroup = albumsByGroup,
songsByAlbum = songsByAlbum,
loveSongs = loveSongs,
recentSongs = recentSongs,
menus = menus,
songsByMenu = songsByMenu,
allSongIds = allSongIds,
)
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.toString()].orEmpty()
fun play(item: CarPlaySongItem, queue: List<CarPlaySongItem>) {
onPlay?.invoke(item.musicId, queue)
}
private fun Music.toItem(coverUri: String?) = CarPlaySongItem(
musicId = musicId.orEmpty(),
title = musicName.orEmpty(),
artist = artist,
coverPath = coverUri,
)
companion object {
fun defaultGroups(): List<CarPlayGroupNode> = listOf(
CarPlayGroupNode(GroupKey.GROUP_US.displayName, "μ's", "ラブライブ!", "LogoUs"),
CarPlayGroupNode(GroupKey.GROUP_AQOURS.displayName, "Aqours", "ラブライブ!サンシャイン!!", "LogoAqours"),
CarPlayGroupNode(
GroupKey.GROUP_NIJIGASAKI.displayName,
"虹咲学园学园偶像同好会",
"虹ヶ咲学園スクールアイドル同好会",
"LogoNiji",
),
CarPlayGroupNode(
GroupKey.GROUP_LIELLA.displayName,
"Liella!",
"ラブライブ!スーパースター!!",
"LogoLiella",
),
CarPlayGroupNode(
GroupKey.GROUP_HASUNOSORA.displayName,
"莲之空女学院",
"蓮ノ空女学院スクールアイドルクラブ",
"LogoHasunosora",
),
CarPlayGroupNode(
GroupKey.GROUP_YOHANE.displayName,
"幻日夜羽",
"幻日のヨハネ -SUNSHINE in the MIRROR-",
"LogoYohane",
),
CarPlayGroupNode(
GroupKey.GROUP_MUSICAL.displayName,
"学园偶像音乐剧",
"スクールアイドルミュージカル",
"LogoMusical",
),
CarPlayGroupNode(
GroupKey.GROUP_BLUEBIRD.displayName,
"BlueBird",
"イキヅライブ!LOVELIVE! BLUEBIRD",
"LogoBluebird",
),
CarPlayGroupNode(GroupKey.GROUP_COMBINE.displayName, "其他", "u咩", "LogoCombine"),
)
}
}
@@ -0,0 +1,40 @@
package com.zhushenwudi.lovelivemusicplayer.platform
/**
* 本地存储根路径与文件辅助方法。
*/
expect class FileSystem {
/**
* 应用文件/文档根目录(以分隔符结尾)。
* 默认音乐根路径,如 Android `…/files/`、iOS Documents。
*/
val filesRoot: String
/**
* 音乐/媒体根目录(以分隔符结尾)。
* 云端 `base_url` 已以 `LoveLive/…` 开头,此处不得再追加 `LoveLive/`。
*/
val musicRoot: String
fun exists(absolutePath: String): Boolean
fun ensureDir(absolutePath: String): Boolean
fun writeBytes(absolutePath: String, bytes: ByteArray): Boolean
/** 创建/清空文件,供分块写入(大 flac 不能整包进内存)。 */
fun writeBegin(absolutePath: String): Boolean
fun writeAppend(absolutePath: String, bytes: ByteArray, offset: Int, length: Int): Boolean
/** `absoluteDir` 下文件的非递归绝对路径(不含目录)。 */
fun listFiles(absoluteDir: String): List<String>
/**
* Android:`getExternalFilesDirs` 得到的应用私有目录(含内部存储 + SD/USB)。
* iOS:空列表。对齐 Flutter `SDUtils.getUsbPathList`。
*/
fun listUsbRoots(): List<String>
}
expect fun createFileSystem(): FileSystem
@@ -1,29 +1,39 @@
package top.zhushenwudi.llmp.platform
package com.zhushenwudi.lovelivemusicplayer.platform
/**
* Platform system integrations used by AppContainer / UI.
* Android & iOS wire real implementations; NoOp is compile-time fallback only.
* AppContainer / UI 使用的平台系统集成。
* Android 与 iOS 接入真实实现;NoOp 仅为编译期回退。
*/
/** Desktop / floating lyric overlay (Android WindowManager; iOS PiP lyric). */
/** 桌面/悬浮歌词叠加层(Android WindowManager;iOS PiP 歌词)。 */
interface DesktopLyricController {
/** 是否允许后台自动弹出(对齐 Flutter `isAutoPip` / `isOpenPicInPic`)。 */
var enabled: Boolean
/**
* 对齐 Flutter `DesktopLyricUtil.pipAutoOpen`。
* Android 开启时若无悬浮窗权限会跳转设置并返回 false(调用方应回滚开关)。
*/
fun pipAutoOpen(enable: Boolean): Boolean
/** Android:挂上悬浮窗。iOS:后台时尝试弹出 PiP。 */
fun show()
/** Android:卸下悬浮窗。iOS:关掉当前 PiP,不改变开关。 */
fun hide()
fun updateLines(line1: String?, line2: String?, currentLine: Int)
fun setPlaying(playing: Boolean)
fun isOverlayShowing(): 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",
)
@@ -32,7 +42,7 @@ interface HomeWidgetController {
fun refresh()
}
/** Deep link parser for llmp:// share URLs. */
/** llmp:// 分享链接深链解析。 */
interface DeepLinkHandler {
fun parse(url: String): DeepLinkPayload?
fun handle(url: String)
@@ -45,17 +55,27 @@ data class DeepLinkPayload(
val data: String? = null,
)
/** USB mount / unmount monitoring. */
/** USB 挂载/卸载监听。对齐 Flutter `usb_broadcast` / `SDUtils.setUsbMountListener`。 */
interface UsbMountMonitor {
fun start()
fun stop()
fun listMountedVolumes(): List<String>
fun setOnChanged(listener: (() -> Unit)?)
}
/** System share (Android Intent / iOS share sheet). */
/** 系统分享(Android Intent / iOS 分享面板)与 QQ 网页卡片分享。 */
interface ShareBridge {
fun shareText(text: String, title: String? = null)
fun shareUrl(url: String, title: String? = null)
/** 直接唤起 QQ/TIM 分享网页卡片(对齐原项目 ShareSDK `SSDKContentTypes.webpage`)。 */
fun shareToQq(
title: String,
text: String,
url: String,
imageUrl: String? = null,
imagePath: String? = null,
)
}
interface MediaSessionHook {
@@ -73,17 +93,23 @@ interface PictureInPictureHook {
interface CarPlayHook {
fun refreshCatalog()
fun refreshNowPlaying()
fun bind(catalog: CarPlayCatalog)
}
// --- NoOp fallbacks (last resort; not default once platform actuals register) ---
// --- NoOp 回退(最后手段;平台 actual 注册后非默认) ---
object NoOpDesktopLyric : DesktopLyricController {
override var enabled: Boolean = false
override fun pipAutoOpen(enable: Boolean): Boolean {
enabled = enable
return true
}
override fun show() = Unit
override fun hide() = Unit
override fun updateLines(line1: String?, line2: String?, currentLine: Int) = Unit
override fun setPlaying(playing: Boolean) = Unit
override fun isOverlayShowing(): Boolean = false
}
object NoOpHomeWidget : HomeWidgetController {
@@ -92,9 +118,6 @@ object NoOpHomeWidget : HomeWidgetController {
artist: String?,
isPlaying: Boolean,
favorite: Boolean,
lyricLine1: String?,
lyricLine2: String?,
currentLine: Int,
coverPath: String?,
playText: String,
) = Unit
@@ -112,11 +135,19 @@ object NoOpUsbMount : UsbMountMonitor {
override fun start() = Unit
override fun stop() = Unit
override fun listMountedVolumes(): List<String> = emptyList()
override fun setOnChanged(listener: (() -> Unit)?) = Unit
}
object NoOpShare : ShareBridge {
override fun shareText(text: String, title: String?) = Unit
override fun shareUrl(url: String, title: String?) = Unit
override fun shareToQq(
title: String,
text: String,
url: String,
imageUrl: String?,
imagePath: String?,
) = Unit
}
object NoOpMediaSession : MediaSessionHook {
@@ -130,6 +161,7 @@ object NoOpAnalytics : AnalyticsHook {
object NoOpCarPlay : CarPlayHook {
override fun refreshCatalog() = Unit
override fun refreshNowPlaying() = Unit
override fun bind(catalog: CarPlayCatalog) = Unit
}
@@ -137,7 +169,7 @@ object NoOpPiP : PictureInPictureHook {
override fun enterPiP() = Unit
}
/** Shared deep-link parsing (no platform deps). */
/** 共享深链解析(无平台依赖)。 */
object DeepLinkParser {
private const val PREFIX = "llmp://"
@@ -159,8 +191,8 @@ object DeepLinkParser {
}
/**
* Singleton registry filled by platform entry (MainActivity / MainViewController).
* Defaults to NoOp until [install] is called.
* 由平台入口(MainActivity / MainViewController)填充的单例注册表。
* 调用 `PlatformServices.install` 前默认为 NoOp。
*/
object PlatformServices {
var desktopLyric: DesktopLyricController = NoOpDesktopLyric
@@ -201,30 +233,30 @@ object PlatformServices {
}
/**
* 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",
"[完成] OSS 元数据导入",
"[完成] 曲库浏览(歌曲/专辑/艺人/收藏/歌单/最近)",
"[完成] 播放队列 + Android MediaSession 服务(单一 ExoPlayer)",
"[完成] 歌词解析 + 滚动 UI",
"[完成] 收藏/歌单/历史 + 同步合并",
"[完成] IP 连接传输 + 扫码(Android)",
"[完成] 数据同步客户端",
"[完成] 设置(主题/HTTP/定时/更新/桌面歌词)",
"[完成] 驾驶模式",
"[完成] SD/USB 列表 + USB 挂载监听",
"[完成] 应用更新检查",
"[完成] 主屏小组件(Android Glance;iOS WidgetKit 源码)",
"[完成] 桌面/悬浮歌词(Android 叠加层;iOS PiP 源码)",
"[完成] CarPlay 目录模型 + iOS SceneDelegate 源码",
"[完成] 分享桥接 + 深链 llmp://",
"[完成] 萌娘百科/立绘 WebView",
"[完成] 每日推送文本",
"[完成] 模块索引/日志",
"[完成] 权限页面",
"[完成] 友盟统计桥接(隐私门控)",
"[完成] iOS AVPlayer + FileSystem.writeBytes",
)
}
@@ -0,0 +1,22 @@
package com.zhushenwudi.lovelivemusicplayer.platform
/**
* 面向 UI 的平台操作(扫描/webview/叠加层/toast/选图)。
*/
expect object PlatformUi {
fun openQrScanner(onResult: (String) -> Unit)
fun openWebView(url: String, title: String? = null)
fun requestOverlayPermission()
fun moveTaskToBack()
fun showToast(message: String)
fun openExternalUrl(url: String)
fun exitApp()
/** 传输/同步页对齐 Flutter `WakelockPlus`,禁止自动息屏。 */
fun setKeepScreenOn(enabled: Boolean)
}
/** 强制按 LTR 显示,避免宿主窗口/抽屉 RTL 把中文标点甩到句首。 */
internal fun String.asLtrDisplay(): String {
if (isEmpty()) return this
return "\u202A$this\u202C"
}
@@ -0,0 +1,235 @@
package com.zhushenwudi.lovelivemusicplayer.platform
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* 应用内 WebView 广告过滤。
* Android:拦截请求 + 页面脚本;iOS:WKContentRuleList + UserScript。
*/
object WebViewAdBlock {
val blockedHostSuffixes: List<String> = listOf(
"doubleclick.net",
"googlesyndication.com",
"googleadservices.com",
"google-analytics.com",
"googletagmanager.com",
"googletagservices.com",
"adservice.google.com",
"pagead2.googlesyndication.com",
"ads.youtube.com",
"cpro.baidu.com",
"pos.baidu.com",
"hm.baidu.com",
"tanx.com",
"alimama.com",
"mmstat.com",
"cnzz.com",
"irs01.com",
"irs01.net",
"admaster.com.cn",
"gdt.qq.com",
"e.qq.com",
"ad.qq.com",
"adsrvr.org",
"adnxs.com",
"criteo.com",
"criteo.net",
"taboola.com",
"outbrain.com",
"media.net",
"a-mo.net",
"moatads.com",
"scorecardresearch.com",
"quantserve.com",
"adform.net",
"adform.com",
"rubiconproject.com",
"pubmatic.com",
"openx.net",
"casalemedia.com",
"contextweb.com",
"lijit.com",
"amazon-adsystem.com",
"fundingchoicesmessages.google.com",
"fondrea.com",
"fundeary.com",
"grfz.laogu.wang",
"laogu.wang",
"intellitxt.com",
"exoclick.com",
"juicyads.com",
"propellerads.com",
"popads.net",
"mgid.com",
"revcontent.com",
"zergnet.com",
"yieldmo.com",
"sharethrough.com",
"spotxchange.com",
"teads.tv",
"smartadserver.com",
"serving-sys.com",
"2mdn.net",
"inmobi.com",
"chartbeat.com",
"hotjar.com",
"clarity.ms",
)
private val cssHideSelectors: List<String> = listOf(
"ins.adsbygoogle",
".adsbygoogle",
".google-auto-placed",
"[id*='google_ads']",
"[class*='google-ad']",
"[data-ad-slot]",
"[data-ad-client]",
"iframe[src*='doubleclick']",
"iframe[src*='googlesyndication']",
"iframe[src*='adservice']",
"iframe[src*='fondrea']",
"iframe[src*='fundeary']",
".fc-ab-root",
)
fun isMoegirlUrl(url: String): Boolean =
url.contains("moegirl.org", ignoreCase = true)
fun isBlockedHost(host: String): Boolean {
val normalized = host.trim().lowercase().removePrefix("www.")
if (normalized.isEmpty()) return false
return blockedHostSuffixes.any { suffix ->
normalized == suffix || normalized.endsWith(".$suffix")
}
}
fun injectionScript(url: String): String =
if (isMoegirlUrl(url)) moegirlCleanupScript() else cssHideScript()
/**
* 萌百广告清理:装一次后每秒自跑。
* 不要在 Kotlin raw string 里写 `\\.`,会变成 JS 里的字面反斜杠。
*/
fun moegirlCleanupScript(): String = """
(function() {
if ((location.host || '').indexOf('moegirl.org') === -1) return;
if (window.__llmpAdblock) {
window.__llmpAdblock();
return;
}
var KEYWORDS = ['广告','推广','推廣','廣告','谷歌广告','百度推广','百度推廣','加载中','ads','promotions'];
function compact(text) {
return (text || '').replace(/\s+/g, '').replace(/[×x✕✖X]/g, '').toLowerCase();
}
function isBadge(el) {
var text = (el.innerText || el.textContent || '').trim();
return text.length <= 16 && KEYWORDS.indexOf(compact(text)) >= 0;
}
function isStop(node) {
if (!node || node === document.body || node === document.documentElement) return true;
var id = node.id || '';
return id === 'moe-article' || id === 'moe-main-container' || id === 'app' || id === 'content';
}
function pickRoot(badge) {
var last = null;
for (var node = badge; node && !isStop(node); node = node.parentElement) {
if (node.classList && node.classList.contains('mw-parser-output')) break;
if (node.classList && (
node.classList.contains('moe-card') ||
node.classList.contains('fc-ab-root') ||
node.classList.contains('adsbygoogle')
)) return node;
var style = getComputedStyle(node);
var box = node.getBoundingClientRect();
if ((style.position === 'fixed' || style.position === 'sticky') &&
box.height >= 36 && box.height < innerHeight * 0.6 &&
box.width >= innerWidth * 0.35) return node;
if (node.querySelector && node.querySelector('img,iframe,video,canvas') &&
box.height >= 64 && box.height <= innerHeight * 0.55 && box.width >= 180) {
last = node;
}
}
return last;
}
function run() {
try {
document.querySelectorAll('.adsbygoogle,ins.adsbygoogle,.fc-ab-root,#mw-data-after-content')
.forEach(function(node) { node.remove(); });
document.querySelectorAll('a,span,div,small,label,em,i,button').forEach(function(el) {
if (!isBadge(el)) return;
var root = pickRoot(el);
if (root && !root.closest('#footer-moegirl,footer.mw-footer')) root.remove();
});
} catch (e) {}
}
window.__llmpAdblock = run;
run();
setInterval(run, 1000);
})();
""".trimIndent()
fun cssHideScript(): String {
val selectors = cssHideSelectors.joinToString(",") { it.replace("'", "\\'") }
return """
(function() {
if (window.__llmpCssAdblock) return;
window.__llmpCssAdblock = true;
var selectors = '$selectors';
function hide() {
try {
selectors.split(',').forEach(function(sel) {
document.querySelectorAll(sel).forEach(function(node) {
node.style.setProperty('display', 'none', 'important');
});
});
} catch (e) {}
}
hide();
setInterval(hide, 1000);
})();
""".trimIndent()
}
fun buildContentBlockerJson(): String {
val rules = blockedHostSuffixes.map { host ->
ContentBlockerRule(
trigger = ContentBlockerTrigger(urlFilter = ".*$host"),
action = ContentBlockerAction(type = "block"),
)
} + ContentBlockerRule(
trigger = ContentBlockerTrigger(urlFilter = ".*"),
action = ContentBlockerAction(
type = "css-display-none",
selector = cssHideSelectors.joinToString(", "),
),
)
return Json.encodeToString(rules)
}
}
@Serializable
private data class ContentBlockerRule(
val trigger: ContentBlockerTrigger,
val action: ContentBlockerAction,
)
@Serializable
private data class ContentBlockerTrigger(
@SerialName("url-filter") val urlFilter: String,
)
@Serializable
private data class ContentBlockerAction(
val type: String,
val selector: String? = null,
)
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.player
package com.zhushenwudi.lovelivemusicplayer.player
enum class LoopMode {
LIST,
@@ -0,0 +1,54 @@
package com.zhushenwudi.lovelivemusicplayer.player
import kotlinx.coroutines.flow.StateFlow
import com.zhushenwudi.lovelivemusicplayer.domain.Music
/**
* 平台播放器抽象,UI 仅依赖此接口。
*/
expect class PlayerController {
val state: StateFlow<PlayerState>
/**
* 从曲库 [com.zhushenwudi.lovelivemusicplayer.db.Music] 解析可播放 URI(相对 musicPath + baseUrl)。
* 不得修改 `Music.musicPath` — 歌词需要相对路径。
*/
var resolvePlaybackUri: ((Music) -> String?)?
/**
* 解析媒体通知 / Now Playing 封面 URI/路径。
* Android:file:// 或 https:// URI。iOS:优先本地绝对路径。
*/
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()
/** 跳转到队列指定索引,已在播放时保持播放状态。 */
fun playAtIndex(index: Int)
fun setLoopMode(mode: LoopMode)
/** 移除单曲,尽可能不重建整个引擎队列。 */
fun removeAt(index: Int)
/** 同步收藏状态到播放队列与当前曲目(小组件/应用内收藏需一致)。 */
fun syncLoveState(musicId: String, isLove: Boolean)
/**
* 将当前歌词行写入系统 Now Playing 标题(CarPlay / 锁屏 / 控制中心)。
* 传 null 或空白则恢复歌名。
*/
fun setNowPlayingLyric(line: String?)
/** 清空播放队列与播放状态。 */
fun clearQueue()
/** 播放本地文件路径或 content URI 字符串。 */
fun playUri(uri: String, title: String? = null)
fun release()
}
@@ -1,6 +1,6 @@
package top.zhushenwudi.llmp.player
package com.zhushenwudi.lovelivemusicplayer.player
import top.zhushenwudi.llmp.domain.Music
import com.zhushenwudi.lovelivemusicplayer.domain.Music
data class PlayerState(
val current: Music? = null,
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.player
package com.zhushenwudi.lovelivemusicplayer.player
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@@ -1,13 +1,13 @@
package top.zhushenwudi.llmp.protocol
package com.zhushenwudi.lovelivemusicplayer.protocol
import top.zhushenwudi.llmp.Const
import top.zhushenwudi.llmp.domain.FtpCmd
import com.zhushenwudi.lovelivemusicplayer.Const
import com.zhushenwudi.lovelivemusicplayer.domain.FtpCmd
/**
* Pure handshake logic for `transVer` negotiation.
* `transVer` 协商的纯握手逻辑。
*
* Mobile initiates with `{cmd:"version", body:"<transVer>"}`;
* peer replies with the same shape. Mismatch → disconnect (no silent continue).
* 移动端发起 `{cmd:"version", body:"<transVer>"}`;
* 对端同形回复。不匹配 → 断开(不静默继续)。
*/
object Handshake {
data class Result(
@@ -1,7 +1,7 @@
package top.zhushenwudi.llmp.protocol
package com.zhushenwudi.lovelivemusicplayer.protocol
/**
* WiFi transfer / sync command words from Docs/03.
* WiFi 传输/同步命令字(来自 Docs/03)。
*/
enum class ProtocolCmd(val wire: String) {
VERSION("version"),
@@ -28,9 +28,9 @@ enum class ProtocolCmd(val wire: String) {
}
enum class TransferChannel {
/** Port 4388 – song file transfer. */
/** 端口 4388 — 歌曲文件传输。 */
MUSIC,
/** Port 4389 – love/menu data sync. */
/** 端口 4389 — 收藏/歌单数据同步。 */
DATA,
}
@@ -1,18 +1,14 @@
package top.zhushenwudi.llmp.settings
package com.zhushenwudi.lovelivemusicplayer.settings
import com.russhwolf.settings.Settings
import com.russhwolf.settings.set
import top.zhushenwudi.llmp.player.LoopMode
import com.zhushenwudi.lovelivemusicplayer.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)
@@ -44,10 +40,23 @@ class AppSettings(private val settings: Settings) {
get() = settings.getBoolean(SettingsKeys.AI_PICTURE, true)
set(value) = settings.set(SettingsKeys.AI_PICTURE, value)
/** 歌曲页正倒序(兼容旧版全局 `SP_SORT_ORDER`)。 */
var sortOrder: String
get() = settings.getString(SettingsKeys.SORT_ORDER, "ASC")
set(value) = settings.set(SettingsKeys.SORT_ORDER, value)
var albumsSortOrder: String
get() = settings.getString(SettingsKeys.SORT_ORDER_ALBUMS, "ASC")
set(value) = settings.set(SettingsKeys.SORT_ORDER_ALBUMS, value)
var loveSortOrder: String
get() = settings.getString(SettingsKeys.SORT_ORDER_LOVE, "ASC")
set(value) = settings.set(SettingsKeys.SORT_ORDER_LOVE, value)
var recentSortOrder: String
get() = settings.getString(SettingsKeys.SORT_ORDER_RECENT, "ASC")
set(value) = settings.set(SettingsKeys.SORT_ORDER_RECENT, value)
var sdPath: String
get() = settings.getString(SettingsKeys.SD_PATH, "")
set(value) = settings.set(SettingsKeys.SD_PATH, value)
@@ -55,12 +64,8 @@ class AppSettings(private val settings: Settings) {
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)
}
/** 规范列表按页独立套用正倒序。 */
fun <T> List<T>.withSortOrder(order: String): List<T> =
if (order == "DESC") reversed() else this
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.settings
package com.zhushenwudi.lovelivemusicplayer.settings
import com.russhwolf.settings.Settings
@@ -1,8 +1,7 @@
package top.zhushenwudi.llmp.settings
package com.zhushenwudi.lovelivemusicplayer.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"
@@ -11,7 +10,8 @@ object SettingsKeys {
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 SORT_ORDER_ALBUMS = "SP_SORT_ORDER_ALBUMS"
const val SORT_ORDER_LOVE = "SP_SORT_ORDER_LOVE"
const val SORT_ORDER_RECENT = "SP_SORT_ORDER_RECENT"
const val SD_PATH = "SP_SD_PATH"
const val ENABLE_BG = "SP_ENABLE_BACKGROUND"
const val BG_PHOTO = "SP_BACKGROUND_PHOTO"
}
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.splash
package com.zhushenwudi.lovelivemusicplayer.splash
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -11,18 +11,18 @@ 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 com.zhushenwudi.lovelivemusicplayer.Const
import com.zhushenwudi.lovelivemusicplayer.db.currentTimeMs
import com.zhushenwudi.lovelivemusicplayer.network.OssApi
import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem
import kotlin.random.Random
/**
* Flutter `SplashPhoto` / `SDUtils` splash cache.
* Cache dir = `{filesRoot}splash/` (NOT under LoveLive), matching Flutter SDUtils.path + splash/.
* 启动图缓存服务。
* 缓存目录 = `{filesRoot}splash/`(不在 LoveLive 子目录下)。
*
* Splash UI only reads local cache; config fetch + missing downloads run after splash
* via [syncInBackground].
* 启动页 UI 仅读本地缓存;配置拉取与缺失下载在启动页之后
* 通过 `syncInBackground` 执行。
*/
class SplashPhotoService(
private val fs: FileSystem,
@@ -35,8 +35,8 @@ class SplashPhotoService(
get() = fs.filesRoot.trimEnd('/', '\\') + "/splash/"
/**
* Random local cached splash path, or null if cache is empty.
* Does **not** hit the network.
* 随机本地缓存启动图路径,缓存为空时返回 null。
* **不**访问网络。
*/
suspend fun pickCachedDisplayPath(): String? = withContext(Dispatchers.Default) {
fs.ensureDir(splashDir)
@@ -44,7 +44,7 @@ class SplashPhotoService(
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() }
@@ -55,7 +55,7 @@ class SplashPhotoService(
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
// forceChoose 仅在文件已缓存后影响下次冷启动
enqueueMissingDownloads(config)
}
@@ -67,7 +67,7 @@ class SplashPhotoService(
}
private fun localPathFor(singer: String, index: Int): String =
// Flat cache names match Flutter download: splash/bg_{singer}_{index}.png
// 扁平缓存文件名:splash/bg_{singer}_{index}.png
splashDir + "bg_${singer}_$index.png"
private suspend fun enqueueMissingDownloads(config: SplashConfig) {
@@ -81,7 +81,7 @@ class SplashPhotoService(
}
}
}
// Also ensure forceChoose target is present when still active
// forceChoose 仍生效时确保目标文件存在
config.forceChoose?.let { force ->
val endTime = (force["endTime"] as? JsonPrimitive)?.longOrNull
if (endTime != null && endTime >= currentTimeMs()) {
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.transfer
package com.zhushenwudi.lovelivemusicplayer.transfer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -10,22 +10,23 @@ 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
import com.zhushenwudi.lovelivemusicplayer.Const
import com.zhushenwudi.lovelivemusicplayer.i18n.I18n
import com.zhushenwudi.lovelivemusicplayer.db.LibraryRepository
import com.zhushenwudi.lovelivemusicplayer.domain.FtpCmd
import com.zhushenwudi.lovelivemusicplayer.domain.Menu
import com.zhushenwudi.lovelivemusicplayer.domain.TransData
import com.zhushenwudi.lovelivemusicplayer.domain.TransMenu
import com.zhushenwudi.lovelivemusicplayer.network.WsClient
import com.zhushenwudi.lovelivemusicplayer.protocol.Handshake
import com.zhushenwudi.lovelivemusicplayer.protocol.ProtocolCmd
import com.zhushenwudi.lovelivemusicplayer.protocol.TransferChannel
data class SyncUiState(
val host: String = "",
val connected: Boolean = false,
val verified: Boolean = false,
val status: String = "未连接",
val status: String = I18n.t("connected"),
val error: String? = null,
)
@@ -41,13 +42,13 @@ class DataSyncService(
fun connect(host: String) {
job?.cancel()
_state.value = SyncUiState(host = host, status = "连接中…")
_state.value = SyncUiState(host = host, status = I18n.t("connecting"))
job = scope.launch {
if (!ws.connect(host, TransferChannel.DATA)) {
_state.update { it.copy(error = "连接失败", status = "失败") }
_state.update { it.copy(error = I18n.t("connect_fail"), status = I18n.t("failed")) }
return@launch
}
_state.update { it.copy(connected = true, status = "握手中…") }
_state.update { it.copy(connected = true, status = I18n.t("handshaking")) }
ws.sendVersion()
ws.incoming().collect { handle(it) }
}
@@ -56,7 +57,7 @@ class DataSyncService(
fun pushPhoneToPc(isCover: Boolean) {
scope.launch {
if (!_state.value.verified) {
_state.update { it.copy(error = "未完成握手") }
_state.update { it.copy(error = I18n.t("handshake_incomplete")) }
return@launch
}
val menus = library.getAllMenus()
@@ -75,7 +76,7 @@ class DataSyncService(
isCover = isCover,
)
ws.send(FtpCmd(ProtocolCmd.PHONE2PC.wire, json.encodeToString(data)))
_state.update { it.copy(status = "已发送 phone2pc") }
_state.update { it.copy(status = I18n.t("sent_phone2pc")) }
}
}
@@ -83,14 +84,14 @@ class DataSyncService(
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") }
_state.update { it.copy(status = I18n.t("requested_pc2phone")) }
}
}
fun stop() {
scope.launch { ws.close() }
job?.cancel()
_state.update { it.copy(connected = false, verified = false, status = "已断开") }
_state.update { it.copy(connected = false, verified = false, status = I18n.t("disconnected")) }
}
private suspend fun handle(cmd: FtpCmd) {
@@ -98,17 +99,17 @@ class DataSyncService(
ProtocolCmd.VERSION -> {
val result = Handshake.verify(cmd, Const.TRANS_VER)
if (!result.ok) {
_state.update { it.copy(error = result.reason, status = "版本不匹配") }
_state.update { it.copy(error = result.reason, status = I18n.t("version_mismatch")) }
ws.close()
return
}
_state.update { it.copy(verified = true, status = "已验证") }
_state.update { it.copy(verified = true, status = I18n.t("verified")) }
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 侧喜欢列表") }
_state.update { it.copy(status = I18n.t("applied_pc_love")) }
ws.close()
}
ProtocolCmd.PC2PHONE -> {
@@ -127,10 +128,10 @@ class DataSyncService(
),
)
}
_state.update { it.copy(status = "已应用 PC 歌单/喜欢") }
_state.update { it.copy(status = I18n.t("applied_pc_menus")) }
}
ProtocolCmd.BACK, ProtocolCmd.STOP, ProtocolCmd.FINISH -> {
_state.update { it.copy(status = "会话结束") }
_state.update { it.copy(status = I18n.t("session_end")) }
ws.close()
}
else -> Unit
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.transfer
package com.zhushenwudi.lovelivemusicplayer.transfer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -7,37 +7,43 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import io.ktor.http.encodeURLPathPart
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
import com.zhushenwudi.lovelivemusicplayer.Const
import com.zhushenwudi.lovelivemusicplayer.i18n.I18n
import com.zhushenwudi.lovelivemusicplayer.db.LibraryRepository
import com.zhushenwudi.lovelivemusicplayer.domain.Album
import com.zhushenwudi.lovelivemusicplayer.domain.DownloadMusic
import com.zhushenwudi.lovelivemusicplayer.domain.FtpCmd
import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.network.OssApi
import com.zhushenwudi.lovelivemusicplayer.network.WsClient
import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem
import com.zhushenwudi.lovelivemusicplayer.protocol.Handshake
import com.zhushenwudi.lovelivemusicplayer.protocol.ProtocolCmd
import com.zhushenwudi.lovelivemusicplayer.protocol.TransferChannel
import com.zhushenwudi.lovelivemusicplayer.util.AudioPathUtils
import com.zhushenwudi.lovelivemusicplayer.util.PathResolver
data class TransferUiState(
val host: String = "",
val connected: Boolean = false,
val verified: Boolean = false,
val httpPort: String = "",
val status: String = "未连接",
val status: String = I18n.t("connected"),
val current: String = "",
val artist: String = "",
val coverUrl: String = "",
val progress: Int = 0,
val total: Int = 0,
val error: String? = null,
val completed: Boolean = false,
)
/**
* PC → phone WiFi transfer client (WS 4388 + HTTP files).
* PC → 手机 WiFi 传输客户端(WS 4388 + HTTP 文件)。
*/
class MusicTransferService(
private val ws: WsClient,
@@ -46,6 +52,7 @@ class MusicTransferService(
private val fs: FileSystem,
private val paths: PathResolver,
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default),
private val onImported: () -> Unit = {},
) {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private val _state = MutableStateFlow(TransferUiState())
@@ -59,17 +66,38 @@ class MusicTransferService(
systemBody = system
}
/** 再次打开快传页时清掉上次完成标记,避免一进页就自动返回。 */
fun resetForOpen() {
if (_state.value.connected) return
_state.value = TransferUiState()
}
fun connect(host: String) {
job?.cancel()
_state.value = TransferUiState(host = host, status = "连接中…")
_state.value = TransferUiState(host = host, status = I18n.t("connecting"))
job = scope.launch {
if (!ws.connect(host, TransferChannel.MUSIC)) {
_state.update { it.copy(status = "连接失败", error = "无法连接 $host:${Const.WS_MUSIC_PORT}") }
_state.update {
it.copy(
status = I18n.t("connect_fail"),
error = I18n.t("cannot_connect_host", "$host:${Const.WS_MUSIC_PORT}"),
)
}
return@launch
}
_state.update { it.copy(connected = true, status = "握手中…") }
_state.update { it.copy(connected = true, status = I18n.t("handshaking")) }
ws.sendVersion()
ws.incoming().collect { cmd -> handle(cmd) }
try {
ws.incoming().collect { cmd -> handle(cmd) }
} finally {
_state.update {
it.copy(
connected = false,
verified = false,
status = if (it.completed) it.status else I18n.t("disconnected"),
)
}
}
}
}
@@ -77,7 +105,7 @@ class MusicTransferService(
scope.launch {
runCatching { ws.send(FtpCmd(ProtocolCmd.FINISH.wire, "")) }
ws.close()
_state.update { it.copy(connected = false, verified = false, status = "已断开") }
_state.update { it.copy(connected = false, verified = false, status = I18n.t("disconnected")) }
}
job?.cancel()
job = null
@@ -89,16 +117,16 @@ class MusicTransferService(
val result = Handshake.verify(cmd, Const.TRANS_VER)
if (!result.ok) {
_state.update {
it.copy(error = result.reason, status = "版本不匹配", verified = false)
it.copy(error = result.reason, status = I18n.t("version_mismatch"), verified = false)
}
ws.close()
return
}
_state.update { it.copy(verified = true, status = "已验证") }
_state.update { it.copy(verified = true, status = I18n.t("verified")) }
ws.send(FtpCmd(ProtocolCmd.SYSTEM.wire, systemBody))
}
ProtocolCmd.PORT -> {
_state.update { it.copy(httpPort = cmd.body, status = "HTTP 端口 ${cmd.body}") }
_state.update { it.copy(httpPort = cmd.body, status = I18n.t("http_port", cmd.body)) }
}
ProtocolCmd.PREPARE -> {
val parts = cmd.body.split(" === ")
@@ -111,14 +139,15 @@ class MusicTransferService(
all
} else {
all.filter { item ->
val localPath = AudioPathUtils.flac2wav(item.musicPath).orEmpty()
val dest = paths.storageRoot() + item.baseUrl.trimStart('/') +
item.musicPath.trimStart('/')
localPath.trimStart('/')
!fs.exists(dest)
}
}
pending = need
_state.update {
it.copy(total = need.size, progress = 0, status = "待传 ${need.size} 首")
it.copy(total = need.size, progress = 0, status = I18n.t("pending_n", need.size))
}
val ids = json.encodeToString(need.map { it.musicUId })
ws.send(FtpCmd(ProtocolCmd.MUSIC_LIST.wire, ids))
@@ -128,7 +157,7 @@ class MusicTransferService(
json.decodeFromString<List<DownloadMusic>>(cmd.body)
}.getOrDefault(emptyList())
_state.update {
it.copy(total = pending.size, progress = 0, status = "就绪 ${pending.size}")
it.copy(total = pending.size, progress = 0, status = I18n.t("ready_n", pending.size))
}
}
ProtocolCmd.DOWNLOAD -> {
@@ -143,7 +172,9 @@ class MusicTransferService(
_state.update {
it.copy(
current = item.musicName,
status = "下载中 ${item.musicName}",
artist = item.artist,
coverUrl = coverHttpUrl(item),
status = I18n.t("downloading_song", item.musicName),
)
}
val ok = downloadOne(item)
@@ -158,11 +189,29 @@ class MusicTransferService(
}
if (isLast) {
ws.send(FtpCmd(ProtocolCmd.FINISH.wire, ""))
_state.update { it.copy(status = "传输完成") }
if (ok) {
_state.update {
it.copy(
status = I18n.t("transfer_done"),
completed = true,
connected = false,
)
}
} else {
_state.update {
it.copy(
status = I18n.t("failed"),
error = I18n.t("failed") + " " + item.musicName,
completed = false,
connected = false,
)
}
}
ws.close()
}
}
ProtocolCmd.STOP, ProtocolCmd.BACK -> {
_state.update { it.copy(status = "对方中断") }
_state.update { it.copy(status = I18n.t("peer_abort"), connected = false) }
ws.close()
}
else -> Unit
@@ -170,24 +219,37 @@ class MusicTransferService(
}
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('/')
val musicRel = AudioPathUtils.flac2wav(item.musicPath).orEmpty()
val musicDest = root + item.baseUrl.trimStart('/') + musicRel.trimStart('/')
return try {
if (item.coverPath.isNotBlank()) {
fs.writeBytes(coverDest, http.downloadBytes(url(item.coverPath)))
runCatching {
fs.writeBytes(coverDest, http.downloadBytes(fileHttpUrl(item.baseUrl, item.coverPath)))
if (fs.exists(coverDest)) {
_state.update { it.copy(coverUrl = "file://$coverDest") }
}
}
}
fs.writeBytes(musicDest, http.downloadBytes(url(item.musicPath)))
http.downloadToFile(fileHttpUrl(item.baseUrl, musicRel), musicDest, fs)
} catch (_: Throwable) {
false
}
}
private fun fileHttpUrl(vararg relativeParts: String): String {
val host = _state.value.host
val port = _state.value.httpPort.ifBlank { "10000" }
val encoded = relativeParts.joinToString("").trimStart('/')
.split('/').filter { it.isNotEmpty() }
.joinToString("/") { it.encodeURLPathPart() }
return "http://$host:$port/$encoded"
}
private fun coverHttpUrl(item: DownloadMusic): String =
fileHttpUrl(item.baseUrl, item.coverPath)
private fun importOne(item: DownloadMusic) {
library.upsertAlbum(
Album(
@@ -209,7 +271,7 @@ class MusicTransferService(
albumId = item.albumUId,
albumName = item.albumName,
coverPath = item.coverPath,
musicPath = item.musicPath,
musicPath = AudioPathUtils.flac2wav(item.musicPath),
time = item.totalTime,
baseUrl = item.baseUrl,
category = item.category,
@@ -219,5 +281,6 @@ class MusicTransferService(
existFile = true,
),
)
onImported()
}
}
@@ -0,0 +1,69 @@
package com.zhushenwudi.lovelivemusicplayer.update
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import com.zhushenwudi.lovelivemusicplayer.Const
import com.zhushenwudi.lovelivemusicplayer.domain.VersionInfo
import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo
import com.zhushenwudi.lovelivemusicplayer.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
}
/**
* 对齐原 Flutter `UpdateLogic.checkUpdate` iOS 分支:
* 请求 App Store lookup,本地版本 < 商店版本则需要更新。
*/
suspend fun checkIos(): Boolean {
val raw = oss.fetchText(Const.APPSTORE_LOOKUP_URL)
val lookup = json.decodeFromString<AppStoreLookup>(raw)
val remote = lookup.results.firstOrNull { it.bundleId == Const.APPSTORE_BUNDLE_ID }
?.version
?: return false
return isOlderVersion(PlatformInfo.appVersion, remote)
}
}
/** `AppUtils.compareVersion(old, latest)`:old < latest 为 true。 */
internal fun isOlderVersion(current: String, remote: String): Boolean {
var i = 0
var j = 0
while (i < current.length || j < remote.length) {
var num1 = 0
while (i < current.length && current[i] != '.') {
val d = current[i] - '0'
if (d !in 0..9) break
num1 = num1 * 10 + d
i++
}
var num2 = 0
while (j < remote.length && remote[j] != '.') {
val d = remote[j] - '0'
if (d !in 0..9) break
num2 = num2 * 10 + d
j++
}
if (num1 != num2) return num1 < num2
i++
j++
}
return false
}
@Serializable
private data class AppStoreLookup(
val results: List<AppStoreApp> = emptyList(),
)
@Serializable
private data class AppStoreApp(
val bundleId: String = "",
val version: String = "",
)
@@ -0,0 +1,19 @@
package com.zhushenwudi.lovelivemusicplayer.util
import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo
/**
* flac 与 wav 扩展名互转。
* iOS 本地文件存/播 `.wav`;云端/HTTP 曲库保持 `.flac`。
*/
object AudioPathUtils {
fun flac2wav(path: String?): String? {
if (path == null) return null
return if (PlatformInfo.isIos) path.replace(".flac", ".wav") else path
}
fun wav2flac(path: String?): String? {
if (path == null) return null
return path.replace(".wav", ".flac")
}
}
@@ -0,0 +1,161 @@
package com.zhushenwudi.lovelivemusicplayer.util
import io.ktor.http.encodeURLPathPart
import com.zhushenwudi.lovelivemusicplayer.domain.Album
import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo
import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem
import com.zhushenwudi.lovelivemusicplayer.settings.AppSettings
class PathResolver(
private val fs: FileSystem,
private val settings: AppSettings,
) {
/**
* 存储根路径。
* iOS Documents UUID 重装后会变,iOS 上不持久化 SP_SD_PATH;
* iOS 上始终使用 `FileSystem.musicRoot`。
*/
fun storageRoot(): String {
if (PlatformInfo.isIos) return fs.musicRoot
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
// iOS 本地路径存为 .wav;也兼容数据库中仍为 .flac 的行。
val path = AudioPathUtils.flac2wav(music.musicPath) ?: return null
return storageRoot() + base.trimStart('/') + path.trimStart('/')
}
/**
* 探测本地音频文件的绝对路径候选(iOS:优先 wav,回退 flac)。
*/
fun absoluteMusicPathCandidates(music: Music): List<String> {
val base = music.baseUrl ?: return emptyList()
val raw = music.musicPath?.trim().orEmpty()
if (raw.isEmpty()) return emptyList()
val root = storageRoot() + base.trimStart('/')
val primary = AudioPathUtils.flac2wav(raw) ?: raw
val candidates = linkedSetOf(root + primary.trimStart('/'))
if (PlatformInfo.isIos) {
val flac = AudioPathUtils.wav2flac(raw) ?: raw
if (flac != primary) {
candidates += root + flac.trimStart('/')
}
if (raw.endsWith(".flac", ignoreCase = true)) {
candidates += root + raw.trimStart('/')
}
}
return candidates.toList()
}
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('/')
}
/**
* 拼接 HTTP 根路径与相对路径,逐段编码。
* 部分主机要求编码空格/Unicode(如 `μ's`、中文、`[]`)。
*/
fun joinHttpUrl(vararg relativeParts: String): String? {
val http = settings.httpUrl.trim()
if (http.isBlank()) return null
val rel = relativeParts.joinToString("").trimStart('/')
if (rel.isBlank()) return null
val encoded = rel.split('/')
.filter { it.isNotEmpty() }
.joinToString("/") { it.encodeURLPathPart() }
return http.trimEnd('/') + "/" + encoded
}
/**
* 歌曲封面 URI:本地 file://、HTTP URL,或 null(UI 显示默认 Logo)。
*/
fun coverDisplayUri(music: Music): String? {
if (music.musicId == null) return null
if (music.existFile == true) {
val path = absoluteCoverPath(music)
if (path != null && fs.exists(path)) {
return "file://$path"
}
}
if (settings.enableHttp) {
return joinHttpUrl(music.baseUrl.orEmpty(), music.coverPath.orEmpty())
}
return null
}
/** 专辑封面 URI。 */
fun coverDisplayUri(album: Album): String? {
val rel = album.coverPath ?: return null
if (album.existFile == true) {
val path = absoluteAlbumCoverPath(album)
if (path != null && fs.exists(path)) {
return "file://$path"
}
}
if (settings.enableHttp) {
return joinHttpUrl(rel)
}
return null
}
fun playbackUri(music: Music): String? {
val id = music.musicId
val enableHttp = settings.enableHttp
val httpBlank = settings.httpUrl.isBlank()
if (enableHttp && !httpBlank) {
val base = music.baseUrl.orEmpty()
// HTTP 曲库为 flac;iOS 本地导入后数据库可能存 wav。
val path = AudioPathUtils.wav2flac(music.musicPath).orEmpty()
val joined = joinHttpUrl(base, path)
if (joined == null) {
AppLog.w(PLAY_TAG, "playbackUri http joinNull id=$id")
}
return joined
}
val candidates = absoluteMusicPathCandidates(music)
val local = candidates.firstOrNull { fs.exists(it) }
if (local != null) {
return "file://$local"
}
val fallback = music.musicPath?.takeIf {
it.startsWith("http") || it.startsWith("asset")
}
if (fallback == null) {
AppLog.w(
PLAY_TAG,
"playbackUri null id=$id enableHttp=$enableHttp httpBlank=$httpBlank " +
"candidates=$candidates root=${storageRoot()}",
)
}
return fallback
}
fun refreshExistFile(music: Music): Music {
val exists = absoluteMusicPathCandidates(music).any { fs.exists(it) }
// 磁盘存在时,将 iOS 本地扩展名持久化到数据库(flac→wav)。
val pathForDb = when {
!PlatformInfo.isIos -> music.musicPath
else -> AudioPathUtils.flac2wav(music.musicPath)
}
return music.copy(musicPath = pathForDb, existFile = exists)
}
private companion object {
const val PLAY_TAG = "LLMP-Play"
}
}
@@ -0,0 +1,4 @@
package com.zhushenwudi.lovelivemusicplayer.util
/** SHA-512 hex(小写),对齐 Dart `sha512.convert(...).toString()`。 */
expect fun sha512Hex(text: String): String
@@ -1,45 +0,0 @@
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"
}
@@ -1,65 +0,0 @@
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'))
}
}
}
}
@@ -1,137 +0,0 @@
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"),
)
}
}
@@ -1,31 +0,0 @@
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
@@ -1,16 +0,0 @@
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()
}
@@ -1,45 +0,0 @@
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()
}
@@ -1,16 +0,0 @@
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
}
}
@@ -1,91 +0,0 @@
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))
}
}
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.lyric
package com.zhushenwudi.lovelivemusicplayer.lyric
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.platform
package com.zhushenwudi.lovelivemusicplayer.platform
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -1,7 +1,7 @@
package top.zhushenwudi.llmp.protocol
package com.zhushenwudi.lovelivemusicplayer.protocol
import top.zhushenwudi.llmp.Const
import top.zhushenwudi.llmp.domain.FtpCmd
import com.zhushenwudi.lovelivemusicplayer.Const
import com.zhushenwudi.lovelivemusicplayer.domain.FtpCmd
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.db
package com.zhushenwudi.lovelivemusicplayer.db
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.native.NativeSqliteDriver
@@ -0,0 +1,21 @@
package com.zhushenwudi.lovelivemusicplayer.db
import platform.Foundation.NSDate
import platform.Foundation.NSDateFormatter
import platform.Foundation.dateWithTimeIntervalSince1970
import platform.Foundation.timeIntervalSince1970
actual fun currentTimeMs(): Long =
(NSDate().timeIntervalSince1970 * 1000.0).toLong()
actual fun formatLocalHm(epochMs: Long): String {
val date = NSDate.dateWithTimeIntervalSince1970(epochMs / 1000.0)
val formatter = NSDateFormatter().apply { dateFormat = "HH:mm" }
return formatter.stringFromDate(date)
}
actual fun formatLocalHms(epochMs: Long): String {
val date = NSDate.dateWithTimeIntervalSince1970(epochMs / 1000.0)
val formatter = NSDateFormatter().apply { dateFormat = "HH:mm:ss" }
return formatter.stringFromDate(date)
}
@@ -0,0 +1,9 @@
package com.zhushenwudi.lovelivemusicplayer.i18n
import platform.Foundation.NSLocale
import platform.Foundation.preferredLanguages
actual fun detectSystemLang(): AppLang {
val first = (NSLocale.preferredLanguages.firstOrNull() as? String) ?: "zh"
return langFromTag(first)
}
@@ -0,0 +1,12 @@
package com.zhushenwudi.lovelivemusicplayer.lyric
import platform.Foundation.NSBundle
actual object PlatformInfo {
// iOS 在 EULA 未接受前可能限制日文/罗马音歌词。
actual val allowEulaLyric: Boolean = true
actual val isIos: Boolean = true
actual val appVersion: String =
NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String
?: ""
}
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.network
package com.zhushenwudi.lovelivemusicplayer.network
import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin
@@ -1,22 +1,27 @@
package top.zhushenwudi.llmp.platform
package com.zhushenwudi.lovelivemusicplayer.platform
actual object AppLog {
actual fun d(tag: String, message: String) {
AppLogStore.append("D", tag, message)
println("D/$tag: $message")
}
actual fun i(tag: String, message: String) {
AppLogStore.append("I", tag, message)
println("I/$tag: $message")
}
actual fun w(tag: String, message: String) {
AppLogStore.append("W", tag, message)
println("W/$tag: $message")
}
actual fun e(tag: String, message: String, throwable: Throwable?) {
if (throwable != null) {
AppLogStore.append("E", tag, "$message | ${throwable.message}")
println("E/$tag: $message\n${throwable.stackTraceToString()}")
} else {
AppLogStore.append("E", tag, message)
println("E/$tag: $message")
}
}
@@ -1,5 +1,6 @@
package top.zhushenwudi.llmp.platform
package com.zhushenwudi.lovelivemusicplayer.platform
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
@@ -8,8 +9,11 @@ import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSSearchPathForDirectoriesInDomains
import platform.Foundation.NSUserDomainMask
import platform.Foundation.NSFileHandle
import platform.Foundation.create
import platform.Foundation.writeToFile
@OptIn(ExperimentalForeignApi::class)
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
actual class FileSystem {
actual val filesRoot: String
get() {
@@ -51,6 +55,38 @@ actual class FileSystem {
}
}
actual fun writeBegin(absolutePath: String): Boolean {
val parent = absolutePath.substringBeforeLast('/', missingDelimiterValue = "")
if (parent.isNotEmpty()) {
ensureDir(parent)
}
return NSFileManager.defaultManager.createFileAtPath(
absolutePath,
contents = null,
attributes = null,
)
}
actual fun writeAppend(absolutePath: String, bytes: ByteArray, offset: Int, length: Int): Boolean {
if (length <= 0) return true
val handle = NSFileHandle.fileHandleForWritingAtPath(absolutePath) ?: return false
return try {
handle.seekToEndOfFile()
bytes.usePinned { pinned ->
val data = NSData.create(
bytes = pinned.addressOf(offset),
length = length.toULong(),
)
handle.writeData(data)
}
true
} catch (_: Throwable) {
false
} finally {
handle.closeFile()
}
}
actual fun listFiles(absoluteDir: String): List<String> {
val fm = NSFileManager.defaultManager
val contents = fm.contentsOfDirectoryAtPath(absoluteDir, error = null) ?: return emptyList()
@@ -0,0 +1,369 @@
package com.zhushenwudi.lovelivemusicplayer.platform
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import platform.Foundation.NSData
import platform.Foundation.NSFileManager
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSUserDefaults
import platform.Foundation.create
import platform.Foundation.writeToFile
/** 与 WidgetKit 扩展及 Swift 宿主共享的 App Group。 */
const val IOS_APP_GROUP = "group.com.zhushenwudi.lovelivemusicplayer"
const val IOS_NOTIFICATION_DESKTOP_LYRIC = "llmpDesktopLyricUpdate"
const val IOS_NOTIFICATION_PIP = "llmpPiPRequest"
const val IOS_NOTIFICATION_SHARE = "llmpShareRequest"
const val IOS_NOTIFICATION_DEEP_LINK = "llmpDeepLink"
const val IOS_NOTIFICATION_WIDGET = "llmpWidgetAction"
const val IOS_NOTIFICATION_CARPLAY = "llmpCarPlayAction"
const val IOS_NOTIFICATION_OPEN_WEBVIEW = "llmpOpenWebView"
@OptIn(ExperimentalForeignApi::class)
object WidgetDataWriter {
private val defaults: NSUserDefaults
get() = NSUserDefaults(suiteName = IOS_APP_GROUP)
fun writeString(key: String, value: String?) {
defaults.setObject(value, forKey = key)
defaults.synchronize()
}
fun writeBool(key: String, value: Boolean) {
defaults.setBool(value, forKey = key)
defaults.synchronize()
}
fun writeInt(key: String, value: Int) {
defaults.setInteger(value.toLong(), forKey = key)
defaults.synchronize()
}
fun readString(key: String): String? = defaults.stringForKey(key)
fun readBool(key: String): Boolean = defaults.boolForKey(key)
@OptIn(BetaInteropApi::class)
fun writeFile(name: String, bytes: ByteArray) {
val container = NSFileManager.defaultManager
.containerURLForSecurityApplicationGroupIdentifier(IOS_APP_GROUP)
?: return
val path = container.URLByAppendingPathComponent(name)?.path ?: return
if (bytes.isEmpty()) return
bytes.usePinned { pinned ->
val data = NSData.create(
bytes = pinned.addressOf(0),
length = bytes.size.toULong(),
)
data.writeToFile(path, atomically = true)
}
}
fun writeWidgetState(
songName: String,
artist: String,
isPlaying: Boolean,
favorite: Boolean,
playText: String,
) {
val shared = defaults
shared.setObject(songName, forKey = "songName")
shared.setObject(artist, forKey = "songArtist")
shared.setBool(favorite, forKey = "songFavorite")
shared.setBool(isPlaying, forKey = "isPlaying")
shared.setObject(playText, forKey = "playText")
if (songName.isNotBlank() || isPlaying) {
shared.setBool(false, forKey = "isShutdown")
}
shared.synchronize()
}
fun writeShareImage(fromPath: String?) {
if (fromPath.isNullOrBlank()) {
clearShareImage()
return
}
post("llmpWidgetSaveCover", mapOf("path" to fromPath))
}
fun clearShareImage() {
val container = NSFileManager.defaultManager
.containerURLForSecurityApplicationGroupIdentifier(IOS_APP_GROUP)
?: return
val dest = container.URLByAppendingPathComponent("sharedImage.png")?.path ?: return
if (NSFileManager.defaultManager.fileExistsAtPath(dest)) {
NSFileManager.defaultManager.removeItemAtPath(dest, error = null)
}
}
fun reloadWidgets() {
// Swift 宿主/Widget 扩展监听此值并调用 WidgetCenter.reloadAllTimelines()。
post("llmpReloadWidgets")
}
fun post(name: String, userInfo: Map<String, String> = emptyMap()) {
val info = userInfo.mapKeys { it.key as Any? }.mapValues { it.value as Any? }
NSNotificationCenter.defaultCenter.postNotificationName(
aName = name,
`object` = null,
userInfo = info,
)
}
}
class IosDesktopLyricController : DesktopLyricController {
override var enabled: Boolean = false
set(value) {
field = value
WidgetDataWriter.writeBool("desktopLyricEnabled", value)
WidgetDataWriter.post(
IOS_NOTIFICATION_PIP,
mapOf("action" to "pipAutoOpen", "enabled" to value.toString()),
)
}
override fun pipAutoOpen(enable: Boolean): Boolean {
enabled = enable
return true
}
override fun show() {
WidgetDataWriter.post(IOS_NOTIFICATION_PIP, mapOf("action" to "show"))
}
override fun hide() {
WidgetDataWriter.post(IOS_NOTIFICATION_PIP, mapOf("action" to "hide"))
}
override fun updateLines(line1: String?, line2: String?, currentLine: Int) {
WidgetDataWriter.writeString("curJpLrc", line1)
WidgetDataWriter.writeString("nextJpLrc", line2)
WidgetDataWriter.writeInt("pipCurrentLine", currentLine)
WidgetDataWriter.post(
IOS_NOTIFICATION_DESKTOP_LYRIC,
mapOf(
"line1" to (line1 ?: ""),
"line2" to (line2 ?: ""),
"currentLine" to currentLine.toString(),
"line1Null" to (line1 == null).toString(),
"line2Null" to (line2 == null).toString(),
),
)
}
override fun setPlaying(playing: Boolean) {
WidgetDataWriter.writeBool("pipIsPlaying", playing)
WidgetDataWriter.post(
IOS_NOTIFICATION_PIP,
mapOf("action" to "playing", "playing" to playing.toString()),
)
}
override fun isOverlayShowing(): Boolean = false
}
class IosHomeWidgetController : HomeWidgetController {
private var lastContent: WidgetContent? = null
private var lastCoverPath: String? = null
override fun update(
songName: String?,
artist: String?,
isPlaying: Boolean,
favorite: Boolean,
coverPath: String?,
playText: String,
) {
val content = WidgetContent(
songName = songName.orEmpty(),
artist = artist.orEmpty(),
isPlaying = isPlaying,
favorite = favorite,
playText = playText,
)
val contentChanged = content != lastContent
val newCoverPath = coverPath?.takeIf { it.isNotBlank() && it != lastCoverPath }
val coverChanged = newCoverPath != null
if (!contentChanged && !coverChanged) return
if (contentChanged) {
lastContent = content
WidgetDataWriter.writeWidgetState(
songName = content.songName,
artist = content.artist,
isPlaying = content.isPlaying,
favorite = content.favorite,
playText = content.playText,
)
WidgetDataWriter.reloadWidgets()
}
if (newCoverPath != null) {
lastCoverPath = newCoverPath
WidgetDataWriter.writeShareImage(newCoverPath)
}
}
override fun setShutdown(shutdown: Boolean) {
lastContent = null
WidgetDataWriter.writeBool("isShutdown", shutdown)
if (shutdown) {
lastCoverPath = null
WidgetDataWriter.writeBool("isPlaying", false)
WidgetDataWriter.clearShareImage()
}
WidgetDataWriter.reloadWidgets()
}
override fun refresh() {
WidgetDataWriter.reloadWidgets()
}
private data class WidgetContent(
val songName: String,
val artist: String,
val isPlaying: Boolean,
val favorite: Boolean,
val playText: String,
)
}
class IosShareBridge : ShareBridge {
override fun shareText(text: String, title: String?) {
WidgetDataWriter.writeString("pendingShareText", text)
WidgetDataWriter.writeString("pendingShareTitle", title ?: "")
WidgetDataWriter.writeString("pendingShareUrl", "")
WidgetDataWriter.post(
IOS_NOTIFICATION_SHARE,
mapOf("type" to "text", "text" to text, "title" to (title ?: "")),
)
}
override fun shareUrl(url: String, title: String?) {
WidgetDataWriter.writeString("pendingShareUrl", url)
WidgetDataWriter.writeString("pendingShareTitle", title ?: "")
WidgetDataWriter.writeString("pendingShareText", "")
WidgetDataWriter.post(
IOS_NOTIFICATION_SHARE,
mapOf("type" to "url", "url" to url, "title" to (title ?: "")),
)
}
override fun shareToQq(
title: String,
text: String,
url: String,
imageUrl: String?,
imagePath: String?,
) {
WidgetDataWriter.post(
IOS_NOTIFICATION_SHARE,
mapOf(
"type" to "qq",
"title" to title,
"text" to text,
"url" to url,
"imageUrl" to (imageUrl ?: ""),
"imagePath" to (imagePath ?: ""),
),
)
}
}
class IosDeepLinkHandler : DeepLinkHandler {
var lastPayload: DeepLinkPayload? = null
private set
override fun parse(url: String): DeepLinkPayload? = DeepLinkParser.parse(url)
override fun handle(url: String) {
val payload = parse(url) ?: return
lastPayload = payload
WidgetDataWriter.writeString("pendingDeepLink", url)
WidgetDataWriter.post(IOS_NOTIFICATION_DEEP_LINK, mapOf("url" to url))
}
}
class IosCarPlayHook : CarPlayHook {
private var catalog: CarPlayCatalog = CarPlayCatalog()
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
override fun bind(catalog: CarPlayCatalog) {
this.catalog = catalog
}
override fun refreshCatalog() {
writeNowPlaying()
WidgetDataWriter.writeFile(
CARPLAY_CATALOG_FILE,
json.encodeToString(catalog.snapshot()).encodeToByteArray(),
)
WidgetDataWriter.post(IOS_NOTIFICATION_CARPLAY, nowPlayingUserInfo("refresh"))
}
override fun refreshNowPlaying() {
writeNowPlaying()
WidgetDataWriter.writeFile(
CARPLAY_RECENT_FILE,
json.encodeToString(catalog.recentSongs).encodeToByteArray(),
)
WidgetDataWriter.post(IOS_NOTIFICATION_CARPLAY, nowPlayingUserInfo("nowPlaying"))
}
private fun nowPlayingUserInfo(action: String) = mapOf(
"action" to action,
"isFavorite" to catalog.isFavorite.toString(),
"loopMode" to catalog.loopMode,
"isPlaying" to catalog.isPlaying.toString(),
)
private fun writeNowPlaying() {
WidgetDataWriter.writeString("carplay_nowPlaying", catalog.nowPlayingTitle)
WidgetDataWriter.writeString("carplay_nowPlayingCover", catalog.nowPlayingCover ?: "")
WidgetDataWriter.writeString("carplay_nowPlayingId", catalog.nowPlayingId ?: "")
WidgetDataWriter.writeBool("carplay_isPlaying", catalog.isPlaying)
WidgetDataWriter.writeBool("carplay_isFavorite", catalog.isFavorite)
WidgetDataWriter.writeString("carplay_loopMode", catalog.loopMode)
}
companion object {
const val CARPLAY_CATALOG_FILE = "carplay_catalog.json"
const val CARPLAY_RECENT_FILE = "carplay_recent.json"
}
}
class IosPiPHook : PictureInPictureHook {
override fun enterPiP() {
WidgetDataWriter.post(IOS_NOTIFICATION_PIP, mapOf("action" to "enter"))
}
}
class IosMediaSessionBridge : MediaSessionHook {
override fun updateSession(title: String?, artist: String?, isPlaying: Boolean) {
WidgetDataWriter.writeString("songName", title ?: "")
WidgetDataWriter.writeString("songArtist", artist ?: "")
WidgetDataWriter.writeBool("isPlaying", isPlaying)
}
}
fun installIosPlatformServices() {
PlatformServices.install(
desktopLyric = IosDesktopLyricController(),
homeWidget = IosHomeWidgetController(),
deepLink = IosDeepLinkHandler(),
usbMount = NoOpUsbMount,
share = IosShareBridge(),
mediaSession = IosMediaSessionBridge(),
analytics = NoOpAnalytics,
carPlay = IosCarPlayHook(),
pip = IosPiPHook(),
)
}
@@ -0,0 +1,61 @@
package com.zhushenwudi.lovelivemusicplayer.platform
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSURL
import platform.Foundation.NSURLComponents
import platform.UIKit.UIApplication
import platform.darwin.dispatch_async
import platform.darwin.dispatch_get_main_queue
import kotlin.system.exitProcess
@OptIn(ExperimentalForeignApi::class)
actual object PlatformUi {
actual fun openQrScanner(onResult: (String) -> Unit) {
// iOS 相机扫码由 Swift 承载;common UI 仍支持 IP 粘贴。
onResult("")
}
actual fun openWebView(url: String, title: String?) {
if (url.isBlank()) return
WidgetDataWriter.post(
IOS_NOTIFICATION_OPEN_WEBVIEW,
mapOf("url" to url, "title" to (title ?: "")),
)
}
actual fun requestOverlayPermission() = Unit
actual fun moveTaskToBack() = Unit
actual fun showToast(message: String) {
if (message.isBlank()) return
WidgetDataWriter.post("llmpShowToast", mapOf("message" to message.asLtrDisplay()))
}
actual fun openExternalUrl(url: String) {
val nsUrl = parseUrl(url) ?: return
dispatch_async(dispatch_get_main_queue()) {
UIApplication.sharedApplication.openURL(
nsUrl,
options = emptyMap<Any?, Any>(),
completionHandler = null,
)
}
}
actual fun exitApp() {
exitProcess(0)
}
actual fun setKeepScreenOn(enabled: Boolean) {
dispatch_async(dispatch_get_main_queue()) {
UIApplication.sharedApplication.idleTimerDisabled = enabled
}
}
}
private fun parseUrl(url: String): NSURL? {
if (url.isBlank()) return null
return NSURL.URLWithString(url)
?: NSURLComponents(string = url, encodingInvalidCharacters = true).URL
}
@@ -0,0 +1,191 @@
package com.zhushenwudi.lovelivemusicplayer.player
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.ObjCSignatureOverride
import platform.AVFoundation.AVAssetResourceLoader
import platform.AVFoundation.AVAssetResourceLoaderDelegateProtocol
import platform.AVFoundation.AVAssetResourceLoadingRequest
import platform.Foundation.NSError
import platform.Foundation.NSHTTPURLResponse
import platform.Foundation.NSLock
import platform.Foundation.NSMutableURLRequest
import platform.Foundation.NSNumber
import platform.Foundation.NSURL
import platform.Foundation.NSURLRequestReloadIgnoringLocalCacheData
import platform.Foundation.NSURLSession
import platform.Foundation.NSURLSessionTask
import platform.Foundation.dataTaskWithRequest
import platform.Foundation.setHTTPMethod
import platform.Foundation.setValue
import platform.darwin.NSObject
import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
private const val LOADER_TAG = "LLMP-Player"
private const val LOADER_UA = "LoveLiveMusicPlayer/0.2.0 (KMP; Android/iOS)"
internal object HttpAudioResourceLoaderKeys {
const val SCHEME_HTTPS = "llmphttps"
const val SCHEME_HTTP = "llmphttp"
fun toCustomUrl(httpUrl: String): String? = when {
httpUrl.startsWith("https://") -> SCHEME_HTTPS + "://" + httpUrl.removePrefix("https://")
httpUrl.startsWith("http://") -> SCHEME_HTTP + "://" + httpUrl.removePrefix("http://")
else -> null
}
fun toRealHttpUrl(custom: NSURL?): NSURL? {
val abs = custom?.absoluteString ?: return null
val real = when {
abs.startsWith("$SCHEME_HTTPS://") ->
"https://" + abs.removePrefix("$SCHEME_HTTPS://")
abs.startsWith("$SCHEME_HTTP://") ->
"http://" + abs.removePrefix("$SCHEME_HTTP://")
else -> return null
}
return NSURL.URLWithString(real)
}
fun parseTotalLength(http: NSHTTPURLResponse?): Long? {
if (http == null) return null
val headers = http.allHeaderFields
val contentRange = headers["Content-Range"] as? String
?: headers["content-range"] as? String
if (contentRange != null) {
val total = contentRange.substringAfterLast('/', missingDelimiterValue = "")
.trim()
.toLongOrNull()
if (total != null && total > 0) return total
}
val contentLength = headers["Content-Length"] as? String
?: headers["content-length"] as? String
return contentLength?.toLongOrNull()?.takeIf { it > 0 }
?: (headers["Content-Length"] as? NSNumber)?.longLongValue?.takeIf { it > 0 }
}
fun simpleError(message: String): NSError =
NSError.errorWithDomain(
domain = "LLMPHttpAudio",
code = -1,
userInfo = mapOf("NSLocalizedDescription" to message),
)
}
/**
* 通过自定义 URL scheme 为 AVPlayer 供流,以便:
* - 自行经 URLSession(GET)跟随 senluopan → CDN 重定向
* - 忽略签名对象 URL 上的 Content-Disposition:attachment / HEAD-403
* - 声明 AVPlayer 接受的 byte-range + audio/flac 内容信息
*
* 资源 URL 须使用 `HttpAudioResourceLoaderKeys.SCHEME_HTTPS` / SCHEME_HTTP。
*/
@OptIn(ExperimentalForeignApi::class)
class HttpAudioResourceLoader : NSObject(), AVAssetResourceLoaderDelegateProtocol {
private val lock = NSLock()
private val tasks = mutableMapOf<Int, NSURLSessionTask>()
@ObjCSignatureOverride
override fun resourceLoader(
resourceLoader: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource: AVAssetResourceLoadingRequest,
): Boolean {
val loadingRequest = shouldWaitForLoadingOfRequestedResource
val customUrl = loadingRequest.request.URL
val realUrl = HttpAudioResourceLoaderKeys.toRealHttpUrl(customUrl)
if (realUrl == null) {
AppLog.e(LOADER_TAG, "resourceLoader bad url=${customUrl?.absoluteString}")
loadingRequest.finishLoadingWithError(HttpAudioResourceLoaderKeys.simpleError("bad custom url"))
return false
}
fulfill(loadingRequest, realUrl)
return true
}
@ObjCSignatureOverride
override fun resourceLoader(
resourceLoader: AVAssetResourceLoader,
didCancelLoadingRequest: AVAssetResourceLoadingRequest,
) {
val key = didCancelLoadingRequest.hashCode()
lock.lock()
val task = tasks.remove(key)
lock.unlock()
task?.cancel()
}
private fun fulfill(loadingRequest: AVAssetResourceLoadingRequest, realUrl: NSURL) {
val dataRequest = loadingRequest.dataRequest
val infoRequest = loadingRequest.contentInformationRequest
val offset: Long
val length: Long
val toEnd: Boolean
if (dataRequest != null) {
offset = dataRequest.requestedOffset
toEnd = dataRequest.requestsAllDataToEndOfResource
length = dataRequest.requestedLength
} else {
offset = 0L
length = 2L
toEnd = false
}
val endInclusive = when {
toEnd || length <= 0L || length >= Int.MAX_VALUE.toLong() - 8 -> null
else -> offset + length - 1
}
val rangeValue = if (endInclusive == null) {
"bytes=$offset-"
} else {
"bytes=$offset-$endInclusive"
}
val request = NSMutableURLRequest.requestWithURL(realUrl)
request.setHTTPMethod("GET")
request.setCachePolicy(NSURLRequestReloadIgnoringLocalCacheData)
request.setValue(LOADER_UA, forHTTPHeaderField = "User-Agent")
request.setValue("*/*", forHTTPHeaderField = "Accept")
request.setValue(rangeValue, forHTTPHeaderField = "Range")
val key = loadingRequest.hashCode()
val task =
NSURLSession.sharedSession.dataTaskWithRequest(request) { data, response, error ->
lock.lock()
tasks.remove(key)
lock.unlock()
if (error != null) {
AppLog.e(
LOADER_TAG,
"resourceLoader net fail msg=${error.localizedDescription}"
)
loadingRequest.finishLoadingWithError(error)
return@dataTaskWithRequest
}
val http = response as? NSHTTPURLResponse
val status = http?.statusCode?.toInt() ?: -1
if (status != 200 && status != 206) {
AppLog.e(LOADER_TAG, "resourceLoader bad status=$status range=$rangeValue")
loadingRequest.finishLoadingWithError(HttpAudioResourceLoaderKeys.simpleError("HTTP $status"))
return@dataTaskWithRequest
}
if (infoRequest != null) {
val total = HttpAudioResourceLoaderKeys.parseTotalLength(http)
?: data?.length?.toLong()
?: 0L
infoRequest.byteRangeAccessSupported = true
infoRequest.contentLength = total
infoRequest.contentType = "public.flac-audio"
}
if (dataRequest != null && data != null) {
dataRequest.respondWithData(data)
}
loadingRequest.finishLoading()
}
lock.lock()
tasks[key] = task
lock.unlock()
task.resume()
}
}
@@ -0,0 +1,921 @@
package com.zhushenwudi.lovelivemusicplayer.player
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import platform.AVFAudio.AVAudioSession
import platform.AVFAudio.AVAudioSessionCategoryPlayback
import platform.AVFAudio.AVAudioSessionRouteChangeNotification
import platform.AVFAudio.AVAudioSessionRouteChangeReasonKey
import platform.AVFAudio.AVAudioSessionRouteChangeReasonOldDeviceUnavailable
import platform.AVFAudio.setActive
import platform.AVFoundation.AVPlayer
import platform.AVFoundation.AVPlayerItem
import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification
import platform.AVFoundation.AVPlayerItemFailedToPlayToEndTimeErrorKey
import platform.AVFoundation.AVPlayerItemFailedToPlayToEndTimeNotification
import platform.AVFoundation.AVPlayerItemStatusFailed
import platform.AVFoundation.AVPlayerItemStatusReadyToPlay
import platform.AVFoundation.AVURLAsset
import platform.AVFoundation.currentItem
import platform.AVFoundation.currentTime
import platform.AVFoundation.duration
import platform.AVFoundation.pause
import platform.AVFoundation.play
import platform.AVFoundation.rate
import platform.AVFoundation.replaceCurrentItemWithPlayerItem
import platform.AVFoundation.resourceLoader
import platform.AVFoundation.seekToTime
import platform.CoreMedia.CMTimeGetSeconds
import platform.CoreMedia.CMTimeMake
import platform.Foundation.NSBundle
import platform.Foundation.NSError
import platform.Foundation.NSMutableURLRequest
import platform.Foundation.NSNotification
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSNumber
import platform.Foundation.NSURL
import platform.Foundation.NSURLSession
import platform.Foundation.dataTaskWithRequest
import platform.Foundation.setValue
import platform.MediaPlayer.MPChangePlaybackPositionCommandEvent
import platform.MediaPlayer.MPMediaItemArtwork
import platform.MediaPlayer.MPMediaItemPropertyAlbumTitle
import platform.MediaPlayer.MPMediaItemPropertyArtist
import platform.MediaPlayer.MPMediaItemPropertyArtwork
import platform.MediaPlayer.MPMediaItemPropertyPlaybackDuration
import platform.MediaPlayer.MPMediaItemPropertyTitle
import platform.MediaPlayer.MPNowPlayingInfoCenter
import platform.MediaPlayer.MPNowPlayingInfoPropertyDefaultPlaybackRate
import platform.MediaPlayer.MPNowPlayingInfoPropertyElapsedPlaybackTime
import platform.MediaPlayer.MPNowPlayingInfoPropertyPlaybackRate
import platform.MediaPlayer.MPRemoteCommandCenter
import platform.MediaPlayer.MPRemoteCommandEvent
import platform.MediaPlayer.MPRemoteCommandHandlerStatus
import platform.MediaPlayer.MPRemoteCommandHandlerStatusCommandFailed
import platform.MediaPlayer.MPRemoteCommandHandlerStatusSuccess
import platform.MediaPlayer.MPSeekCommandEvent
import platform.UIKit.UIImage
import platform.darwin.dispatch_async
import platform.darwin.dispatch_get_main_queue
import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeSource
@OptIn(ExperimentalForeignApi::class)
actual class PlayerController {
private val player = AVPlayer()
private val scope = CoroutineScope(Dispatchers.Main.immediate)
private var positionJob: Job? = null
private var itemWatchJob: Job? = null
private var scrubJob: Job? = null
private var endObserver: Any? = null
private var failObserver: Any? = null
private var routeObserver: Any? = null
private val _state = MutableStateFlow(PlayerState())
actual val state: StateFlow<PlayerState> = _state.asStateFlow()
actual var resolvePlaybackUri: ((Music) -> String?)? = null
actual var resolveCoverUri: ((Music) -> String?)? = null
private var queue: List<Music> = emptyList()
private var loopMode: LoopMode = LoopMode.LIST
private var cachedArtworkMusicId: String? = null
private var cachedArtwork: MPMediaItemArtwork? = null
/** 避免同一 musicId 重复下载 HTTP 封面。 */
private var coverFetchingMusicId: String? = null
/** 实际交给 AVPlayer 的 URI(http/file),用于 play() 诊断。 */
private var loadedUri: String? = null
/** HTTP ResourceLoader 强引用(AVURLAsset delegate 为弱引用)。 */
private var httpResourceLoader: HttpAudioResourceLoader? = null
/** 本控制器创建的音乐 item,用于区分桌面歌词 dummy 视频的通知。 */
private var musicItem: AVPlayerItem? = null
private var lastEndMark: TimeSource.Monotonic.ValueTimeMark? = null
private var audioReady = false
/** CarPlay / 锁屏 Now Playing 标题用的当前歌词;null 表示显示歌名。 */
private var nowPlayingLyric: String? = null
init {
// 延迟到首次 play() 再设置 AVAudioSession / 远程命令。
// 在 Compose 首次布局时调用 setCategory 会在模拟器上中止
// (HAL 插件 / VolumeButtonMonitor)并抛出未捕获的 Kotlin 异常。
observePlaybackEnd()
observePlaybackFailed()
observeAudioRouteChanges()
}
private fun ensureAudioReady() {
if (audioReady) return
audioReady = true
runCatching {
val session = AVAudioSession.sharedInstance()
session.setCategory(AVAudioSessionCategoryPlayback, error = null)
session.setActive(true, error = null)
}.onFailure { AppLog.e(TAG, "ensureAudioReady session fail", it) }
runCatching { setupRemoteCommands() }
.onFailure { AppLog.e(TAG, "ensureAudioReady remoteCommands fail", it) }
}
private fun setupRemoteCommands() {
val center = MPRemoteCommandCenter.sharedCommandCenter()
center.playCommand.setEnabled(true)
center.pauseCommand.setEnabled(true)
center.togglePlayPauseCommand.setEnabled(true)
center.nextTrackCommand.setEnabled(true)
center.previousTrackCommand.setEnabled(true)
center.changePlaybackPositionCommand.setEnabled(true)
center.seekForwardCommand.setEnabled(true)
center.seekBackwardCommand.setEnabled(true)
center.playCommand.addTargetWithHandler { _ ->
play()
MPRemoteCommandHandlerStatusSuccess
}
center.pauseCommand.addTargetWithHandler { _ ->
pause()
MPRemoteCommandHandlerStatusSuccess
}
center.togglePlayPauseCommand.addTargetWithHandler { _ ->
toggle()
MPRemoteCommandHandlerStatusSuccess
}
center.nextTrackCommand.addTargetWithHandler { _ ->
next()
MPRemoteCommandHandlerStatusSuccess
}
center.previousTrackCommand.addTargetWithHandler { _ ->
previous()
MPRemoteCommandHandlerStatusSuccess
}
center.changePlaybackPositionCommand.addTargetWithHandler { event ->
val seconds = playbackPositionSeconds(event)
if (seconds == null || seconds.isNaN() || seconds < 0) {
MPRemoteCommandHandlerStatusCommandFailed
} else {
seekTo((seconds * 1000.0).toLong())
MPRemoteCommandHandlerStatusSuccess
}
}
center.seekForwardCommand.addTargetWithHandler { event ->
handleSeekCommand(event, forward = true)
}
center.seekBackwardCommand.addTargetWithHandler { event ->
handleSeekCommand(event, forward = false)
}
}
/**
* 长按上一曲/下一曲:系统发 seekForward/Backward(begin/end)。
* HTTP FLAC 不一定支持 rate≠1,改为步进 seek。
*/
private fun handleSeekCommand(event: MPRemoteCommandEvent?, forward: Boolean): MPRemoteCommandHandlerStatus {
val raw = (event as? MPSeekCommandEvent)?.type?.value
when (raw) {
0uL -> beginScrub(forward)
1uL -> endScrub()
else -> if (scrubJob != null) endScrub() else beginScrub(forward)
}
return MPRemoteCommandHandlerStatusSuccess
}
private fun beginScrub(forward: Boolean) {
scrubJob?.cancel()
if (!_state.value.isPlaying) play()
scrubJob = scope.launch {
while (isActive) {
val pos = currentPositionMs()
val dur = currentDurationMs()
val next = if (forward) {
if (dur > 0) (pos + SCRUB_STEP_MS).coerceAtMost(dur) else pos + SCRUB_STEP_MS
} else {
(pos - SCRUB_STEP_MS).coerceAtLeast(0L)
}
seekTo(next)
if (forward && dur > 0 && next >= dur) {
endScrub()
onPlaybackEnded()
break
}
delay(SCRUB_TICK)
}
}
}
private fun endScrub() {
scrubJob?.cancel()
scrubJob = null
}
/** Kotlin/Native 对 MPChangePlaybackPositionCommandEvent 的 as? 经常失败,用 KVC 兜底。 */
private fun playbackPositionSeconds(event: MPRemoteCommandEvent?): Double? {
val t = (event as? MPChangePlaybackPositionCommandEvent)?.positionTime
return t?.takeIf { !it.isNaN() && it >= 0 }
}
/**
* 判断通知里的 item 是否为本控制器正在播放的音乐(而非桌面歌词 dummy 视频)。
* 用 isEqual 而不是 ===:Kotlin/Native 每次桥接 ObjC 对象可能给出不同的包装器实例。
*/
private fun isMusicItem(item: AVPlayerItem): Boolean {
musicItem?.let { if (item.isEqual(it)) return true }
player.currentItem?.let { if (item.isEqual(it)) return true }
return false
}
private fun observePlaybackEnd() {
endObserver = NSNotificationCenter.defaultCenter.addObserverForName(
name = AVPlayerItemDidPlayToEndTimeNotification,
`object` = null,
queue = null,
) { notification: NSNotification? ->
val ended = notification?.`object` as? AVPlayerItem
// 桌面歌词 dummy black.mp4 也会发 DidPlayToEnd;只处理本控制器的音乐 item。
// Kotlin/Native 的 === 对 ObjC 对象包装器不可靠,须用 isEqual 比较指针。
if (ended == null || !isMusicItem(ended)) {
return@addObserverForName
}
dispatch_async(dispatch_get_main_queue()) {
onPlaybackEnded()
}
}
}
private fun observePlaybackFailed() {
failObserver = NSNotificationCenter.defaultCenter.addObserverForName(
name = AVPlayerItemFailedToPlayToEndTimeNotification,
`object` = null,
queue = null,
) { notification: NSNotification? ->
val failed = notification?.`object` as? AVPlayerItem
if (failed == null || !isMusicItem(failed)) return@addObserverForName
val error =
notification.userInfo?.get(AVPlayerItemFailedToPlayToEndTimeErrorKey) as? NSError
val current = _state.value.current
AppLog.e(
TAG,
"av fail domain=${error?.domain} code=${error?.code} " +
"msg=${error?.localizedDescription} uri=${current?.musicPath} " +
"id=${current?.musicId} name=${current?.musicName}",
)
_state.update {
it.copy(
isPlaying = false,
error = error?.localizedDescription ?: "AVPlayerItem failed",
)
}
}
}
/** 耳机/蓝牙音频路由不可用时暂停(拔出/断开)。 */
private fun observeAudioRouteChanges() {
routeObserver = NSNotificationCenter.defaultCenter.addObserverForName(
name = AVAudioSessionRouteChangeNotification,
`object` = null,
queue = null,
) { notification: NSNotification? ->
val reason =
(notification?.userInfo?.get(AVAudioSessionRouteChangeReasonKey) as? NSNumber)
?.unsignedLongValue
dispatch_async(dispatch_get_main_queue()) {
if (reason == AVAudioSessionRouteChangeReasonOldDeviceUnavailable) {
pause()
}
// 蓝牙接入时立刻重推 Now Playing,外设才能读到当前歌词标题。
updateNowPlayingInfo()
}
}
}
private fun onPlaybackEnded() {
// 结束通知与"播放到尾部却停住"的兜底可能同时命中,去重。
val previous = lastEndMark
if (previous != null && previous.elapsedNow() < END_DEDUPE) return
lastEndMark = TimeSource.Monotonic.markNow()
when (loopMode) {
LoopMode.SINGLE -> {
seekTo(0)
play()
}
LoopMode.LIST -> {
if (_state.value.index < queue.lastIndex) {
loadIndex(_state.value.index + 1, autoPlay = true)
} else if (queue.isNotEmpty()) {
loadIndex(0, autoPlay = true)
} else {
_state.update { it.copy(isPlaying = false, positionMs = 0) }
}
}
LoopMode.SHUFFLE -> {
if (queue.size <= 1) {
seekTo(0)
play()
} else {
val current = _state.value.index
var nextIndex = Random.nextInt(queue.size)
while (nextIndex == current) {
nextIndex = Random.nextInt(queue.size)
}
loadIndex(nextIndex, autoPlay = true)
}
}
}
}
private fun startPositionUpdates() {
positionJob?.cancel()
positionJob = scope.launch {
while (isActive) {
val positionMs = currentPositionMs()
val durationMs = currentDurationMs()
val playing = player.rate > 0f
val wasPlaying = _state.value.isPlaying
_state.update {
it.copy(
positionMs = positionMs,
durationMs = durationMs,
isPlaying = playing,
)
}
updateNowPlayingInfo()
// 兜底:HTTP 流有时不发 DidPlayToEnd,只是在尾部停住。
if (wasPlaying && !playing && durationMs > 0 &&
positionMs >= durationMs - END_TOLERANCE_MS
) {
onPlaybackEnded()
}
delay(200.milliseconds)
}
}
}
private fun currentPositionMs(): Long {
val seconds = CMTimeGetSeconds(player.currentTime())
if (seconds.isNaN() || seconds < 0) return 0
return (seconds * 1000).toLong()
}
private fun currentDurationMs(): Long {
val item = player.currentItem
if (item != null) {
val seconds = CMTimeGetSeconds(item.duration)
if (!seconds.isNaN() && !seconds.isInfinite() && seconds > 0) {
return (seconds * 1000).toLong()
}
}
return (musicTimeSeconds(_state.value.current?.time) * 1000.0).toLong()
}
private fun musicTimeSeconds(time: String?): Double {
if (time.isNullOrBlank()) return 0.0
val parts = time.trim().split(':')
return runCatching {
when (parts.size) {
1 -> parts[0].toDouble()
2 -> parts[0].toDouble() * 60 + parts[1].toDouble()
3 -> parts[0].toDouble() * 3600 + parts[1].toDouble() * 60 + parts[2].toDouble()
else -> 0.0
}
}.getOrDefault(0.0)
}
/**
* 系统 Now Playing / 灵动岛 / 控制中心 / 锁屏。
* 封面 + 专辑信息启用灵动岛媒体胶囊。
*/
private fun updateNowPlayingInfo() {
val s = _state.value
val current = s.current ?: return
val songName = current.musicName ?: return
val title = if (s.isPlaying) {
nowPlayingLyric?.takeIf { it.isNotBlank() } ?: songName
} else {
songName
}
val artist = current.artist.orEmpty()
val album = current.albumName.orEmpty()
val durationSec = when {
s.durationMs > 0 -> s.durationMs / 1000.0
else -> musicTimeSeconds(current.time)
}
val elapsedSec = s.positionMs.coerceAtLeast(0) / 1000.0
val rate = if (s.isPlaying) 1.0 else 0.0
val info = mutableMapOf<Any?, Any?>(
MPMediaItemPropertyTitle to title,
MPMediaItemPropertyArtist to artist,
MPMediaItemPropertyAlbumTitle to album,
MPMediaItemPropertyPlaybackDuration to NSNumber(double = durationSec),
MPNowPlayingInfoPropertyElapsedPlaybackTime to NSNumber(double = elapsedSec),
MPNowPlayingInfoPropertyPlaybackRate to NSNumber(double = rate),
MPNowPlayingInfoPropertyDefaultPlaybackRate to NSNumber(double = 1.0),
)
artworkFor(current)?.let { art ->
info[MPMediaItemPropertyArtwork] = art
}
MPNowPlayingInfoCenter.defaultCenter().nowPlayingInfo = info
}
private fun artworkFor(music: Music): MPMediaItemArtwork? {
val id = music.musicId
if (id != null && id == cachedArtworkMusicId && cachedArtwork != null) {
return cachedArtwork
}
val raw = resolveCoverUri?.invoke(music) ?: return null
if (raw.startsWith("http://") || raw.startsWith("https://")) {
// HTTP 曲库:异步加载远程封面供灵动岛/锁屏。
if (id != null) prefetchHttpArtwork(id, raw)
return cachedArtwork.takeIf { cachedArtworkMusicId == id }
}
val path = when {
raw.startsWith("file://") -> raw.removePrefix("file://")
else -> raw
}
val image = UIImage.imageWithContentsOfFile(path) ?: return null
val artwork = MPMediaItemArtwork(boundsSize = image.size) { _ -> image }
cachedArtworkMusicId = id
cachedArtwork = artwork
return artwork
}
private fun prefetchHttpArtwork(musicId: String, url: String) {
if (musicId == coverFetchingMusicId) return
if (musicId == cachedArtworkMusicId && cachedArtwork != null) return
val nsUrl = NSURL.URLWithString(url) ?: return
coverFetchingMusicId = musicId
val request = NSMutableURLRequest.requestWithURL(nsUrl)
request.setValue(
"LoveLiveMusicPlayer/0.2.0 (KMP; Android/iOS)",
forHTTPHeaderField = "User-Agent",
)
NSURLSession.sharedSession.dataTaskWithRequest(request) { data, _, error ->
if (error != null || data == null) {
AppLog.w(
TAG,
"cover http fetch fail id=$musicId msg=${error?.localizedDescription}"
)
dispatch_async(dispatch_get_main_queue()) {
if (coverFetchingMusicId == musicId) coverFetchingMusicId = null
}
return@dataTaskWithRequest
}
val image = UIImage.imageWithData(data)
dispatch_async(dispatch_get_main_queue()) {
if (coverFetchingMusicId == musicId) coverFetchingMusicId = null
if (image == null) {
AppLog.w(TAG, "cover http decode fail id=$musicId")
return@dispatch_async
}
if (_state.value.current?.musicId != musicId) return@dispatch_async
val artwork = MPMediaItemArtwork(boundsSize = image.size) { _ -> image }
cachedArtworkMusicId = musicId
cachedArtwork = artwork
updateNowPlayingInfo()
}
}.resume()
}
actual fun setQueue(items: List<Music>, startIndex: Int) {
val index = if (items.isEmpty()) -1 else startIndex.coerceIn(0, items.lastIndex)
val nextCurrent = items.getOrNull(index)
val sameCurrent = nextCurrent?.musicId != null &&
nextCurrent.musicId == _state.value.current?.musicId &&
loadedUri != null &&
player.currentItem != null
queue = items
loopMode = _state.value.loopMode
if (items.isEmpty()) {
player.pause()
player.replaceCurrentItemWithPlayerItem(null)
loadedUri = null
httpResourceLoader = null
musicItem = null
_state.update {
it.copy(queue = emptyList(), index = -1, current = null, isPlaying = false)
}
return
}
// 插入下一首时不重建整个播放队列
// 当前播放项 — 保持位置/解码器状态。
if (sameCurrent) {
_state.update {
it.copy(queue = items, index = index, current = nextCurrent, loopMode = loopMode)
}
return
}
loadIndex(index, autoPlay = false)
_state.update { it.copy(queue = items, loopMode = loopMode) }
}
actual fun play() {
ensureAudioReady()
val current = _state.value.current
if (loadedUri.isNullOrBlank() && current != null) {
AppLog.e(TAG, "play() abort reason=noLoadedUri id=${current.musicId}")
_state.update { it.copy(isPlaying = false, error = "No playable URI") }
return
}
player.play()
_state.update { it.copy(isPlaying = true, error = null) }
startPositionUpdates()
updateNowPlayingInfo()
}
actual fun pause() {
endScrub()
player.pause()
positionJob?.cancel()
_state.update { it.copy(isPlaying = false) }
updateNowPlayingInfo()
}
actual fun toggle() {
if (_state.value.isPlaying) pause() else play()
}
actual fun seekTo(positionMs: Long) {
val time = CMTimeMake(value = positionMs, timescale = 1000)
player.seekToTime(time)
_state.update { it.copy(positionMs = positionMs) }
updateNowPlayingInfo()
}
actual fun next() {
if (queue.isEmpty()) return
when (loopMode) {
LoopMode.SHUFFLE -> onPlaybackEnded()
else -> {
val next = if (_state.value.index < queue.lastIndex) {
_state.value.index + 1
} else if (loopMode == LoopMode.LIST) {
0
} else {
return
}
loadIndex(next, autoPlay = _state.value.isPlaying)
}
}
}
actual fun previous() {
if (queue.isEmpty()) return
val pos = _state.value.positionMs
if (pos > 3000) {
seekTo(0)
return
}
val prev = if (_state.value.index > 0) {
_state.value.index - 1
} else if (loopMode == LoopMode.LIST) {
queue.lastIndex
} else {
0
}
loadIndex(prev, autoPlay = _state.value.isPlaying)
}
actual fun playAtIndex(index: Int) {
if (index !in queue.indices) return
loadIndex(index, autoPlay = _state.value.isPlaying)
}
actual fun setLoopMode(mode: LoopMode) {
loopMode = mode
_state.update { it.copy(loopMode = mode) }
}
actual fun syncLoveState(musicId: String, isLove: Boolean) {
queue = queue.map { music ->
if (music.musicId == musicId) music.copy(isLove = isLove) else music
}
_state.update { st ->
val current = st.current?.let { music ->
if (music.musicId == musicId) music.copy(isLove = isLove) else music
}
st.copy(queue = queue, current = current)
}
}
actual fun setNowPlayingLyric(line: String?) {
val next = line?.trim()?.takeIf { it.isNotEmpty() }
if (nowPlayingLyric == next) return
nowPlayingLyric = next
updateNowPlayingInfo()
}
actual fun removeAt(index: Int) {
if (index !in queue.indices) return
if (queue.size == 1) {
clearQueue()
return
}
val wasPlaying = _state.value.isPlaying
val currentIndex = _state.value.index
val removingCurrent = index == currentIndex
val newQueue = queue.toMutableList().also { it.removeAt(index) }
queue = newQueue
if (!removingCurrent) {
val newIdx = when {
index < currentIndex -> currentIndex - 1
else -> currentIndex
}.coerceIn(0, newQueue.lastIndex)
_state.update {
it.copy(queue = newQueue, index = newIdx, current = newQueue.getOrNull(newIdx))
}
return
}
val nextIndex = when {
loopMode == LoopMode.SHUFFLE -> Random.nextInt(newQueue.size)
index > newQueue.lastIndex -> 0
else -> index
}
loadIndex(nextIndex.coerceIn(0, newQueue.lastIndex), autoPlay = wasPlaying)
_state.update { it.copy(queue = newQueue) }
}
actual fun clearQueue() {
positionJob?.cancel()
itemWatchJob?.cancel()
player.pause()
player.replaceCurrentItemWithPlayerItem(null)
queue = emptyList()
loadedUri = null
httpResourceLoader = null
musicItem = null
cachedArtwork = null
cachedArtworkMusicId = null
coverFetchingMusicId = null
MPNowPlayingInfoCenter.defaultCenter().nowPlayingInfo = null
nowPlayingLyric = null
_state.update {
it.copy(
queue = emptyList(),
index = -1,
current = null,
isPlaying = false,
positionMs = 0,
durationMs = 0,
)
}
}
actual fun playUri(uri: String, title: String?) {
val music = Music(
musicId = "local",
musicName = title ?: uri.substringAfterLast('/'),
musicPath = uri,
)
queue = listOf(music)
loopMode = _state.value.loopMode
val url = resolveUrl(uri)
if (url == null) {
AppLog.e(TAG, "playUri fail reason=badUrl uri=$uri")
_state.update {
it.copy(
current = music,
queue = queue,
index = 0,
isPlaying = false,
error = "Cannot resolve playback URL: $uri",
)
}
return
}
val asset = AVURLAsset(uRL = url, options = null)
val item = AVPlayerItem(asset = asset)
musicItem = item
player.replaceCurrentItemWithPlayerItem(item)
player.play()
_state.update {
it.copy(
current = music,
queue = queue,
index = 0,
isPlaying = true,
error = null,
positionMs = 0,
)
}
startPositionUpdates()
updateNowPlayingInfo()
}
actual fun release() {
positionJob?.cancel()
itemWatchJob?.cancel()
player.pause()
player.replaceCurrentItemWithPlayerItem(null)
endObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) }
endObserver = null
failObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) }
failObserver = null
routeObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) }
routeObserver = null
queue = emptyList()
loadedUri = null
httpResourceLoader = null
musicItem = null
_state.value = PlayerState()
}
private fun loadIndex(index: Int, autoPlay: Boolean) {
nowPlayingLyric = null
val music = queue.getOrNull(index) ?: return
// 绝不回退到数据库相对 musicPath(如 "02. ICE LIMIT.wav")—
// NSURL.fileURLWithPath 会静默接受,AVPlayer 稍后失败。
val path = resolvePlaybackUri?.invoke(music)
?: music.musicPath?.takeIf { isPlayableUri(it) }
if (path.isNullOrBlank()) {
loadedUri = null
AppLog.e(
TAG,
"loadIndex fail id=${music.musicId} name=${music.musicName} reason=missingPath " +
"autoPlay=$autoPlay dbPath=${music.musicPath} base=${music.baseUrl}",
)
_state.update {
it.copy(
index = index,
current = music,
error = "Missing music path",
isPlaying = false,
)
}
return
}
itemWatchJob?.cancel()
attachPlayerItem(music, index, path, autoPlay)
}
private fun attachPlayerItem(music: Music, index: Int, path: String, autoPlay: Boolean) {
val url = resolveUrl(path)
if (url == null) {
loadedUri = null
AppLog.e(
TAG,
"loadIndex fail id=${music.musicId} name=${music.musicName} reason=badUrl path=$path",
)
_state.update {
it.copy(
index = index,
current = music,
error = "Cannot resolve URL: $path",
isPlaying = false,
)
}
return
}
loadedUri = path
if (music.musicId != cachedArtworkMusicId) {
cachedArtwork = null
cachedArtworkMusicId = null
coverFetchingMusicId = null
}
val asset: AVURLAsset
if (path.startsWith("http://") || path.startsWith("https://")) {
val custom = HttpAudioResourceLoaderKeys.toCustomUrl(path)
val customNs = custom?.let { NSURL.URLWithString(it) }
if (customNs == null) {
loadedUri = null
AppLog.e(TAG, "loadIndex fail customScheme path=$path")
_state.update {
it.copy(
index = index,
current = music,
error = "Cannot build proxied URL",
isPlaying = false,
)
}
return
}
// 须保留 delegate — AVAssetResourceLoader.delegate 为弱引用。
httpResourceLoader = HttpAudioResourceLoader()
asset = AVURLAsset(uRL = customNs, options = null)
asset.resourceLoader.setDelegate(
httpResourceLoader,
queue = dispatch_get_main_queue(),
)
} else {
httpResourceLoader = null
asset = AVURLAsset(uRL = url, options = null)
}
val item = AVPlayerItem(asset = asset)
musicItem = item
player.replaceCurrentItemWithPlayerItem(item)
watchItemStatus(item, path, music)
if (autoPlay) {
ensureAudioReady()
player.play()
startPositionUpdates()
} else {
player.pause()
positionJob?.cancel()
}
_state.update {
it.copy(
index = index,
current = music,
isPlaying = autoPlay,
error = null,
positionMs = 0,
)
}
updateNowPlayingInfo()
}
private fun watchItemStatus(item: AVPlayerItem, path: String, music: Music) {
itemWatchJob?.cancel()
itemWatchJob = scope.launch {
repeat(40) {
delay(250.milliseconds)
when (item.status) {
AVPlayerItemStatusReadyToPlay -> {
return@launch
}
AVPlayerItemStatusFailed -> {
val err = item.error
AppLog.e(
TAG,
"item failed id=${music.musicId} name=${music.musicName} " +
"domain=${err?.domain} code=${err?.code} " +
"msg=${err?.localizedDescription} path=$path",
)
_state.update {
it.copy(
isPlaying = false,
error = err?.localizedDescription ?: "AVPlayerItem status failed",
)
}
return@launch
}
else -> Unit
}
}
AppLog.w(
TAG,
"item status timeout id=${music.musicId} name=${music.musicName} " +
"status=${item.status} path=$path",
)
}
}
private fun isPlayableUri(raw: String): Boolean =
raw.startsWith("http://") || raw.startsWith("https://") ||
raw.startsWith("file://") || raw.startsWith("asset://") ||
raw.startsWith("/")
private fun resolveUrl(raw: String): NSURL? {
val url = when {
raw.startsWith("http://") || raw.startsWith("https://") ||
raw.startsWith("file://") -> NSURL.URLWithString(raw)
raw.startsWith("asset:///") -> {
val name = raw.removePrefix("asset:///").substringBeforeLast('.')
val ext = raw.substringAfterLast('.', "")
val path = NSBundle.mainBundle.pathForResource(name, ext)
path?.let { NSURL.fileURLWithPath(it) }
}
raw.startsWith("asset://") -> {
val name = raw.removePrefix("asset://").trimStart('/').substringBeforeLast('.')
val ext = raw.substringAfterLast('.', "")
val path = NSBundle.mainBundle.pathForResource(name, ext)
path?.let { NSURL.fileURLWithPath(it) }
}
raw.startsWith("/") -> NSURL.fileURLWithPath(raw)
else -> {
AppLog.w(TAG, "resolveUrl reject relative path=$raw")
null
}
}
return url
}
private companion object {
const val TAG = "LLMP-Player"
val END_DEDUPE = 1500.milliseconds
const val END_TOLERANCE_MS = 500L
const val SCRUB_STEP_MS = 2000L
val SCRUB_TICK = 250.milliseconds
}
}
@@ -1,4 +1,4 @@
package top.zhushenwudi.llmp.settings
package com.zhushenwudi.lovelivemusicplayer.settings
import com.russhwolf.settings.NSUserDefaultsSettings
import com.russhwolf.settings.Settings
@@ -0,0 +1,26 @@
package com.zhushenwudi.lovelivemusicplayer.util
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.usePinned
import platform.CoreCrypto.CC_SHA512
import platform.CoreCrypto.CC_SHA512_DIGEST_LENGTH
@OptIn(ExperimentalForeignApi::class)
actual fun sha512Hex(text: String): String {
val data = text.encodeToByteArray()
val digest = UByteArray(CC_SHA512_DIGEST_LENGTH)
data.usePinned { input ->
digest.usePinned { output ->
CC_SHA512(
input.addressOf(0),
data.size.toUInt(),
output.addressOf(0).reinterpret(),
)
}
}
return digest.joinToString("") { b ->
b.toInt().toString(16).padStart(2, '0')
}
}
@@ -1,7 +0,0 @@
package top.zhushenwudi.llmp.db
import platform.Foundation.NSDate
import platform.Foundation.timeIntervalSince1970
actual fun currentTimeMs(): Long =
(NSDate().timeIntervalSince1970 * 1000.0).toLong()
@@ -1,6 +0,0 @@
package top.zhushenwudi.llmp.lyric
actual object PlatformInfo {
// Align with Flutter: iOS may restrict JP/ROMA until EULA accepted.
actual val allowEulaLyric: Boolean = true
}
@@ -1,226 +0,0 @@
package top.zhushenwudi.llmp.platform
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import platform.Foundation.NSFileManager
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSUserDefaults
/** App Group shared with WidgetKit extension and Swift host. */
const val IOS_APP_GROUP = "group.top.zhushenwudi.llmp"
const val IOS_NOTIFICATION_DESKTOP_LYRIC = "llmpDesktopLyricUpdate"
const val IOS_NOTIFICATION_PIP = "llmpPiPRequest"
const val IOS_NOTIFICATION_SHARE = "llmpShareRequest"
const val IOS_NOTIFICATION_DEEP_LINK = "llmpDeepLink"
const val IOS_NOTIFICATION_WIDGET = "llmpWidgetAction"
const val IOS_NOTIFICATION_CARPLAY = "llmpCarPlayAction"
@OptIn(ExperimentalForeignApi::class)
object WidgetDataWriter {
private val defaults: NSUserDefaults?
get() = NSUserDefaults(suiteName = IOS_APP_GROUP)
fun writeString(key: String, value: String?) {
defaults?.setObject(value, forKey = key)
defaults?.synchronize()
}
fun writeBool(key: String, value: Boolean) {
defaults?.setBool(value, forKey = key)
defaults?.synchronize()
}
fun writeInt(key: String, value: Int) {
defaults?.setInteger(value.toLong(), forKey = key)
defaults?.synchronize()
}
fun writeShareImage(fromPath: String?) {
if (fromPath.isNullOrBlank()) return
val container = NSFileManager.defaultManager
.containerURLForSecurityApplicationGroupIdentifier(IOS_APP_GROUP)
?: return
val dest = container.URLByAppendingPathComponent("sharedImage.png")?.path ?: return
if (NSFileManager.defaultManager.fileExistsAtPath(fromPath)) {
NSFileManager.defaultManager.removeItemAtPath(dest, error = null)
NSFileManager.defaultManager.copyItemAtPath(fromPath, toPath = dest, error = null)
}
}
fun reloadWidgets() {
// Swift host / Widget Extension observe this and call WidgetCenter.reloadAllTimelines().
post("llmpReloadWidgets")
}
fun post(name: String, userInfo: Map<String, String> = emptyMap()) {
val info = userInfo.mapKeys { it.key as Any? }.mapValues { it.value as Any? }
NSNotificationCenter.defaultCenter.postNotificationName(
aName = name,
`object` = null,
userInfo = info,
)
}
}
class IosDesktopLyricController : DesktopLyricController {
override var enabled: Boolean = false
set(value) {
field = value
WidgetDataWriter.writeBool("desktopLyricEnabled", value)
WidgetDataWriter.post(IOS_NOTIFICATION_DESKTOP_LYRIC, mapOf("enabled" to value.toString()))
}
override fun show() {
enabled = true
WidgetDataWriter.post(IOS_NOTIFICATION_PIP, mapOf("action" to "show"))
}
override fun hide() {
enabled = false
WidgetDataWriter.post(IOS_NOTIFICATION_PIP, mapOf("action" to "hide"))
}
override fun updateLines(line1: String?, line2: String?, currentLine: Int) {
WidgetDataWriter.writeString("curJpLrc", line1)
WidgetDataWriter.writeString("nextJpLrc", line2)
WidgetDataWriter.writeInt("pipCurrentLine", currentLine)
WidgetDataWriter.writeString("lyricLine1", line1)
WidgetDataWriter.writeString("lyricLine2", line2)
WidgetDataWriter.writeInt("currentLine", currentLine)
WidgetDataWriter.post(
IOS_NOTIFICATION_DESKTOP_LYRIC,
mapOf(
"line1" to (line1 ?: ""),
"line2" to (line2 ?: ""),
"currentLine" to currentLine.toString(),
),
)
}
override fun setPlaying(playing: Boolean) {
WidgetDataWriter.writeBool("pipIsPlaying", playing)
WidgetDataWriter.post(IOS_NOTIFICATION_PIP, mapOf("playing" to playing.toString()))
}
}
class IosHomeWidgetController : HomeWidgetController {
override fun update(
songName: String?,
artist: String?,
isPlaying: Boolean,
favorite: Boolean,
lyricLine1: String?,
lyricLine2: String?,
currentLine: Int,
coverPath: String?,
playText: String,
) {
WidgetDataWriter.writeString("songName", songName ?: "")
WidgetDataWriter.writeString("songArtist", artist ?: "")
WidgetDataWriter.writeBool("songFavorite", favorite)
WidgetDataWriter.writeBool("isPlaying", isPlaying)
WidgetDataWriter.writeString("playText", playText)
WidgetDataWriter.writeString("lyricLine1", lyricLine1)
WidgetDataWriter.writeString("lyricLine2", lyricLine2)
WidgetDataWriter.writeInt("currentLine", currentLine)
WidgetDataWriter.writeString("bgColor", "255,255,255")
coverPath?.let { WidgetDataWriter.writeShareImage(it) }
WidgetDataWriter.reloadWidgets()
}
override fun setShutdown(shutdown: Boolean) {
WidgetDataWriter.writeBool("isShutdown", shutdown)
if (shutdown) {
WidgetDataWriter.writeBool("isPlaying", false)
}
WidgetDataWriter.reloadWidgets()
}
override fun refresh() {
WidgetDataWriter.reloadWidgets()
}
}
class IosShareBridge : ShareBridge {
override fun shareText(text: String, title: String?) {
WidgetDataWriter.writeString("pendingShareText", text)
WidgetDataWriter.writeString("pendingShareTitle", title ?: "")
WidgetDataWriter.writeString("pendingShareUrl", "")
WidgetDataWriter.post(
IOS_NOTIFICATION_SHARE,
mapOf("type" to "text", "text" to text, "title" to (title ?: "")),
)
}
override fun shareUrl(url: String, title: String?) {
WidgetDataWriter.writeString("pendingShareUrl", url)
WidgetDataWriter.writeString("pendingShareTitle", title ?: "")
WidgetDataWriter.writeString("pendingShareText", "")
WidgetDataWriter.post(
IOS_NOTIFICATION_SHARE,
mapOf("type" to "url", "url" to url, "title" to (title ?: "")),
)
}
}
class IosDeepLinkHandler : DeepLinkHandler {
var lastPayload: DeepLinkPayload? = null
private set
override fun parse(url: String): DeepLinkPayload? = DeepLinkParser.parse(url)
override fun handle(url: String) {
val payload = parse(url) ?: return
lastPayload = payload
WidgetDataWriter.writeString("pendingDeepLink", url)
WidgetDataWriter.post(IOS_NOTIFICATION_DEEP_LINK, mapOf("url" to url))
}
}
class IosCarPlayHook : CarPlayHook {
private var catalog: CarPlayCatalog = CarPlayCatalog()
private val json = Json { ignoreUnknownKeys = true }
override fun bind(catalog: CarPlayCatalog) {
this.catalog = catalog
}
override fun refreshCatalog() {
WidgetDataWriter.writeString("carplay_nowPlaying", catalog.nowPlayingTitle)
WidgetDataWriter.writeString("carplay_nowPlayingCover", catalog.nowPlayingCover ?: "")
WidgetDataWriter.writeString("carplay_groups", json.encodeToString(catalog.groups))
WidgetDataWriter.writeString("carplay_love", json.encodeToString(catalog.loveSongs))
WidgetDataWriter.writeString("carplay_menus", json.encodeToString(catalog.menus))
WidgetDataWriter.post(IOS_NOTIFICATION_CARPLAY, mapOf("action" to "refresh"))
}
}
class IosPiPHook : PictureInPictureHook {
override fun enterPiP() {
WidgetDataWriter.post(IOS_NOTIFICATION_PIP, mapOf("action" to "enter"))
}
}
class IosMediaSessionBridge : MediaSessionHook {
override fun updateSession(title: String?, artist: String?, isPlaying: Boolean) {
WidgetDataWriter.writeString("songName", title ?: "")
WidgetDataWriter.writeString("songArtist", artist ?: "")
WidgetDataWriter.writeBool("isPlaying", isPlaying)
}
}
fun installIosPlatformServices() {
PlatformServices.install(
desktopLyric = IosDesktopLyricController(),
homeWidget = IosHomeWidgetController(),
deepLink = IosDeepLinkHandler(),
usbMount = NoOpUsbMount,
share = IosShareBridge(),
mediaSession = IosMediaSessionBridge(),
analytics = NoOpAnalytics,
carPlay = IosCarPlayHook(),
pip = IosPiPHook(),
)
}
@@ -1,38 +0,0 @@
package top.zhushenwudi.llmp.platform
import platform.Foundation.NSURL
import platform.UIKit.UIApplication
import kotlin.system.exitProcess
actual object PlatformUi {
actual fun openQrScanner(onResult: (String) -> Unit) {
// Camera QR on iOS is hosted by Swift; common UI still allows IP paste.
onResult("")
}
actual fun openWebView(url: String, title: String?) {
val nsUrl = NSURL.URLWithString(url) ?: return
UIApplication.sharedApplication.openURL(nsUrl)
}
actual fun requestOverlayPermission() = Unit
actual fun moveTaskToBack() = Unit
actual fun showToast(message: String) {
// Native toast hosted by Swift later; no-op for now.
}
actual fun pickImage(onResult: (String?) -> Unit) {
onResult(null)
}
actual fun openExternalUrl(url: String) {
val nsUrl = NSURL.URLWithString(url) ?: return
UIApplication.sharedApplication.openURL(nsUrl)
}
actual fun exitApp() {
exitProcess(0)
}
}
@@ -1,502 +0,0 @@
package top.zhushenwudi.llmp.player
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import platform.AVFAudio.AVAudioSession
import platform.AVFAudio.AVAudioSessionCategoryPlayback
import platform.AVFAudio.AVAudioSessionRouteChangeNotification
import platform.AVFAudio.AVAudioSessionRouteChangeReasonKey
import platform.AVFAudio.AVAudioSessionRouteChangeReasonOldDeviceUnavailable
import platform.AVFoundation.AVPlayer
import platform.AVFoundation.AVPlayerItem
import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification
import platform.AVFoundation.AVURLAsset
import platform.AVFoundation.replaceCurrentItemWithPlayerItem
import platform.AVFoundation.seekToTime
import platform.CoreMedia.CMTimeGetSeconds
import platform.CoreMedia.CMTimeMake
import platform.Foundation.NSBundle
import platform.Foundation.NSNotification
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSNumber
import platform.Foundation.NSURL
import platform.MediaPlayer.MPChangePlaybackPositionCommandEvent
import platform.MediaPlayer.MPMediaItemArtwork
import platform.MediaPlayer.MPMediaItemPropertyAlbumTitle
import platform.MediaPlayer.MPMediaItemPropertyArtist
import platform.MediaPlayer.MPMediaItemPropertyArtwork
import platform.MediaPlayer.MPMediaItemPropertyPlaybackDuration
import platform.MediaPlayer.MPMediaItemPropertyTitle
import platform.MediaPlayer.MPNowPlayingInfoCenter
import platform.MediaPlayer.MPNowPlayingInfoPropertyElapsedPlaybackTime
import platform.MediaPlayer.MPNowPlayingInfoPropertyPlaybackRate
import platform.MediaPlayer.MPRemoteCommandCenter
import platform.MediaPlayer.MPRemoteCommandHandlerStatusSuccess
import platform.UIKit.UIImage
import top.zhushenwudi.llmp.domain.Music
import kotlin.random.Random
@OptIn(ExperimentalForeignApi::class)
actual class PlayerController {
private val player = AVPlayer()
private val scope = CoroutineScope(Dispatchers.Main.immediate)
private var positionJob: Job? = null
private var endObserver: Any? = null
private var routeObserver: Any? = null
private val _state = MutableStateFlow(PlayerState())
actual val state: StateFlow<PlayerState> = _state.asStateFlow()
actual var resolvePlaybackUri: ((Music) -> String?)? = null
actual var resolveCoverUri: ((Music) -> String?)? = null
private var queue: List<Music> = emptyList()
private var loopMode: LoopMode = LoopMode.LIST
private var cachedArtworkMusicId: String? = null
private var cachedArtwork: MPMediaItemArtwork? = null
init {
setupAudioSession()
setupRemoteCommands()
observePlaybackEnd()
observeAudioRouteChanges()
}
private fun setupAudioSession() {
val session = AVAudioSession.sharedInstance()
session.setCategory(AVAudioSessionCategoryPlayback, error = null)
session.setActive(true, error = null)
}
private fun setupRemoteCommands() {
val center = MPRemoteCommandCenter.sharedCommandCenter()
center.playCommand.setEnabled(true)
center.pauseCommand.setEnabled(true)
center.togglePlayPauseCommand.setEnabled(true)
center.nextTrackCommand.setEnabled(true)
center.previousTrackCommand.setEnabled(true)
center.changePlaybackPositionCommand.setEnabled(true)
center.playCommand.addTargetWithHandler { _ ->
play()
MPRemoteCommandHandlerStatusSuccess
}
center.pauseCommand.addTargetWithHandler { _ ->
pause()
MPRemoteCommandHandlerStatusSuccess
}
center.togglePlayPauseCommand.addTargetWithHandler { _ ->
toggle()
MPRemoteCommandHandlerStatusSuccess
}
center.nextTrackCommand.addTargetWithHandler { _ ->
next()
MPRemoteCommandHandlerStatusSuccess
}
center.previousTrackCommand.addTargetWithHandler { _ ->
previous()
MPRemoteCommandHandlerStatusSuccess
}
center.changePlaybackPositionCommand.addTargetWithHandler { event ->
val posEvent = event as? MPChangePlaybackPositionCommandEvent
if (posEvent != null) {
seekTo((posEvent.positionTime * 1000).toLong())
}
MPRemoteCommandHandlerStatusSuccess
}
}
private fun observePlaybackEnd() {
endObserver = NSNotificationCenter.defaultCenter.addObserverForName(
name = AVPlayerItemDidPlayToEndTimeNotification,
`object` = null,
queue = null,
) { _ ->
onPlaybackEnded()
}
}
/** Pause when headphones / BT audio route becomes unavailable (unplug / disconnect). */
private fun observeAudioRouteChanges() {
routeObserver = NSNotificationCenter.defaultCenter.addObserverForName(
name = AVAudioSessionRouteChangeNotification,
`object` = null,
queue = null,
) { notification: NSNotification? ->
val reason = (notification?.userInfo?.get(AVAudioSessionRouteChangeReasonKey) as? NSNumber)
?.unsignedLongValue
if (reason == AVAudioSessionRouteChangeReasonOldDeviceUnavailable) {
pause()
}
}
}
private fun onPlaybackEnded() {
when (loopMode) {
LoopMode.SINGLE -> {
seekTo(0)
play()
}
LoopMode.LIST -> {
if (_state.value.index < queue.lastIndex) {
loadIndex(_state.value.index + 1, autoPlay = true)
} else if (queue.isNotEmpty()) {
loadIndex(0, autoPlay = true)
} else {
_state.update { it.copy(isPlaying = false, positionMs = 0) }
}
}
LoopMode.SHUFFLE -> {
if (queue.size <= 1) {
seekTo(0)
play()
} else {
val current = _state.value.index
var nextIndex = Random.nextInt(queue.size)
while (nextIndex == current) {
nextIndex = Random.nextInt(queue.size)
}
loadIndex(nextIndex, autoPlay = true)
}
}
}
}
private fun startPositionUpdates() {
positionJob?.cancel()
positionJob = scope.launch {
while (isActive) {
val positionMs = currentPositionMs()
val durationMs = currentDurationMs()
_state.update {
it.copy(
positionMs = positionMs,
durationMs = durationMs,
isPlaying = player.rate > 0f,
)
}
updateNowPlayingInfo()
delay(200)
}
}
}
private fun currentPositionMs(): Long {
val seconds = CMTimeGetSeconds(player.currentTime())
if (seconds.isNaN() || seconds < 0) return 0
return (seconds * 1000).toLong()
}
private fun currentDurationMs(): Long {
val item = player.currentItem ?: return 0
val seconds = CMTimeGetSeconds(item.duration)
if (seconds.isNaN() || seconds.isInfinite() || seconds < 0) return 0
return (seconds * 1000).toLong()
}
/**
* System Now Playing / Dynamic Island / Control Center / lock screen.
* Artwork + album enable the rich Dynamic Island media capsule.
*/
private fun updateNowPlayingInfo() {
val s = _state.value
val current = s.current ?: return
val title = current.musicName ?: return
val artist = current.artist.orEmpty()
val album = current.albumName.orEmpty()
val durationSec = s.durationMs.coerceAtLeast(0) / 1000.0
val elapsedSec = s.positionMs.coerceAtLeast(0) / 1000.0
val rate = if (s.isPlaying) 1.0 else 0.0
val info = mutableMapOf<Any?, Any?>(
MPMediaItemPropertyTitle to title,
MPMediaItemPropertyArtist to artist,
MPMediaItemPropertyAlbumTitle to album,
MPMediaItemPropertyPlaybackDuration to durationSec,
MPNowPlayingInfoPropertyElapsedPlaybackTime to elapsedSec,
MPNowPlayingInfoPropertyPlaybackRate to rate,
)
artworkFor(current)?.let { art ->
info[MPMediaItemPropertyArtwork] = art
}
MPNowPlayingInfoCenter.defaultCenter().nowPlayingInfo = info
}
private fun artworkFor(music: Music): MPMediaItemArtwork? {
val id = music.musicId
if (id != null && id == cachedArtworkMusicId) return cachedArtwork
val raw = resolveCoverUri?.invoke(music) ?: return null
val path = when {
raw.startsWith("file://") -> raw.removePrefix("file://")
raw.startsWith("http://") || raw.startsWith("https://") -> return null
else -> raw
}
val image = UIImage.imageWithContentsOfFile(path) ?: return null
val artwork = MPMediaItemArtwork(boundsSize = image.size) { _ -> image }
cachedArtworkMusicId = id
cachedArtwork = artwork
return artwork
}
actual fun setQueue(items: List<Music>, startIndex: Int) {
queue = items
loopMode = _state.value.loopMode
if (items.isEmpty()) {
player.pause()
player.replaceCurrentItemWithPlayerItem(null)
_state.update {
it.copy(queue = emptyList(), index = -1, current = null, isPlaying = false)
}
return
}
val index = startIndex.coerceIn(0, items.lastIndex)
loadIndex(index, autoPlay = false)
_state.update { it.copy(queue = items, loopMode = loopMode) }
}
actual fun play() {
player.play()
_state.update { it.copy(isPlaying = true, error = null) }
startPositionUpdates()
updateNowPlayingInfo()
}
actual fun pause() {
player.pause()
positionJob?.cancel()
_state.update { it.copy(isPlaying = false) }
updateNowPlayingInfo()
}
actual fun toggle() {
if (_state.value.isPlaying) pause() else play()
}
actual fun seekTo(positionMs: Long) {
val time = CMTimeMake(value = positionMs, timescale = 1000)
player.seekToTime(time)
_state.update { it.copy(positionMs = positionMs) }
updateNowPlayingInfo()
}
actual fun next() {
if (queue.isEmpty()) return
when (loopMode) {
LoopMode.SHUFFLE -> onPlaybackEnded()
else -> {
val next = if (_state.value.index < queue.lastIndex) {
_state.value.index + 1
} else if (loopMode == LoopMode.LIST) {
0
} else {
return
}
loadIndex(next, autoPlay = _state.value.isPlaying)
}
}
}
actual fun previous() {
if (queue.isEmpty()) return
val pos = currentPositionMs()
if (pos > 3000) {
seekTo(0)
return
}
val prev = if (_state.value.index > 0) {
_state.value.index - 1
} else if (loopMode == LoopMode.LIST) {
queue.lastIndex
} else {
0
}
loadIndex(prev, autoPlay = _state.value.isPlaying)
}
actual fun playAtIndex(index: Int) {
if (index !in queue.indices) return
loadIndex(index, autoPlay = _state.value.isPlaying)
}
actual fun setLoopMode(mode: LoopMode) {
loopMode = mode
_state.update { it.copy(loopMode = mode) }
}
actual fun removeAt(index: Int) {
if (index !in queue.indices) return
if (queue.size == 1) {
clearQueue()
return
}
val wasPlaying = _state.value.isPlaying
val currentIndex = _state.value.index
val removingCurrent = index == currentIndex
val newQueue = queue.toMutableList().also { it.removeAt(index) }
queue = newQueue
if (!removingCurrent) {
val newIdx = when {
index < currentIndex -> currentIndex - 1
else -> currentIndex
}.coerceIn(0, newQueue.lastIndex)
_state.update {
it.copy(queue = newQueue, index = newIdx, current = newQueue.getOrNull(newIdx))
}
return
}
val nextIndex = when {
loopMode == LoopMode.SHUFFLE -> Random.nextInt(newQueue.size)
index > newQueue.lastIndex -> 0
else -> index
}
loadIndex(nextIndex.coerceIn(0, newQueue.lastIndex), autoPlay = wasPlaying)
_state.update { it.copy(queue = newQueue) }
}
actual fun clearQueue() {
positionJob?.cancel()
player.pause()
player.replaceCurrentItemWithPlayerItem(null)
queue = emptyList()
cachedArtwork = null
cachedArtworkMusicId = null
MPNowPlayingInfoCenter.defaultCenter().nowPlayingInfo = null
_state.update {
it.copy(
queue = emptyList(),
index = -1,
current = null,
isPlaying = false,
positionMs = 0,
durationMs = 0,
)
}
}
actual fun playUri(uri: String, title: String?) {
val music = Music(
musicId = "local",
musicName = title ?: uri.substringAfterLast('/'),
musicPath = uri,
)
queue = listOf(music)
loopMode = _state.value.loopMode
val url = resolveUrl(uri)
if (url == null) {
_state.update {
it.copy(
current = music,
queue = queue,
index = 0,
isPlaying = false,
error = "Cannot resolve playback URL: $uri",
)
}
return
}
val asset = AVURLAsset(uRL = url, options = null)
val item = AVPlayerItem(asset = asset)
player.replaceCurrentItemWithPlayerItem(item)
player.play()
_state.update {
it.copy(
current = music,
queue = queue,
index = 0,
isPlaying = true,
error = null,
positionMs = 0,
)
}
startPositionUpdates()
updateNowPlayingInfo()
}
actual fun release() {
positionJob?.cancel()
player.pause()
player.replaceCurrentItemWithPlayerItem(null)
endObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) }
endObserver = null
routeObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) }
routeObserver = null
queue = emptyList()
_state.value = PlayerState()
}
private fun loadIndex(index: Int, autoPlay: Boolean) {
val music = queue.getOrNull(index) ?: return
val path = resolvePlaybackUri?.invoke(music) ?: music.musicPath
if (path.isNullOrBlank()) {
_state.update {
it.copy(
index = index,
current = music,
error = "Missing music path",
isPlaying = false,
)
}
return
}
val url = resolveUrl(path)
if (url == null) {
_state.update {
it.copy(
index = index,
current = music,
error = "Cannot resolve URL: $path",
isPlaying = false,
)
}
return
}
val asset = AVURLAsset(uRL = url, options = null)
val item = AVPlayerItem(asset = asset)
player.replaceCurrentItemWithPlayerItem(item)
if (autoPlay) {
player.play()
startPositionUpdates()
} else {
player.pause()
positionJob?.cancel()
}
_state.update {
it.copy(
index = index,
current = music,
isPlaying = autoPlay,
error = null,
positionMs = 0,
)
}
updateNowPlayingInfo()
}
private fun resolveUrl(raw: String): NSURL? {
return when {
raw.startsWith("http://") || raw.startsWith("https://") ||
raw.startsWith("file://") -> NSURL.URLWithString(raw)
raw.startsWith("asset:///") -> {
val name = raw.removePrefix("asset:///").substringBeforeLast('.')
val ext = raw.substringAfterLast('.', "")
val path = NSBundle.mainBundle.pathForResource(name, ext)
path?.let { NSURL.fileURLWithPath(it) }
}
raw.startsWith("asset://") -> {
val name = raw.removePrefix("asset://").trimStart('/').substringBeforeLast('.')
val ext = raw.substringAfterLast('.', "")
val path = NSBundle.mainBundle.pathForResource(name, ext)
path?.let { NSURL.fileURLWithPath(it) }
}
else -> NSURL.fileURLWithPath(raw)
}
}
}