跳转至

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()
    }
}