feat: бинокулярный видеорендер в app-glasses + иммерсивный режим + тест-план

- render/: Grafika-связка (EglCore/WindowSurface/FullFrameRect/Texture2dProgram/GlUtil/Drawable2d/EglSurfaceBase) — перенос из pair-view
- ui/BinocularVideoPlayer: один SimpleExoPlayer → SurfaceTexture → EGL-блит в оба глаза (EglVideoRenderer шарится между LEFT/RIGHT)
- ui/ViewUtils: Binocular/BinocularScreenSide/BinocularScreenSize (Compose, из pair-view)
- ui/GlassesMovieScreen: «нос к носу», calculateVideoSizeFull, FLAG_KEEP_SCREEN_ON
- MovieController: PlayVideo/MediaCommand (PLAY/PAUSE/TOGGLE/SEEK/CLOSE), доступ к ExoPlayer только с main-потока
- HostConnection: PlayVideo→плеер, реальная позиция в PlaybackPosition (withContext Main)
- MainActivity: иммерсивный режим (setDecorFitsSystemWindows(false) + hide systemBars)
- TESTING.md: 10 тест-кейсов human-style; QA-прогон (opencode, 88.19) — 10/10 ПРОЙДЕН
This commit is contained in:
Hermes Agent
2026-08-19 04:15:55 +03:00
parent 298d8e0b69
commit 4458678698
16 changed files with 2148 additions and 7 deletions
@@ -0,0 +1,102 @@
package pw.binom.viewmate.glasses
import android.content.Context
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
/**
* Держатель плеера фильма: создаёт/освобождает 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
private val mainHandler = Handler(Looper.getMainLooper())
fun playVideo(msg: PlayVideo) {
mainHandler.post {
val player = _player ?: SimpleExoPlayer.Builder(context).build().also {
_player = it
log("glasses", "SimpleExoPlayer создан")
}
_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.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
_title.value = null
}
}
/** Читать ТОЛЬКО с главного потока. */
fun playbackPosition(): Pair<Long, Boolean> {
val player = _player ?: return 0L to false
return player.currentPosition to player.isPlaying
}
}