330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
import { parseTimerType } from './screen-plan.js'
|
|
import { timerLabels, validQR } from '../src/utils/presentation.js'
|
|
import type { TimerType } from '../src/types/screen.js'
|
|
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<string, unknown> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
throw fieldError(field, 'объект', value, 'Некорректный ответ сервера')
|
|
return value as Record<string, unknown>
|
|
}
|
|
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
|
|
qrUrl: string
|
|
timer: {
|
|
label: string
|
|
timerType?: TimerType
|
|
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',
|
|
'',
|
|
'CONCLUSION',
|
|
'',
|
|
'RESULTS',
|
|
'TIMER',
|
|
'TEXT',
|
|
'QR',
|
|
'TEAMS',
|
|
],
|
|
'state.mode',
|
|
) || 1
|
|
if (![1, 2, 4, 6, 7, 8, 9, 10].includes(mode))
|
|
throw fieldError('state.mode', 'актуальный режим экрана', s.mode, 'Некорректный режим экрана')
|
|
const qrUrl = text(s.qrUrl ?? '', 'state.qrUrl')
|
|
if (mode === 9 && !validQR(qrUrl, text(s.text ?? ''))) throw new Error('Некорректные параметры QR-кода')
|
|
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),
|
|
qrUrl,
|
|
timer: {
|
|
timerType: parseTimerType(s.timerType),
|
|
label: text(s.timerLabel ?? timerLabels[parseTimerType(s.timerType)], '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.qrUrl !== previous.qrUrl ||
|
|
(next.mode === 7 &&
|
|
(next.timer.timerType !== previous.timer.timerType || next.timer.label !== previous.timer.label ||
|
|
next.timer.durationMs !== previous.timer.durationMs))
|
|
) {
|
|
if (next.mode === 9) send(controller, { action: 'qr', qrUrl: next.qrUrl, text: next.text })
|
|
else 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 })
|
|
}
|
|
}
|