Files
view-mate/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MovieController.kt
T

291 lines
14 KiB
Kotlin

package pw.binom.viewmate.glasses
import android.content.Context
import android.net.Uri
import android.net.wifi.WifiManager
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.MediaCommandMsg
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,
)
/** Порог «убегания» позиции вперёд: больше этого — откат к последней реальной. */
internal const val POSITION_DRIFT_THRESHOLD_MS = 5_000L
/**
* Чистая функция: если [currentMs] ушёл вперёд от [lastGoodMs] больше чем на
* порог (гашение экрана ExoPlayer набегает позицию по системным часам) — цель
* отката [lastGoodMs], иначе null (позиция реальная — оставляем).
*/
internal fun rollbackTargetMs(currentMs: Long, lastGoodMs: Long): Long? =
if (lastGoodMs > 0 && currentMs - lastGoodMs > POSITION_DRIFT_THRESHOLD_MS) lastGoodMs else null
/** Порог скачка позиции вперёд за один 5с-цикл: больше — мусор ExoPlayer (игнор). */
internal const val POSITION_JUMP_THRESHOLD_MS = 30_000L
/**
* Чистая функция: защита markPosition от мусорных скачков. При гашении экрана
* ExoPlayer «врёт» позицию (могла прыгнуть за 5с на 42 мин). Если новая
* позиция ушла вперёд от последней достоверной больше чем на порог — игнор
* (возвращает [lastGoodMs]), иначе позиция достоверна ([positionMs]).
* [trusted] — позиция задана пользователем (seek): достоверна всегда,
* защита от мусорных скачков отключается.
*/
internal fun markPositionTargetMs(positionMs: Long, lastGoodMs: Long, trusted: Boolean = false): Long =
if (!trusted && lastGoodMs > 0 && positionMs - lastGoodMs > POSITION_JUMP_THRESHOLD_MS) lastGoodMs else positionMs
/** Чистая функция: 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,
/** Длительность видео, мс; -1 = неизвестно (плеера нет / ещё не prepared). */
val durationMs: Long = -1L,
)
/**
* Держатель плеера фильма: создаёт/освобождает 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())
/** Колбэк диагностических событий (markPosition/rollback/resume/WifiLock) — пишется в events.log извне. */
var eventLogger: ((String) -> Unit)? = null
/**
* Последняя РЕАЛЬНАЯ позиция (из 5с-цикла отправки, когда играем). При
* resume откатываем плеер, если текущая позиция «убежала» далеко вперёд
* (гашение экрана ExoPlayer набегает позицию по системным часам).
* @Volatile — пишется из IO-цикла HostConnection, читается на главном.
*/
@Volatile
private var lastGoodPositionMs: Long = 0
/**
* WifiLock удерживает WiFi-радио очков в активном состоянии, пока идёт
* просмотр. Без него при гашении экрана радио уходит в сон, и пауза/
* resume-пакеты лежат в стеке ~15с, пока радио не проснётся.
*/
private var wifiLock: WifiManager.WifiLock? = null
private fun releaseWifiLock() {
wifiLock?.let {
if (it.isHeld) {
it.release()
eventLogger?.invoke("WifiLock released")
log("glasses", "WifiLock released")
}
}
wifiLock = null
}
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
ensureWifiLock()
}
}
/**
* Гарантировать WifiLock: создать, если ещё нет, либо повторно захватить,
* если система его сбросила (isHeld=false). Вызывается при старте просмотра
* и в точках паузы при гашении экрана (пауза/resume должны уходить без
* задержек). Lock создаётся только при активном просмотре.
*/
fun ensureWifiLock() {
if (!_movieActive.value) return
if (wifiLock == null) {
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
wifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "viewmate-playback").also {
it.setReferenceCounted(false)
it.acquire()
}
eventLogger?.invoke("WifiLock acquired")
log("glasses", "WifiLock acquired (просмотр активен)")
} else if (!wifiLock!!.isHeld) {
wifiLock!!.acquire()
eventLogger?.invoke("WifiLock re-acquired")
log("glasses", "WifiLock re-acquired (не был удержан)")
}
}
/** Команда от жеста очков: MediaCommandMsg из протокола (как из телефона). */
fun applyMediaCommand(msg: MediaCommandMsg, onDone: (() -> Unit)? = null) =
applyMediaCommand(msg.command, msg.seekMs, onDone)
fun applyMediaCommand(command: MediaCommand, seekMs: Long, onDone: (() -> Unit)? = null) {
mainHandler.post {
val player = _player ?: run {
log("glasses", "MediaCommand $command проигнорирован: плеер не активен")
onDone?.invoke()
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)
markPosition(player.currentPosition, trusted = true)
}
MediaCommand.SEEK_BACKWARD -> {
val target = (player.currentPosition - seekMs).coerceAtLeast(0L)
player.seekTo(target)
markPosition(player.currentPosition, trusted = true)
}
MediaCommand.SEEK -> {
val target = seekTargetMs(seekMs, player.duration)
player.seekTo(target)
markPosition(player.currentPosition, trusted = true)
}
// Звук — на телефоне, переключение аудио-дорожек очкам не нужно.
MediaCommand.NEXT_AUDIO, MediaCommand.PREV_AUDIO -> {
log("glasses", "MediaCommand $command игнорируется (звук на телефоне)")
}
MediaCommand.CLOSE -> close()
}
onDone?.invoke()
}
}
fun close() {
mainHandler.post {
val player = _player
_player = null
if (player != null) {
log("glasses", "CLOSE: плеер остановлен и освобождён")
player.playWhenReady = false
player.stop()
player.release()
}
releaseWifiLock()
_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.playWhenReady, contentId.itemId, contentId.audioIndex, player.duration)
}
/** Пауза при потере связи, если плеер играл. Только с главного потока. Возвращает, играл ли. */
fun pauseIfPlaying(): Boolean {
val player = _player ?: return false
val wasPlaying = player.playWhenReady
if (wasPlaying) player.pause()
return wasPlaying
}
/** Продолжить с того же места (связь восстановилась / экран включён). Только с главного потока. */
fun resume() {
val player = _player ?: run {
log("glasses", "resume проигнорирован: плеер не активен")
return
}
val current = player.currentPosition
rollbackTargetMs(current, lastGoodPositionMs)?.let { target ->
log("glasses", "позиция убежала: $current → откат к $target")
eventLogger?.invoke("rollback: current=$current → lastGood=$lastGoodPositionMs")
player.seekTo(target)
} ?: eventLogger?.invoke("resume ok: pos=$current lastGood=$lastGoodPositionMs")
player.play()
}
/**
* Запомнить последнюю реальную позицию — вызывается из 5с-цикла отправки
* PlaybackPosition, ТОЛЬКО когда играем. Потокобезопасно (@Volatile).
* Скачок вперёд >30с за один цикл — мусор ExoPlayer при гашении экрана:
* игнорируется, lastGood остаётся последней достоверной.
* [trusted] — позиция задана пользователем (seek через трекбар): считается
* достоверной сразу, чтобы rollback при resume откатил к ней, а не к началу.
*/
fun markPosition(positionMs: Long, trusted: Boolean = false) {
val target = markPositionTargetMs(positionMs, lastGoodPositionMs, trusted)
if (target != positionMs) {
eventLogger?.invoke("markPosition: скачок $lastGoodPositionMs → $positionMs (игнор)")
return
}
lastGoodPositionMs = target
eventLogger?.invoke("markPosition=$positionMs")
}
}