apps\player\src\main\java\cn\nfarms\player\data\LibraryRepo.kt¶
源码路径:
apps\player\src\main\java\cn\nfarms\player\data\LibraryRepo.kt· 231 行
package cn.nfarms.player.data
import cn.nfarms.core.secure.Crypto
import cn.nfarms.core.store.AppFiles
import cn.nfarms.core.store.JsonStore
import cn.nfarms.core.store.ListSerializer
import java.io.File
import kotlinx.serialization.builtins.serializer
/**
* 曲库 + 播放列表 + 书签的仓库。
*
* **所有写操作都同步落盘**(而不是攒批):播放进度这种数据丢了不影响使用,
* 但「刚加的收藏重启就没了」会让人觉得软件坏了。这里的数据量很小
* (几千条封顶),每次全量写 JSON 完全可接受。
*/
class LibraryRepo(files: AppFiles) {
private val store: JsonStore = files.store("library")
private val mediaDir: File = files.media("audio")
@Volatile
private var state: LibraryState = load()
fun snapshot(): LibraryState = state
private fun load(): LibraryState =
store.read(LibraryState.serializer()) ?: LibraryState()
private fun persist() {
store.write(LibraryState.serializer(), state)
}
/** 把一个音频文件登记进曲库,返回曲目。重复路径会复用已有条目。 */
fun addFile(file: File, title: String? = null): Track {
val key = keyOf(file)
state.tracks.firstOrNull { it.key == key }?.let { return it }
val t = Track(
key = key,
title = title ?: file.nameWithoutExtension,
path = file.absolutePath,
sizeBytes = file.length(),
durationMs = 0,
lyrics = readSidecarLrc(file),
)
state = state.copy(tracks = state.tracks + t)
persist()
return t
}
fun update(track: Track) {
state = state.copy(tracks = state.tracks.map { if (it.key == track.key) track else it })
persist()
}
fun updateAll(tracks: List<Track>) {
val byKey = tracks.associateBy { it.key }
state = state.copy(tracks = state.tracks.map { byKey[it.key] ?: it })
persist()
}
fun remove(key: String) {
state = state.copy(
tracks = state.tracks.filterNot { it.key == key },
playlists = state.playlists.map { it.copy(keys = it.keys - key) },
bookmarks = state.bookmarks.filterNot { it.trackKey == key },
)
persist()
}
fun toggleFavorite(key: String) {
state = state.copy(
tracks = state.tracks.map { if (it.key == key) it.copy(favorite = !it.favorite) else it },
)
persist()
}
fun saveProgress(key: String, positionMs: Long, durationMs: Long) {
state = state.copy(
tracks = state.tracks.map {
if (it.key == key) {
// 播到末尾就当作「听完了」,把进度清零 —— 否则下次一进来就从尾巴放
val pos = if (durationMs > 0 && positionMs >= durationMs - 3_000) 0 else positionMs
it.copy(positionMs = pos, durationMs = durationMs, lastPlayedAt = System.currentTimeMillis())
} else it
},
)
persist()
}
fun setSpeed(speed: Float) {
state = state.copy(speed = speed)
persist()
}
// ------------------------------------------------------------- 播放列表
fun createPlaylist(name: String): Playlist {
val p = Playlist(id = java.util.UUID.randomUUID().toString().take(8), name = name)
state = state.copy(playlists = state.playlists + p)
persist()
return p
}
fun addToPlaylist(playlistId: String, keys: List<String>) {
state = state.copy(
playlists = state.playlists.map {
if (it.id == playlistId) {
it.copy(keys = (it.keys + keys).distinct(), updatedAt = System.currentTimeMillis())
} else it
},
)
persist()
}
fun removeFromPlaylist(playlistId: String, key: String) {
state = state.copy(
playlists = state.playlists.map {
if (it.id == playlistId) it.copy(keys = it.keys - key, updatedAt = System.currentTimeMillis()) else it
},
)
persist()
}
fun deletePlaylist(id: String) {
state = state.copy(playlists = state.playlists.filterNot { it.id == id })
persist()
}
// --------------------------------------------------------------- 书签
fun addBookmark(trackKey: String, positionMs: Long, note: String) {
val b = Bookmark(
id = java.util.UUID.randomUUID().toString().take(8),
trackKey = trackKey,
positionMs = positionMs,
note = note,
)
state = state.copy(bookmarks = state.bookmarks + b)
persist()
}
fun removeBookmark(id: String) {
state = state.copy(bookmarks = state.bookmarks.filterNot { it.id == id })
persist()
}
// --------------------------------------------------------------- 队列
fun setQueue(keys: List<String>, index: Int = 0) {
state = state.copy(queue = keys, queueIndex = index.coerceIn(0, (keys.size - 1).coerceAtLeast(0)))
persist()
}
fun setQueueIndex(i: Int) {
state = state.copy(queueIndex = i)
persist()
}
// --------------------------------------------------------------- 加密
/**
* 用 [Crypto] 把文件加密成 `.nfa`(AES-GCM),并登记成加密曲目。
*
* 为什么是「整文件加密成新文件」而不是「边读边解」:
* ExoPlayer 要能随机 seek,边读边解必须实现一个自定义 DataSource,
* 而 GCM 的认证标签在**尾部** —— 流式解密时你无法在开始播放前就知道
* 后 16 字节有没有被改。所以这条路要做得对,得用「分块 + 每块独立标签」
* 的格式。本轮先用整文件加密(适合有声书/离线包这类场景),
* 并把这个限制写在界面上。
*/
fun encryptTrack(key: String, secrets: cn.nfarms.core.secure.Secrets): Track? {
val t = state.tracks.firstOrNull { it.key == key } ?: return null
val src = File(t.path)
if (!src.isFile) return null
val out = File(mediaDir, src.nameWithoutExtension + ".nfa")
val plain = src.readBytes()
out.writeBytes(Crypto.seal(secrets.dataKey(), plain))
val enc = t.copy(path = out.absolutePath, encrypted = true, key = t.key + "#enc")
state = state.copy(tracks = state.tracks + enc)
persist()
return enc
}
/** 解密到临时文件,交给播放器。**只用于播放,不落明文到私有目录以外**。 */
fun decryptToTemp(track: Track, secrets: cn.nfarms.core.secure.Secrets, tempDir: File): File? {
if (!track.encrypted) return File(track.path).takeIf { it.isFile }
val src = File(track.path)
if (!src.isFile) return null
val out = File(tempDir, src.nameWithoutExtension + ".dec")
return runCatching {
out.writeBytes(Crypto.open(secrets.dataKey(), src.readBytes()))
out
}.getOrNull()
}
// --------------------------------------------------------------- 其它
fun audioDir(): File = mediaDir
fun markSynced(at: Long) {
state = state.copy(lastSyncAt = at)
persist()
}
/** 曲目身份:内容指纹优先,退化为路径哈希。 */
private fun keyOf(file: File): String {
val base = "${file.length()}:${file.name}"
return Crypto.sha256Hex(base).take(16)
}
private fun readSidecarLrc(file: File): String {
val lrc = File(file.parentFile, file.nameWithoutExtension + ".lrc")
return if (lrc.isFile) runCatching { lrc.readText() }.getOrDefault("") else ""
}
/** LRC 与内嵌歌词合并:有独立文件就用文件,否则用内嵌。 */
fun lyricsOf(track: Track): List<LyricLine> {
val path = File(track.path)
val side = File(path.parentFile ?: File("/"), path.nameWithoutExtension + ".lrc")
val text = if (side.isFile) runCatching { side.readText() }.getOrDefault("") else track.lyrics
return Lrc.parse(text)
}
/** 序列化用 —— 让测试能直接比对落盘内容。 */
fun dumpJson(): String = JsonStore.json().encodeToString(LibraryState.serializer(), state)
@Suppress("unused")
private fun unusedListSerializer() = ListSerializer(String.serializer())
}