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