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 = _movieActive.asStateFlow() private val _title = MutableStateFlow(null) val title: StateFlow = _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") } }