import { fieldError, ResponseFieldError } from './request-error.js' import type { Controller } from './controller.js' import type { Intent, ScreenMode } from '../src/types/screen.js' export function object(value: unknown, field?: string): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) throw fieldError(field, 'объект', value, 'Некорректный ответ сервера') return value as Record } export function integer( value: unknown, min = 0, max = Number.MAX_SAFE_INTEGER, field?: string, ): number { const n = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value if (typeof n !== 'number' || !Number.isSafeInteger(n) || n < min || n > max) throw fieldError( field, `целое число от ${min} до ${max}`, value, 'Некорректное число в ответе сервера', ) return n } export function text(value: unknown, field?: string): string { if (typeof value !== 'string') throw fieldError(field, 'строка', value, 'Некорректный текст в ответе сервера') return value } function enumeration(value: unknown, prefix: string, names: string[], field?: string): number { const n = typeof value === 'string' && value.startsWith(prefix) ? names.indexOf(value.slice(prefix.length)) : integer(value ?? 0, 0, Number.MAX_SAFE_INTEGER, field) if (n < 0 || n >= names.length) throw fieldError(field, `enum ${prefix}`, value, 'Некорректный enum в ответе сервера') return integer(n, 0, names.length - 1, field) } function version(value: unknown, field: string): bigint { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new ResponseFieldError( field, 'неотрицательный int64 без потери точности', value, 'Некорректная версия состояния', ) const v = String(value ?? '0') if (!/^\d{1,19}$/.test(v) || BigInt(v) > 9223372036854775807n) throw new ResponseFieldError( field, 'неотрицательный int64 без потери точности', value, 'Некорректная версия состояния', ) return BigInt(v) } export interface RemoteState { gameId: number | null version: bigint mode: ScreenMode command: number position: number volume: number sound: boolean text: string timer: { label: string durationMs: number remainingMs: number status: 'stopped' | 'running' | 'paused' | 'finished' } resources: { cover: string; background: string; intro: string; conclusion: string } music: { version: bigint playlistId: number | null trackId: number | null trackIds: number[] filenames: string[] position: number volume: number loop: boolean playback: number } | null } export function parseRemote(value: unknown): RemoteState { const s = object(value, 'state') const mode = enumeration( s.mode, 'SCREEN_MODE_', [ 'UNSPECIFIED', 'COVER', 'INTRO', 'INTRO_DONE', 'CONCLUSION', 'CONCLUSION_DONE', 'RESULTS', 'TIMER', 'TEXT', ], 'state.mode', ) || 1 const duration = integer(s.timerDurationSeconds ?? 600, 1, 86400, 'state.timerDurationSeconds') const remaining = integer( s.timerRemainingSeconds ?? 0, 0, duration, 'state.timerRemainingSeconds', ) const timerState = enumeration( s.timerState, 'SCREEN_TIMER_STATE_', ['UNSPECIFIED', 'STOPPED', 'RUNNING', 'PAUSED', 'FINISHED'], 'state.timerState', ) let music: RemoteState['music'] = null if (s.music) { const m = object(s.music, 'state.music') const p = m.playlist ? object(m.playlist, 'state.music.playlist') : null const tracks = p?.tracks ?? [] if (!Array.isArray(tracks)) throw new ResponseFieldError( 'state.music.playlist.tracks', 'массив', tracks, 'Некорректный плейлист', ) const trackIds = tracks.map((t, i) => integer( object(t, `state.music.playlist.tracks[${i}]`).ID, 1, Number.MAX_SAFE_INTEGER, `state.music.playlist.tracks[${i}].ID`, ), ) if (new Set(trackIds).size !== trackIds.length) throw new ResponseFieldError( 'state.music.playlist.tracks', 'уникальные ID', tracks, 'Повтор трека в плейлисте', ) const index = integer(m.trackIndex ?? 0, 0, Number.MAX_SAFE_INTEGER, 'state.music.trackIndex') music = { version: version(m.stateVersion, 'state.music.stateVersion'), playlistId: p ? integer(p.ID, 1, Number.MAX_SAFE_INTEGER, 'state.music.playlist.ID') : null, trackId: trackIds[index] ?? null, trackIds, filenames: tracks.map((t, i) => text(object(t).filename ?? '', `state.music.playlist.tracks[${i}].filename`), ), position: integer(m.positionMs ?? 0, 0, Number.MAX_SAFE_INTEGER, 'state.music.positionMs') / 1000, volume: integer(m.volume ?? 0, 0, 100, 'state.music.volume'), loop: m.loop === true, playback: enumeration( m.playback, 'MUSIC_PLAYBACK_', ['UNSPECIFIED', 'STOPPED', 'PLAYING', 'PAUSED'], 'state.music.playback', ), } } return { gameId: s.gameID == null ? null : integer(s.gameID, 1, Number.MAX_SAFE_INTEGER, 'state.gameID'), version: version(s.stateVersion, 'state.stateVersion'), mode: mode as ScreenMode, command: enumeration( s.command, 'SCREEN_COMMAND_', [ 'UNSPECIFIED', 'PLAY', 'PAUSE', 'RESUME', 'STOP', 'REPLAY', 'SEEK', 'VOLUME', 'TIMER_START', 'TIMER_PAUSE', 'TIMER_RESUME', 'TIMER_RESET', ], 'state.command', ), position: integer(s.positionMs ?? 0, 0, Number.MAX_SAFE_INTEGER, 'state.positionMs') / 1000, volume: integer(s.volume ?? 0, 0, 100, 'state.volume'), sound: s.soundEnabled === true, text: text(s.text ?? '', 'state.text').slice(0, 10000), timer: { label: text(s.timerLabel ?? 'Перерыв', 'state.timerLabel').slice(0, 200), durationMs: duration * 1000, remainingMs: remaining * 1000, status: (['stopped', 'stopped', 'running', 'paused', 'finished'] as const)[timerState]!, }, resources: { cover: text(s.coverImage ?? '', 'state.coverImage'), background: text(s.backgroundImage ?? '', 'state.backgroundImage'), intro: text(s.introAudio ?? '', 'state.introAudio'), conclusion: text(s.conclusionVideo ?? '', 'state.conclusionVideo'), }, music, } } export function send(controller: Controller, intent: Intent) { controller.command({ ...intent, session: controller.state.session, sequence: controller.state.sequence + 1, }) } // Сравниваем с предыдущим ответом сервера, а не с локальными действиями ведущего. export function applyRemote(controller: Controller, previous: RemoteState, next: RemoteState) { const old = previous.music const music = next.music // Новая версия музыки означает отдельную серверную команду, даже при тех же значениях. const musicChanged = !!old && !!music && music.version > old.version controller.synchronizeRemote(() => applyRemoteState(controller, previous, next), musicChanged) } function applyRemoteState(controller: Controller, previous: RemoteState, next: RemoteState) { if (next.sound !== previous.sound) send(controller, { action: 'mute', enabled: !next.sound }) if (next.version > previous.version) { if ( next.command === 0 || controller.state.scene.mode !== next.mode || ([2, 4].includes(next.mode) && controller.state.channels.main.source?.url !== controller.state.catalog.media.find( (source) => source.kind === (next.mode === 2 ? 'audio' : 'video'), )?.url) || JSON.stringify(next.resources) !== JSON.stringify(previous.resources) || (next.command >= 8 && controller.state.scene.mode !== 7) || next.mode !== previous.mode || next.text !== previous.text || (next.mode === 7 && (next.timer.label !== previous.timer.label || next.timer.durationMs !== previous.timer.durationMs)) ) { if (next.mode === 8 && next.text.trim()) send(controller, { action: 'text', text: next.text }) else send(controller, { action: 'mode', mode: next.mode }) } if (next.mode === 7) controller.synchronizeTimer(next.timer) if (next.command > 0 && next.command < 8) { if (next.command === 7) send(controller, { action: 'volume', channel: 'main', volume: next.volume }) else if (next.mode === 2 || next.mode === 4) { if (controller.state.scene.mode !== next.mode) send(controller, { action: 'mode', mode: next.mode }) send(controller, { action: 'volume', channel: 'main', volume: next.volume }) const action = (['', 'play', 'pause', 'resume', 'stop', 'replay', 'seek'] as const)[ next.command ]! if (action === 'seek') send(controller, { action, channel: 'main', position: next.position }) else if (action) { send(controller, { action, channel: 'main' }) } } } } const m = next.music if (!m || (previous.music && m.version <= previous.music.version)) return if (!m.playlistId || !m.trackIds.length) { if (controller.state.channels.music.source) send(controller, { action: 'stop', channel: 'music' }) return } const playlist = controller.state.catalog.playlists.find((p) => p.id === m.playlistId) if ( !playlist || playlist.tracks.length !== m.trackIds.length || m.trackIds.some((id, index) => playlist.tracks[index]!.id !== id) ) { throw new Error('Музыка ожидает подготовки локальных файлов') } const index = playlist.tracks.findIndex((t) => t.id === m.trackId) if (index < 0) throw new Error('Трек отсутствует в подготовленных материалах') const old = previous.music const transportChanged = !old || old.playlistId !== m.playlistId || old.trackId !== m.trackId || old.playback !== m.playback || old.position !== m.position if (transportChanged) { const c = controller.state.channels.music if (c.playlistId !== playlist.id) send(controller, { action: 'playlist', channel: 'music', playlistId: playlist.id }) if ( controller.state.channels.music.trackIndex !== index || controller.state.channels.music.source?.url !== playlist.tracks[index]?.source.url ) send(controller, { action: 'track', channel: 'music', trackIndex: index }) } send(controller, { action: 'volume', channel: 'music', volume: m.volume }) send(controller, { action: 'loop', channel: 'music', enabled: m.loop }) if (transportChanged) { send(controller, { action: m.playback === 2 ? 'resume' : m.playback === 3 ? 'pause' : 'stop', channel: 'music', }) const pausingSameTrack = m.playback === 3 && old?.playback === 2 && old.playlistId === m.playlistId && old.trackId === m.trackId // A pause keeps the audible position so a seek cannot cancel its fade-out. if (m.playback !== 1 && !pausingSameTrack) send(controller, { action: 'seek', channel: 'music', position: m.position }) } }