function adapt
This commit is contained in:
+1
-1
@@ -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')
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.zhushenwudi.lovelivemusicplayer.i18n
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
actual fun detectSystemLang(): AppLang = langFromTag(Locale.getDefault().toLanguageTag())
|
||||
+7
@@ -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
-1
@@ -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
|
||||
+2
-2
@@ -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
|
||||
+15
-4
@@ -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)
|
||||
+31
-14
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-24
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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
|
||||
+157
-24
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package top.zhushenwudi.llmp.settings
|
||||
package com.zhushenwudi.lovelivemusicplayer.settings
|
||||
|
||||
import android.content.Context
|
||||
import com.russhwolf.settings.Settings
|
||||
+10
@@ -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()
|
||||
}
|
||||
}
|
||||
+71
-57
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package top.zhushenwudi.llmp.db
|
||||
package com.zhushenwudi.lovelivemusicplayer.db
|
||||
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
|
||||
+49
-46
@@ -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
|
||||
+2
-2
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package top.zhushenwudi.llmp.domain
|
||||
package com.zhushenwudi.lovelivemusicplayer.domain
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package top.zhushenwudi.llmp.lyric
|
||||
package com.zhushenwudi.lovelivemusicplayer.lyric
|
||||
|
||||
data class LyricLine(
|
||||
val timeMs: Long,
|
||||
+44
-37
@@ -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"
|
||||
+2
-17
@@ -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'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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,
|
||||
+2
-2
@@ -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)
|
||||
+56
@@ -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
|
||||
}
|
||||
}
|
||||
+259
@@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
+40
@@ -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
|
||||
+72
-40
@@ -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",
|
||||
)
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+235
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package top.zhushenwudi.llmp.player
|
||||
package com.zhushenwudi.lovelivemusicplayer.player
|
||||
|
||||
enum class LoopMode {
|
||||
LIST,
|
||||
+54
@@ -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()
|
||||
}
|
||||
+2
-2
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package top.zhushenwudi.llmp.player
|
||||
package com.zhushenwudi.lovelivemusicplayer.player
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
+6
-6
@@ -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(
|
||||
+4
-4
@@ -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,
|
||||
}
|
||||
+19
-14
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package top.zhushenwudi.llmp.settings
|
||||
package com.zhushenwudi.lovelivemusicplayer.settings
|
||||
|
||||
import com.russhwolf.settings.Settings
|
||||
|
||||
+4
-4
@@ -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"
|
||||
}
|
||||
+15
-15
@@ -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()) {
|
||||
+25
-24
@@ -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
|
||||
+102
-39
@@ -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()
|
||||
}
|
||||
}
|
||||
+69
@@ -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 = "",
|
||||
)
|
||||
+19
@@ -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")
|
||||
}
|
||||
}
|
||||
+161
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package top.zhushenwudi.llmp.lyric
|
||||
package com.zhushenwudi.lovelivemusicplayer.lyric
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package top.zhushenwudi.llmp.platform
|
||||
package com.zhushenwudi.lovelivemusicplayer.platform
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
+3
-3
@@ -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
-1
@@ -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)
|
||||
}
|
||||
+12
@@ -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
-1
@@ -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
|
||||
+6
-1
@@ -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")
|
||||
}
|
||||
}
|
||||
+38
-2
@@ -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()
|
||||
+369
@@ -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(),
|
||||
)
|
||||
}
|
||||
+61
@@ -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
|
||||
}
|
||||
+191
@@ -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()
|
||||
}
|
||||
}
|
||||
+921
@@ -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
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user