feat: кнопка «⏹ Закрыть» в пульте + автоматика обрыва связи (README «Автоматика»)
- GlassesScreen: кнопка ⏹ Закрыть → sendCommand(CLOSE) (обработчики были: телефон audio.stop, очки MovieController.close) - app-glasses: MovieController.pauseIfPlaying()/resume(); HostConnection — при обрыве WS пауза + флаг, при восстановлении — continue (лог «связь потеряна — пауза» / «связь восстановлена — продолжаю»); флаг сбрасывается на PlayVideo/CLOSE - app-phone: ConnectionAutomatics (connectionEvent, авто-resume по флагу «играл ли») + коллектор на hub.connected - Тесты: app-phone 67/0, app-glasses 15/0 - OFF7 3/3: A5 стоп + обрыв-пауза-продолжение подтверждены живым прогоном
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
/** Событие изменения связи очки↔телефон (для автоматики паузы/продолжения). */
|
||||
internal enum class ConnectionEvent {
|
||||
/** Связь не менялась. */
|
||||
NONE,
|
||||
|
||||
/** Связь потеряна (была → нет). */
|
||||
LOST,
|
||||
|
||||
/** Связь восстановилась (не было → есть). */
|
||||
RESTORED,
|
||||
}
|
||||
|
||||
/** Переход «было подключено / стало подключено» → событие. Чистая функция. */
|
||||
internal fun connectionEvent(prevConnected: Boolean, nowConnected: Boolean): ConnectionEvent = when {
|
||||
prevConnected && !nowConnected -> ConnectionEvent.LOST
|
||||
!prevConnected && nowConnected -> ConnectionEvent.RESTORED
|
||||
else -> ConnectionEvent.NONE
|
||||
}
|
||||
|
||||
/**
|
||||
* Флаг «продолжить звук после восстановления связи»: при обрыве запоминает,
|
||||
* играл ли звук; по восстановлении — отдаёт и сбрасывает.
|
||||
* Чистая логика без Android — покрыта unit-тестами.
|
||||
*/
|
||||
internal class ConnectionAutoResume {
|
||||
|
||||
var resumeAfterReconnect: Boolean = false
|
||||
private set
|
||||
|
||||
/** Обрыв связи: звук продолжится, только если плеер был активен и играл. */
|
||||
fun onLost(audioActive: Boolean, wasPlaying: Boolean) {
|
||||
resumeAfterReconnect = audioActive && wasPlaying
|
||||
}
|
||||
|
||||
/** Восстановление связи: продолжить ли звук (сбрасывает флаг). */
|
||||
fun consumeResume(): Boolean {
|
||||
val shouldResume = resumeAfterReconnect
|
||||
resumeAfterReconnect = false
|
||||
return shouldResume
|
||||
}
|
||||
|
||||
/** Сброс (например, после CLOSE). */
|
||||
fun reset() {
|
||||
resumeAfterReconnect = false
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.media.JellyfinClient
|
||||
import pw.binom.viewmate.core.media.MirrorClient
|
||||
@@ -38,6 +39,9 @@ class PhoneApp : Application() {
|
||||
/** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */
|
||||
val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) }
|
||||
|
||||
/** Автоматика паузы/продолжения звука при обрыве связи. */
|
||||
private val connectionAutoResume = ConnectionAutoResume()
|
||||
|
||||
var actions: PhoneActions? = null
|
||||
private set
|
||||
|
||||
@@ -53,6 +57,33 @@ class PhoneApp : Application() {
|
||||
// каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера
|
||||
server.hub.onPlaybackPosition = { positionMs, playing -> audioSync.sync(positionMs, playing) }
|
||||
|
||||
// автоматика обрыва связи: потеря → пауза, восстановление → продолжить с того же места
|
||||
scope.launch(Dispatchers.Main) {
|
||||
var prevConnected = server.hub.connected.value > 0
|
||||
server.hub.connected.collect { count ->
|
||||
val nowConnected = count > 0
|
||||
when (connectionEvent(prevConnected, nowConnected)) {
|
||||
ConnectionEvent.LOST -> {
|
||||
connectionAutoResume.onLost(audioSync.isActive, state.playing)
|
||||
if (audioSync.isActive) {
|
||||
log("audio", "связь потеряна — пауза")
|
||||
audioSync.pause()
|
||||
}
|
||||
}
|
||||
|
||||
ConnectionEvent.RESTORED -> {
|
||||
if (connectionAutoResume.consumeResume()) {
|
||||
log("audio", "связь восстановлена — продолжаю")
|
||||
audioSync.play()
|
||||
}
|
||||
}
|
||||
|
||||
ConnectionEvent.NONE -> Unit
|
||||
}
|
||||
prevConnected = nowConnected
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
val userId = runCatching { jellyfin.users().firstOrNull()?.Id }.getOrNull()
|
||||
?: ""
|
||||
|
||||
@@ -130,6 +130,7 @@ fun GlassesScreen(
|
||||
RemoteButton("⏪ −30с") { sendCommand(MediaCommand.SEEK_BACKWARD, 30_000) }
|
||||
RemoteButton("▶ Плей") { sendCommand(MediaCommand.PLAY) }
|
||||
RemoteButton("🔀 Режим: Кино") { applyMode(GlassesMode.MOVIE) }
|
||||
RemoteButton("⏹ Закрыть") { sendCommand(MediaCommand.CLOSE) }
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RemoteButton("⏩ +30с") { sendCommand(MediaCommand.SEEK_FORWARD, 30_000) }
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ConnectionAutomaticsTest {
|
||||
|
||||
@Test
|
||||
fun connectionEventTransitions() {
|
||||
assertEquals(ConnectionEvent.NONE, connectionEvent(prevConnected = false, nowConnected = false))
|
||||
assertEquals(ConnectionEvent.RESTORED, connectionEvent(prevConnected = false, nowConnected = true))
|
||||
assertEquals(ConnectionEvent.LOST, connectionEvent(prevConnected = true, nowConnected = false))
|
||||
assertEquals(ConnectionEvent.NONE, connectionEvent(prevConnected = true, nowConnected = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resumeRememberedOnlyWhenSoundWasActiveAndPlaying() {
|
||||
val auto = ConnectionAutoResume()
|
||||
auto.onLost(audioActive = true, wasPlaying = true)
|
||||
assertTrue(auto.resumeAfterReconnect, "плеер был активен и играл — надо продолжить")
|
||||
assertTrue(auto.consumeResume(), "consume отдаёт флаг")
|
||||
assertFalse(auto.resumeAfterReconnect, "consume сбрасывает флаг")
|
||||
assertFalse(auto.consumeResume(), "повторный consume — false")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noResumeWhenSoundWasPaused() {
|
||||
val auto = ConnectionAutoResume()
|
||||
auto.onLost(audioActive = true, wasPlaying = false)
|
||||
assertFalse(auto.resumeAfterReconnect, "звук был на паузе — не продолжаем")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noResumeWhenNoAudioPlayer() {
|
||||
val auto = ConnectionAutoResume()
|
||||
auto.onLost(audioActive = false, wasPlaying = true)
|
||||
assertFalse(auto.resumeAfterReconnect, "плеера нет (CLOSE) — нечего продолжать")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetClearsFlag() {
|
||||
val auto = ConnectionAutoResume()
|
||||
auto.onLost(audioActive = true, wasPlaying = true)
|
||||
auto.reset()
|
||||
assertFalse(auto.resumeAfterReconnect)
|
||||
assertFalse(auto.consumeResume())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user