apps\player\src\main\java\cn\nfarms\player\PlayerViewModel.kt¶
源码路径:
apps\player\src\main\java\cn\nfarms\player\PlayerViewModel.kt· 367 行
package cn.nfarms.player
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import cn.nfarms.player.data.Bookmark
import cn.nfarms.player.data.LibraryState
import cn.nfarms.player.data.LyricLine
import cn.nfarms.player.data.Playlist
import cn.nfarms.player.data.Track
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class PlayerUi(
val lib: LibraryState = LibraryState(),
val current: Track? = null,
val playing: Boolean = false,
val positionMs: Long = 0,
val durationMs: Long = 0,
val speed: Float = 1f,
val lyrics: List<LyricLine> = emptyList(),
val bookmarks: List<Bookmark> = emptyList(),
val playlists: List<Playlist> = emptyList(),
val message: String? = null,
val connected: Boolean = false,
/** 正在解密/起播 —— 加密曲目要等一下,界面必须显示,否则像卡死 */
val preparing: Boolean = false,
)
/**
* 播放逻辑。
*
* 状态分两层,这个划分很重要:
* * **持久层**(音量以外的一切):曲库、进度、书签 —— 在 [LibraryRepo],落盘;
* * **瞬时层**:是否正在播、当前位置 —— 在 ExoPlayer 里,每秒采样一次。
*
* 不把「当前位置」也落盘,是因为它每秒都变,写盘会把闪存写穿;
* 只在**暂停 / 切曲 / 退出**时把当前值写一次。
*/
class PlayerViewModel(private val c: Container) : ViewModel() {
private val repo = c.repo
private var controller: MediaController? = null
private val _ui = MutableStateFlow(PlayerUi(lib = repo.snapshot()))
val ui: StateFlow<PlayerUi> = _ui.asStateFlow()
init {
// 首次启动准备演示曲库
viewModelScope.launch(Dispatchers.IO) {
c.ensureDemoLibrary()
withContext(Dispatchers.Main) { refreshLibrary() }
}
connect()
// 位置采样:200ms 一次。再快没有意义(屏幕刷新才 60Hz),再慢歌词跟不上。
viewModelScope.launch {
while (true) {
sample()
delay(200)
}
}
}
private fun connect() {
c.connectPlayer { ctrl ->
controller = ctrl
ctrl.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
_ui.value = _ui.value.copy(playing = isPlaying)
if (!isPlaying) persistProgress()
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_READY) {
_ui.value = _ui.value.copy(
durationMs = ctrl.duration.coerceAtLeast(0),
preparing = false,
)
// 起播后跳回上次位置(断点续播)
val t = currentTrack()
if (t != null && t.hasProgress && ctrl.currentPosition < 2_000) {
ctrl.seekTo(t.positionMs)
}
}
if (playbackState == Player.STATE_ENDED) {
persistProgress()
next()
}
}
override fun onPlayerError(error: PlaybackException) {
_ui.value = _ui.value.copy(
preparing = false,
message = "播放失败:${error.errorCodeName} — ${error.message}",
)
}
})
_ui.value = _ui.value.copy(connected = true)
ctrl.setPlaybackParameters(PlaybackParameters(repo.snapshot().speed.coerceIn(0.5f, 3f)))
}
}
// ------------------------------------------------------------- 曲库操作
fun dismissMessage() { _ui.value = _ui.value.copy(message = null) }
fun refreshLibrary() {
_ui.value = _ui.value.copy(
lib = repo.snapshot(),
playlists = repo.snapshot().playlists,
bookmarks = repo.snapshot().bookmarks,
speed = repo.snapshot().speed,
)
}
fun addLocalFile(path: String) {
val f = File(path)
if (!f.isFile) {
_ui.value = _ui.value.copy(message = "文件不存在:$path")
return
}
viewModelScope.launch(Dispatchers.IO) {
repo.addFile(f)
withContext(Dispatchers.Main) { refreshLibrary() }
}
}
fun toggleFavorite(key: String) {
repo.toggleFavorite(key)
refreshLibrary()
}
fun remove(key: String) {
repo.remove(key)
if (_ui.value.current?.key == key) controller?.stop()
refreshLibrary()
}
// --------------------------------------------------------------- 播放
private fun currentTrack(): Track? =
controller?.currentMediaItem?.mediaId?.let { id -> repo.snapshot().tracks.firstOrNull { it.key == id } }
?: _ui.value.current
/**
* 播放一首歌。
*
* **必须把整个曲库建成播放队列**,不能只 `setMediaItem` 一首 ——
* 只放一首时 `seekToNextMediaItem()` / `seekToPreviousMediaItem()`
* 会静默失败(队列里没有"下一首"),表现就是「上一首/下一首按钮点了没反应」。
* 这个 bug 在只放单曲的实现里非常常见,而且**不报任何错**。
*
* 加密曲目不能在列表预加载时解密(那会把所有密钥都解出来),
* 所以这里对加密项按需解密成临时文件,再用它建 [MediaItem]。
* `mediaId` 一律设成曲目 key —— 进度落盘与"当前曲目"都靠它反查。
*/
fun play(track: Track) {
val ctrl = controller ?: run {
_ui.value = _ui.value.copy(message = "播放服务还没连上,稍等一下")
return
}
_ui.value = _ui.value.copy(preparing = true, current = track, lyrics = repo.lyricsOf(track))
viewModelScope.launch(Dispatchers.IO) {
val library = repo.snapshot().tracks
val playable = library.mapNotNull { t ->
val src = if (t.encrypted) repo.decryptToTemp(t, c.secrets, c.tempDir) else File(t.path).takeIf { it.isFile }
src?.let { t to it }
}
val index = playable.indexOfFirst { it.first.key == track.key }
withContext(Dispatchers.Main) {
if (index < 0) {
_ui.value = _ui.value.copy(preparing = false, message = "文件不在了:${track.path}")
return@withContext
}
val items = playable.map { (t, f) ->
MediaItem.Builder()
.setUri(f.toURI().toString())
.setMediaId(t.key)
.setMediaMetadata(
androidx.media3.common.MediaMetadata.Builder()
.setTitle(t.title)
.setArtist(t.artist)
.build(),
)
.build()
}
ctrl.setMediaItems(items, index, 0L)
ctrl.prepare()
ctrl.play()
repo.setQueue(library.map { it.key }, index)
_ui.value = _ui.value.copy(preparing = false)
}
}
}
fun toggle() {
val ctrl = controller ?: return
if (ctrl.isPlaying) {
ctrl.pause()
persistProgress()
} else {
ctrl.play()
}
}
fun seekTo(ms: Long) {
controller?.seekTo(ms.coerceAtLeast(0))
}
fun seekBy(deltaMs: Long) {
val ctrl = controller ?: return
ctrl.seekTo((ctrl.currentPosition + deltaMs).coerceIn(0, ctrl.duration.coerceAtLeast(0)))
}
fun next() {
val ctrl = controller ?: return
persistProgress()
ctrl.seekToNextMediaItem()
controller?.play()
}
fun prev() {
val ctrl = controller ?: return
persistProgress()
ctrl.seekToPreviousMediaItem()
controller?.play()
}
/**
* 变速。**耳机/播放器里这一步最容易被漏掉**:只改 ExoPlayer 的参数而不落盘,
* 下次启动就回到 1.0 —— 有声书用户会很烦。所以这里同时写 repo。
*/
fun setSpeed(speed: Float) {
val s = speed.coerceIn(0.5f, 3f)
controller?.setPlaybackParameters(PlaybackParameters(s))
repo.setSpeed(s)
_ui.value = _ui.value.copy(speed = s)
}
// --------------------------------------------------------------- 书签
fun addBookmarkHere(note: String) {
val t = currentTrack() ?: return
val pos = controller?.currentPosition ?: _ui.value.positionMs
repo.addBookmark(t.key, pos, note)
refreshLibrary()
}
fun removeBookmark(id: String) {
repo.removeBookmark(id)
refreshLibrary()
}
fun jumpToBookmark(b: Bookmark) {
val t = repo.snapshot().tracks.firstOrNull { it.key == b.trackKey } ?: return
if (currentTrack()?.key != t.key) play(t)
seekTo(b.positionMs)
}
// ------------------------------------------------------------- 播放列表
fun createPlaylist(name: String) {
repo.createPlaylist(name)
refreshLibrary()
}
fun addCurrentToPlaylist(id: String) {
val t = currentTrack() ?: return
repo.addToPlaylist(id, listOf(t.key))
refreshLibrary()
_ui.value = _ui.value.copy(message = "已加入播放列表")
}
fun deletePlaylist(id: String) {
repo.deletePlaylist(id)
refreshLibrary()
}
/** 「列表同步」:把当前曲目按路径增量合并进一个固定名播放列表。 */
fun syncPlaylist() {
val tracks = repo.snapshot().tracks
val name = "本地扫描"
val p = repo.snapshot().playlists.firstOrNull { it.name == name } ?: repo.createPlaylist(name)
repo.addToPlaylist(p.id, tracks.map { it.key })
repo.markSynced(System.currentTimeMillis())
refreshLibrary()
_ui.value = _ui.value.copy(message = "同步完成:${tracks.size} 首")
}
// --------------------------------------------------------------- 内部
/**
* 位置采样。
*
* # 为什么不能每 200ms 无条件写一次 UI 状态
*
* [PlayerUi] 是一个 data class,`copy()` 出来的**引用变了**,Compose 会认为
* 整棵界面都该重组 —— 包括曲库列表、书签列表、歌词全部 100 多行。
* 每秒 5 次全树重组在低端机上是肉眼可见的掉帧,在长歌词页更明显。
*
* 所以这里按「值真的变了」才发布:
* * 位置变化不到 250ms 不更新(进度条本来就是按视频宽度画的,看不出差别);
* * 时长/播放状态没变就不动这两个字段。
*
* `copy()` 依旧是新对象,但**只在需要时才产生**,重组频率降到约 4 次/秒
* 且只发生在"进度真的走了"的帧上。
*/
private fun sample() {
val ctrl = controller ?: return
val pos = ctrl.currentPosition.coerceAtLeast(0)
val dur = ctrl.duration.coerceAtLeast(0)
val cur = _ui.value
val nextDur = if (dur > 0) dur else cur.durationMs
val playing = ctrl.isPlaying
val posChanged = kotlin.math.abs(pos - cur.positionMs) >= 250
val metaChanged = nextDur != cur.durationMs || playing != cur.playing
if (posChanged || metaChanged) {
_ui.value = cur.copy(
positionMs = pos,
durationMs = nextDur,
playing = playing,
)
}
// 每 5 秒把进度落一次盘:既不会写穿闪存,崩了也只丢几秒。
// 用「上次落盘位置」判断,而不是 `pos % 5000 < 200` —— 后者在采样抖动时
// 可能整个区间都错过(比如某帧落在 5200、下一帧 5600)。
if (pos - lastPersistedPos >= 5_000 || (pos < lastPersistedPos && pos > 0)) {
persistProgress()
lastPersistedPos = pos
}
}
/** 上次落盘的位置(只在 [sample] 与切歌/暂停时更新)。 */
private var lastPersistedPos: Long = 0
private fun persistProgress() {
val ctrl = controller ?: return
val item = ctrl.currentMediaItem?.mediaId ?: return
val dur = ctrl.duration.coerceAtLeast(0)
if (dur <= 0) return
val pos = ctrl.currentPosition
// 落盘是同步 I/O:放到后台线程,主线程只负责算"该不该写"
viewModelScope.launch(Dispatchers.IO) {
repo.saveProgress(item, pos, dur)
}
}
override fun onCleared() {
persistProgress()
controller?.release()
controller = null
super.onCleared()
}
}