Files
view-mate/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MovieController.kt
T
Hermes Agent 4309d4edd2 feat(app-glasses): гашение экрана очков → пауза видео+звука, включение → продолжение
Требование пользователя (железо): погасил экран очков — звук тоже должен
остановиться (раньше ExoPlayer «тормозил», звук играл, синк дёргал звук
к видео). MainActivity: onPause → pauseIfPlaying (флаг resumeAfterScreenOn),
PlaybackPosition(playing=false) → звук пауза ≤5с; onResume → resume +
PlaybackPosition(playing=true) → звук синхронно. MovieController.resume()
получил guard на существующий плеер. Железо: «экран погас — пауза» →
sync стоп → «экран включён — продолжаю» → diff 247.
2026-08-19 21:56:46 +03:00

174 lines
7.3 KiB
Kotlin

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