import type { Controller } from './controller.js' import type { Intent, ScreenMode } from '../src/types/screen.js' export function object(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Некорректный ответ сервера') return value as Record } export function integer(value: unknown, min = 0, max = Number.MAX_SAFE_INTEGER): number { const n = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value if (typeof n !== 'number' || !Number.isSafeInteger(n) || n < min || n > max) throw new Error('Некорректное число в ответе сервера') return n } export function text(value: unknown): string { if (typeof value !== 'string') throw new Error('Некорректный текст в ответе сервера') return value } function enumeration(value: unknown, prefix: string, names: string[]): number { const n = typeof value === 'string' && value.startsWith(prefix) ? names.indexOf(value.slice(prefix.length)) : integer(value ?? 0) return integer(n, 0, names.length - 1) } function version(value: unknown): bigint { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('Некорректная версия состояния') const v = String(value ?? '0') if (!/^\d{1,19}$/.test(v) || BigInt(v) > 9223372036854775807n) throw new Error('Некорректная версия состояния') 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' } music: { version: bigint playlistId: number | null trackId: number | null trackIds: number[] position: number volume: number loop: boolean playback: number } | null } export function parseRemote(value: unknown): RemoteState { const s = object(value) const mode = enumeration(s.mode, 'SCREEN_MODE_', [ 'UNSPECIFIED', 'COVER', 'INTRO', 'INTRO_DONE', 'CONCLUSION', 'CONCLUSION_DONE', 'RESULTS', 'TIMER', 'TEXT', ]) || 1 const duration = integer(s.timerDurationSeconds ?? 600, 1, 86400) const remaining = integer(s.timerRemainingSeconds ?? 0, 0, duration) const timerState = enumeration(s.timerState, 'SCREEN_TIMER_STATE_', [ 'UNSPECIFIED', 'STOPPED', 'RUNNING', 'PAUSED', 'FINISHED', ]) let music: RemoteState['music'] = null if (s.music) { const m = object(s.music) const p = m.playlist ? object(m.playlist) : null const tracks = p?.tracks ?? [] if (!Array.isArray(tracks)) throw new Error('Некорректный плейлист') const trackIds = tracks.map((t) => integer(object(t).ID, 1)) if (new Set(trackIds).size !== trackIds.length) throw new Error('Повтор трека в плейлисте') const index = integer(m.trackIndex ?? 0) music = { version: version(m.stateVersion), playlistId: p ? integer(p.ID, 1) : null, trackId: trackIds[index] ?? null, trackIds, position: integer(m.positionMs ?? 0) / 1000, volume: integer(m.volume ?? 0, 0, 100), loop: m.loop === true, playback: enumeration(m.playback, 'MUSIC_PLAYBACK_', [ 'UNSPECIFIED', 'STOPPED', 'PLAYING', 'PAUSED', ]), } } return { gameId: s.gameID == null ? null : integer(s.gameID, 1), version: version(s.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', ]), position: integer(s.positionMs ?? 0) / 1000, volume: integer(s.volume ?? 0, 0, 100), sound: s.soundEnabled === true, text: text(s.text ?? '').slice(0, 10000), timer: { label: text(s.timerLabel ?? 'Перерыв').slice(0, 200), durationMs: duration * 1000, remainingMs: remaining * 1000, status: (['stopped', 'stopped', 'running', 'paused', 'finished'] as const)[timerState]!, }, 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) { if (next.sound !== previous.sound) send(controller, { action: 'mute', enabled: !next.sound }) if (next.version > previous.version) { if ( next.command === 0 || (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) { 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) => !playlist.tracks.some((t) => t.id === id)) ) { if (controller.state.channels.music.source) send(controller, { action: 'stop', channel: 'music' }) 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) 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', }) if (m.playback !== 1) send(controller, { action: 'seek', channel: 'music', position: m.position }) } }