Files
evening_detective_screen/electron/remote-state.ts
T
2026-10-18 19:07:29 +07:00

308 lines
11 KiB
TypeScript

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
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) {
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 })
}
}