4309d4edd2
Требование пользователя (железо): погасил экран очков — звук тоже должен остановиться (раньше ExoPlayer «тормозил», звук играл, синк дёргал звук к видео). MainActivity: onPause → pauseIfPlaying (флаг resumeAfterScreenOn), PlaybackPosition(playing=false) → звук пауза ≤5с; onResume → resume + PlaybackPosition(playing=true) → звук синхронно. MovieController.resume() получил guard на существующий плеер. Железо: «экран погас — пауза» → sync стоп → «экран включён — продолжаю» → diff 247.
174 lines
7.3 KiB
Kotlin
174 lines
7.3 KiB
Kotlin
package pw.binom.viewmate.glasses
|
|
|
|
import android.content.Context
|
|
import android.net.Uri
|
|
import android.os.Handler
|
|
import android.os.Looper
|
|
import com.google.android.exoplayer2.MediaItem
|
|
import com.google.android.exoplayer2.SimpleExoPlayer
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.StateFlow
|
|
import kotlinx.coroutines.flow.asStateFlow
|
|
import pw.binom.viewmate.core.MediaCommand
|
|
import pw.binom.viewmate.core.protocol.PlayVideo
|
|
import java.io.File
|
|
|
|
/**
|
|
* Абсолютная цель seek-а: [seekMs] зажимается в [0, durationMs].
|
|
* durationMs ≤ 0 (ещё не известно / нет контента) → зажимаем только снизу.
|
|
* Чистая функция — покрыта unit-тестами без Android.
|
|
*/
|
|
internal fun seekTargetMs(seekMs: Long, durationMs: Long): Long =
|
|
seekMs.coerceIn(0L, durationMs.coerceAtLeast(0L))
|
|
|
|
/**
|
|
* Идентичность контента, играющего на очках (itemId + audioIndex). Хранится
|
|
* отдельно от плеера: телефону она нужна для автоподхвата звука по
|
|
* PlaybackPosition (очки играют — телефон создаёт аудио-плеер).
|
|
*/
|
|
internal data class ContentId(
|
|
val itemId: String? = null,
|
|
val audioIndex: Int = 0,
|
|
)
|
|
|
|
/** Чистая функция: PlayVideo запоминает itemId/audioIndex. */
|
|
internal fun contentIdOnPlayVideo(msg: PlayVideo): ContentId = ContentId(msg.itemId, msg.audioIndex)
|
|
|
|
/** Чистая функция: CLOSE сбрасывает контент — телефон не должен подхватывать звук. */
|
|
internal fun contentIdOnClose(): ContentId = ContentId()
|
|
|
|
/** Снимок позиции плеера для PlaybackPosition (очки → телефон, каждые 5с). */
|
|
internal data class PlaybackSnapshot(
|
|
val positionMs: Long,
|
|
val playing: Boolean,
|
|
val itemId: String?,
|
|
val audioIndex: Int,
|
|
)
|
|
|
|
/**
|
|
* Держатель плеера фильма: создаёт/освобождает SimpleExoPlayer,
|
|
* выполняет команды из WS-протокола. Сам плеер создаётся только по PlayVideo.
|
|
*/
|
|
class MovieController(private val context: Context) {
|
|
|
|
private val _movieActive = MutableStateFlow(false)
|
|
val movieActive: StateFlow<Boolean> = _movieActive.asStateFlow()
|
|
|
|
private val _title = MutableStateFlow<String?>(null)
|
|
val title: StateFlow<String?> = _title.asStateFlow()
|
|
|
|
private var _player: SimpleExoPlayer? = null
|
|
val player: SimpleExoPlayer? get() = _player
|
|
|
|
/** Текущий контент (itemId/audioIndex) — для PlaybackPosition, без обращения к плееру. */
|
|
private var contentId = ContentId()
|
|
|
|
private val mainHandler = Handler(Looper.getMainLooper())
|
|
|
|
fun playVideo(msg: PlayVideo) {
|
|
mainHandler.post {
|
|
// Стриминг отключён полностью: играем ТОЛЬКО локальный файл file://, который есть на диске.
|
|
if (!msg.videoUrl.startsWith("file://")) {
|
|
log("glasses", "PlayVideo отклонён: стриминг запрещён, url не локальный (${msg.videoUrl.take(80)})")
|
|
return@post
|
|
}
|
|
val local = Uri.parse(msg.videoUrl).path?.let { File(it) }
|
|
if (local == null || !local.isFile || local.length() <= 0) {
|
|
log("glasses", "PlayVideo отклонён: локальный файл не найден (${msg.videoUrl})")
|
|
return@post
|
|
}
|
|
val player = _player ?: SimpleExoPlayer.Builder(context).build().also {
|
|
_player = it
|
|
log("glasses", "SimpleExoPlayer создан")
|
|
}
|
|
contentId = contentIdOnPlayVideo(msg)
|
|
_title.value = msg.title
|
|
log("glasses", "PlayVideo: «${msg.title}» — ${msg.videoUrl}")
|
|
player.setMediaItem(MediaItem.fromUri(msg.videoUrl))
|
|
player.prepare()
|
|
player.seekTo(msg.startPositionMs)
|
|
player.playWhenReady = true
|
|
_movieActive.value = true
|
|
}
|
|
}
|
|
|
|
fun applyMediaCommand(command: MediaCommand, seekMs: Long) {
|
|
mainHandler.post {
|
|
val player = _player ?: run {
|
|
log("glasses", "MediaCommand $command проигнорирован: плеер не активен")
|
|
return@post
|
|
}
|
|
when (command) {
|
|
MediaCommand.PLAY -> player.play()
|
|
|
|
MediaCommand.PAUSE -> player.pause()
|
|
|
|
MediaCommand.TOGGLE -> {
|
|
if (player.isPlaying) player.pause() else player.play()
|
|
}
|
|
|
|
MediaCommand.SEEK_FORWARD -> {
|
|
val target = player.currentPosition + seekMs
|
|
player.seekTo(target)
|
|
}
|
|
|
|
MediaCommand.SEEK_BACKWARD -> {
|
|
val target = (player.currentPosition - seekMs).coerceAtLeast(0L)
|
|
player.seekTo(target)
|
|
}
|
|
|
|
MediaCommand.SEEK -> {
|
|
val target = seekTargetMs(seekMs, player.duration)
|
|
player.seekTo(target)
|
|
}
|
|
|
|
// Звук — на телефоне, переключение аудио-дорожек очкам не нужно.
|
|
MediaCommand.NEXT_AUDIO, MediaCommand.PREV_AUDIO -> {
|
|
log("glasses", "MediaCommand $command игнорируется (звук на телефоне)")
|
|
}
|
|
|
|
MediaCommand.CLOSE -> close()
|
|
}
|
|
}
|
|
}
|
|
|
|
fun close() {
|
|
mainHandler.post {
|
|
val player = _player
|
|
_player = null
|
|
if (player != null) {
|
|
log("glasses", "CLOSE: плеер остановлен и освобождён")
|
|
player.playWhenReady = false
|
|
player.stop()
|
|
player.release()
|
|
}
|
|
_movieActive.value = false
|
|
contentId = contentIdOnClose()
|
|
_title.value = null
|
|
}
|
|
}
|
|
|
|
/** Снимок для PlaybackPosition (позиция, playing, itemId, audioIndex). Только с главного потока. */
|
|
internal fun playbackPosition(): PlaybackSnapshot {
|
|
val player = _player ?: return PlaybackSnapshot(0L, false, contentId.itemId, contentId.audioIndex)
|
|
return PlaybackSnapshot(player.currentPosition, player.isPlaying, contentId.itemId, contentId.audioIndex)
|
|
}
|
|
|
|
/** Пауза при потере связи, если плеер играл. Только с главного потока. Возвращает, играл ли. */
|
|
fun pauseIfPlaying(): Boolean {
|
|
val player = _player ?: return false
|
|
val wasPlaying = player.isPlaying
|
|
if (wasPlaying) player.pause()
|
|
return wasPlaying
|
|
}
|
|
|
|
/** Продолжить с того же места (связь восстановилась / экран включён). Только с главного потока. */
|
|
fun resume() {
|
|
val player = _player ?: run {
|
|
log("glasses", "resume проигнорирован: плеер не активен")
|
|
return
|
|
}
|
|
player.play()
|
|
}
|
|
}
|