225 lines
8.1 KiB
TypeScript
225 lines
8.1 KiB
TypeScript
import type { Controller } from './controller.js'
|
|
import type { Intent, ScreenMode } from '../src/types/screen.js'
|
|
|
|
export function object(value: unknown): Record<string, unknown> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
throw new Error('Некорректный ответ сервера')
|
|
return value as Record<string, unknown>
|
|
}
|
|
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, index) => playlist.tracks[index]!.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 })
|
|
}
|
|
}
|