291 lines
14 KiB
Kotlin
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")
|
|
}
|
|
}
|