4458678698
- 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 ПРОЙДЕН
103 lines
3.7 KiB
Kotlin
103 lines
3.7 KiB
Kotlin
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
|
|
}
|
|
}
|