import { randomUUID } from 'node:crypto' import { performance } from 'node:perf_hooks' import { isDeepStrictEqual } from 'node:util' import type { Catalog, Channel, ChannelState, Command, Intent, Report, State, ScreenMode, TimerState, ScreenStep, } from '../src/types/screen.js' import { parseResults } from './results.js' import type { LocalPackage } from './package-store.js' function number(value: unknown, min: number, max = Number.MAX_SAFE_INTEGER): value is number { return typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max } export function parseCommand(value: unknown): Command { if (!value || typeof value !== 'object') throw new Error('Некорректная команда') const c = value as Record if ( typeof c.session !== 'string' || !/^[a-f0-9-]{36}$/.test(c.session) || !Number.isSafeInteger(c.sequence) || !number(c.sequence, 1) ) throw new Error('Некорректная сессия команды') let intent: Intent if (c.action === 'mute' && typeof c.enabled === 'boolean') intent = { action: 'mute', enabled: c.enabled } else if (c.action === 'mode' && Number.isInteger(c.mode) && number(c.mode, 1, 8)) intent = { action: 'mode', mode: c.mode as ScreenMode } else if (c.action === 'step' && Number.isSafeInteger(c.index) && number(c.index, 0)) intent = { action: 'step', index: c.index } else if ( c.action === 'text' && typeof c.text === 'string' && c.text.trim() && c.text.length <= 10000 ) intent = { action: 'text', text: c.text.trim() } else if ( c.action === 'timer-set' && typeof c.label === 'string' && c.label.length <= 200 && Number.isInteger(c.durationSeconds) && number(c.durationSeconds, 1, 86400) ) intent = { action: 'timer-set', label: c.label.trim() || 'Перерыв', durationSeconds: c.durationSeconds, } else if ( typeof c.action === 'string' && ['timer-start', 'timer-pause', 'timer-resume', 'timer-reset'].includes(c.action) ) intent = { action: c.action as 'timer-start' | 'timer-pause' | 'timer-resume' | 'timer-reset' } else { if (c.channel !== 'main' && c.channel !== 'music') throw new Error('Некорректный канал') const channel = c.channel switch (c.action) { case 'play': case 'pause': case 'resume': case 'stop': case 'replay': intent = { action: c.action, channel } break case 'seek': if (!number(c.position, 0)) throw new Error('Некорректная позиция') intent = { action: 'seek', channel, position: c.position } break case 'volume': if (!number(c.volume, 0, 100)) throw new Error('Некорректная громкость') intent = { action: 'volume', channel, volume: c.volume } break case 'select': if ( channel !== 'main' || typeof c.sourceId !== 'string' || !/^[a-zA-Z0-9-]{1,80}$/.test(c.sourceId) ) throw new Error('Некорректный ресурс') intent = { action: 'select', channel, sourceId: c.sourceId } break case 'playlist': if (channel !== 'music' || !number(c.playlistId, 1) || !Number.isSafeInteger(c.playlistId)) throw new Error('Некорректный плейлист') intent = { action: 'playlist', channel, playlistId: c.playlistId } break case 'track': if (channel !== 'music' || !number(c.trackIndex, 0) || !Number.isSafeInteger(c.trackIndex)) throw new Error('Некорректный трек') intent = { action: 'track', channel, trackIndex: c.trackIndex } break case 'next': if (channel !== 'music') throw new Error('Некорректный канал') intent = { action: 'next', channel } break case 'loop': if (channel !== 'music' || typeof c.enabled !== 'boolean') throw new Error('Некорректный повтор') intent = { action: 'loop', channel, enabled: c.enabled } break default: throw new Error('Некорректная команда') } } return { session: c.session, sequence: c.sequence as number, ...intent } } const fixtureCatalog = (): Catalog => ({ key: null, name: 'Тестовый режим SCREEN-001', playlists: [], cover: null, background: null, steps: [], media: [ { id: 'fixture-audio', name: 'Тестовое аудио · WAV', kind: 'audio', url: 'screen-app://app/media/audio', }, { id: 'fixture-video', name: 'Тестовое видео · H.264 + AAC', kind: 'video', url: 'screen-app://app/media/video', }, ], }) function initialChannel(): ChannelState { return { operation: 0, seekOperation: 0, source: null, desired: 'stopped', status: 'idle', position: 0, duration: null, volume: 100, error: '', playlistId: null, trackIndex: 0, loop: false, } } function initialTimer(): TimerState { return { revision: 0, label: 'Перерыв', durationMs: 300000, remainingMs: 300000, status: 'stopped', } } export class Controller { onMusicEnded: ((report: Report) => boolean | void) | null = null state: State = { session: randomUUID(), sequence: 0, revision: 0, outputOpen: false, catalog: fixtureCatalog(), muted: false, scene: { revision: 0, mode: 1, stepIndex: null, text: '', status: 'idle', error: '' }, timer: initialTimer(), results: null, channels: { main: initialChannel(), music: initialChannel() }, samples: [], } private measured = new Map() private timerDeadline: number | null = null constructor( private publish: (state: State) => void, private now: () => number = () => performance.now(), ) { this.state.channels.main.source = this.state.catalog.media[0]! } private broadcast() { this.state.revision++ this.publish(structuredClone(this.state)) } private change(channel: Channel, patch: Partial) { const c = this.state.channels[channel] this.state.channels[channel] = { ...c, ...patch, operation: c.operation + 1, seekOperation: patch.position === undefined ? c.seekOperation : c.operation + 1, error: '', } this.measured.delete(channel) } private newSession() { this.updateTimer() this.state.session = randomUUID() this.state.sequence = 0 this.invalidateScene() for (const channel of ['main', 'music'] as const) this.change(channel, { desired: 'stopped', status: this.state.channels[channel].source ? 'pending' : 'idle', position: 0, }) } output(open: boolean, pauseTimer = false) { if (!open && pauseTimer && this.state.timer.status === 'running') { this.updateTimer() if (this.state.timer.status === 'running') { this.timerDeadline = null this.state.timer.status = 'paused' this.state.timer.revision++ } } this.newSession() this.state.outputOpen = open this.state.scene.status = open ? 'pending' : 'idle' if (!open) for (const c of Object.values(this.state.channels)) c.status = c.source ? 'stopped' : 'idle' this.broadcast() } selectPackage(pkg: LocalPackage | null, preserve = false) { let catalog = fixtureCatalog() if (pkg) { const source = (id: string, assetId: string, name: string, kind: 'audio' | 'video') => ({ id, name, kind, url: `screen-app://app/packages/${pkg.key}/${assetId}`, ready: pkg.files.some((file) => file.id === assetId), }) const media = [] if (pkg.package.media.intro) media.push(source('intro', pkg.package.media.intro, 'Вступление', 'audio')) if (pkg.package.media.conclusion) media.push( source('conclusion', pkg.package.media.conclusion, 'Заключительное видео', 'video'), ) catalog = { key: pkg.key, name: pkg.package.game.name, media, cover: pkg.package.media.cover ? `screen-app://app/packages/${pkg.key}/${pkg.package.media.cover}` : null, background: pkg.package.media.background ? `screen-app://app/packages/${pkg.key}/${pkg.package.media.background}` : null, steps: pkg.package.steps.map((step) => ({ ...step, mode: step.mode as ScreenMode })), playlists: pkg.package.playlists.map((p) => ({ id: p.id, name: p.name, tracks: p.tracks.map((t) => ({ id: t.id, name: t.name, source: source(`track-${p.id}-${t.id}`, t.assetId, t.name, 'audio'), })), })), } } if (preserve && catalog.key === this.state.catalog.key) { catalog.cover = this.state.catalog.cover catalog.background = this.state.catalog.background const music = this.state.channels.music const tracks = catalog.playlists.find((p) => p.id === music.playlistId)?.tracks const index = tracks?.findIndex((t) => t.source.url === music.source?.url) ?? -1 if (index >= 0) music.trackIndex = index this.state.catalog = catalog this.broadcast() return } this.state.catalog = catalog this.state.scene = { revision: this.state.scene.revision, mode: 1, stepIndex: null, text: '', status: 'idle', error: '', } this.state.timer = initialTimer() this.timerDeadline = null this.state.results = null this.state.channels = { main: { ...initialChannel(), source: catalog.media[0] ?? null }, music: initialChannel(), } const preferred = pkg?.package.music const playlist = catalog.playlists.find( (p) => p.id === (preferred?.selectedPlaylistId ?? preferred?.gamePlaylistId ?? preferred?.scenarioPlaylistId), ) ?? catalog.playlists[0] if (playlist) { this.state.channels.music.playlistId = playlist.id this.state.channels.music.source = playlist.tracks[0]?.source ?? null } this.newSession() this.broadcast() } private invalidateScene() { this.state.scene.revision++ this.state.scene.status = this.state.outputOpen ? 'pending' : 'idle' this.state.scene.error = '' } private mode(mode: ScreenMode, stepIndex: number | null = null, text = this.state.scene.text) { this.state.scene = { ...this.state.scene, mode, stepIndex, text } const c = this.state.channels.main const kind = mode === 2 ? 'audio' : mode === 4 ? 'video' : null const source = kind ? (this.state.catalog.media.find((s) => s.kind === kind) ?? null) : c.source this.change('main', { source, desired: 'stopped', position: 0, duration: source?.id === c.source?.id ? c.duration : null, status: source ? 'pending' : 'idle', }) this.invalidateScene() } private configureTimer(label: string, seconds: number) { this.timerDeadline = null this.state.timer = { revision: this.state.timer.revision + 1, label, durationMs: seconds * 1000, remainingMs: seconds * 1000, status: 'stopped', } } private updateTimer() { if (this.timerDeadline === null) return false const t = this.state.timer const remaining = Math.max(0, this.timerDeadline - this.now()) if (remaining === t.remainingMs) return false t.remainingMs = remaining t.revision++ if (!remaining) { t.status = 'finished' this.timerDeadline = null } return true } tick() { if (this.updateTimer()) this.broadcast() } synchronizeTimer(timer: Omit) { this.state.timer = { ...timer, revision: this.state.timer.revision + 1 } this.timerDeadline = timer.status === 'running' ? this.now() + timer.remainingMs : null this.broadcast() } markAssetReady(assetId: string) { let changed = false for (const source of [ ...this.state.catalog.media, ...this.state.catalog.playlists.flatMap((playlist) => playlist.tracks.map((track) => track.source), ), ]) { if (source.url.endsWith(`/${assetId}`) && source.ready === false) { source.ready = true changed = true } } if (changed) this.broadcast() } setImages(cover: string | null, background: string | null) { if (cover === this.state.catalog.cover && background === this.state.catalog.background) return this.state.catalog.cover = cover this.state.catalog.background = background this.invalidateScene() this.broadcast() } setPlan(steps: ScreenStep[]) { if (isDeepStrictEqual(steps, this.state.catalog.steps)) return const index = this.state.scene.stepIndex const old = index === null ? null : this.state.catalog.steps[index] this.state.catalog.steps = structuredClone(steps) if (index !== null && !isDeepStrictEqual(old, steps[index])) this.state.scene.stepIndex = null this.broadcast() } markStep(index: number) { const step = this.state.catalog.steps[index] const scene = this.state.scene if (!step || step.mode !== scene.mode || (step.mode === 8 && step.text !== scene.text)) return if ( step.mode === 7 && (step.timerLabel !== this.state.timer.label || step.timerDurationSeconds * 1000 !== this.state.timer.durationMs) ) return if (scene.stepIndex === index) return scene.stepIndex = index this.broadcast() } setResults(value: unknown) { const key = this.state.catalog.key if (value !== null && !key) throw new Error('Сначала выберите пакет игры') const snapshot = value === null ? null : parseResults(value, key!) const hasChanged = !isDeepStrictEqual(this.state.results?.questions, snapshot?.questions) || !isDeepStrictEqual(this.state.results?.teams, snapshot?.teams) this.state.results = snapshot if (hasChanged && this.state.scene.mode === 6) this.invalidateScene() this.broadcast() } reportScene(value: unknown) { if (!value || typeof value !== 'object') throw new Error('Некорректное подтверждение сцены') const r = value as Record if ( typeof r.session !== 'string' || !Number.isSafeInteger(r.revision) || (r.status !== 'ready' && r.status !== 'error') || (r.error !== undefined && typeof r.error !== 'string') ) throw new Error('Некорректное подтверждение сцены') if ( !this.state.outputOpen || r.session !== this.state.session || r.revision !== this.state.scene.revision ) return const mediaError = [2, 4].includes(this.state.scene.mode) ? this.state.channels.main.error : '' const status = mediaError ? 'error' : r.status const error = String(mediaError || r.error || '').slice(0, 500) if (this.state.scene.status === status && this.state.scene.error === error) return this.state.scene.status = status this.state.scene.error = error this.broadcast() } private presentation(command: Command): boolean { switch (command.action) { case 'mode': this.mode(command.mode) return true case 'text': this.mode(8, null, command.text) return true case 'step': { const step = this.state.catalog.steps[command.index] if (!step) throw new Error('Шаг плана не найден') if (step.mode === 7) this.configureTimer(step.timerLabel || 'Перерыв', step.timerDurationSeconds) this.mode(step.mode, command.index, step.text) return true } case 'timer-set': this.configureTimer(command.label, command.durationSeconds) this.mode(7) return true case 'timer-start': case 'timer-pause': case 'timer-resume': case 'timer-reset': { this.updateTimer() const t = this.state.timer if (command.action === 'timer-reset') { this.configureTimer(t.label, t.durationMs / 1000) return true } if (command.action === 'timer-pause') { if (t.status === 'running') { this.timerDeadline = null t.status = 'paused' t.revision++ } } else if ( t.status !== 'running' && (command.action === 'timer-start' || t.status === 'paused') ) { if (t.remainingMs <= 0) t.remainingMs = t.durationMs t.status = 'running' t.revision++ this.timerDeadline = this.now() + t.remainingMs } return true } default: return false } } private track(index: number) { const c = this.state.channels.music const track = this.state.catalog.playlists.find((p) => p.id === c.playlistId)?.tracks[index] if (!track) throw new Error('Трек не найден') this.change('music', { source: track.source, trackIndex: index, position: 0, duration: null, status: 'pending', }) } private advance() { const c = this.state.channels.music const tracks = this.state.catalog.playlists.find((p) => p.id === c.playlistId)?.tracks ?? [] if (!tracks.length) throw new Error('Плейлист пуст') const next = c.trackIndex + 1 if (next < tracks.length || c.loop) this.track(next % tracks.length) else this.change('music', { desired: 'paused', status: 'ended', position: c.duration ?? c.position, }) } command(input: unknown) { const command = parseCommand(input) if (command.session !== this.state.session || command.sequence <= this.state.sequence) return if (command.action === 'mute') this.state.muted = command.enabled else if (this.presentation(command)) { /* Режимы и таймер не зависят от транспортных каналов. */ } else if ('channel' in command) { const channel = command.channel let c = this.state.channels[channel] switch (command.action) { case 'select': { const source = this.state.catalog.media.find((s) => s.id === command.sourceId) if (!source) throw new Error('Ресурс не найден в выбранном пакете') this.mode(source.kind === 'audio' ? 2 : 4) this.change(channel, { source, desired: 'stopped', status: 'pending', position: 0, duration: null, }) break } case 'playlist': { const playlist = this.state.catalog.playlists.find((p) => p.id === command.playlistId) if (!playlist) throw new Error('Плейлист не найден в выбранном пакете') this.change(channel, { playlistId: playlist.id, trackIndex: 0, source: playlist.tracks[0]?.source ?? null, position: 0, duration: null, desired: 'stopped', status: playlist.tracks.length ? 'pending' : 'idle', }) break } case 'track': this.track(command.trackIndex) break case 'next': this.advance() break case 'volume': c.volume = command.volume break case 'loop': c.loop = command.enabled break default: { if (!c.source) throw new Error('Для канала не выбран ресурс') if (channel === 'main' && ['play', 'resume', 'replay'].includes(command.action)) { const mode = c.source.kind === 'audio' ? 2 : 4 if (this.state.scene.status === 'error') this.invalidateScene() if (this.state.scene.mode !== mode) { this.mode(mode) c = this.state.channels[channel] } } const patch: Partial = { status: 'pending' } if (command.action === 'play' || command.action === 'replay') { patch.position = 0 patch.desired = 'playing' } if (command.action === 'resume') { if (c.status === 'ended') patch.position = 0 patch.desired = 'playing' } if (command.action === 'pause') patch.desired = 'paused' if (command.action === 'stop') { patch.position = 0 patch.desired = 'stopped' } if (command.action === 'seek') patch.position = c.duration === null ? command.position : Math.min(command.position, c.duration) this.change(channel, patch) if (['play', 'resume', 'replay'].includes(command.action)) this.measured.set(channel, { operation: this.state.channels[channel].operation, startedAt: performance.now(), }) } } } this.state.sequence = command.sequence this.broadcast() } report(input: unknown) { if (!input || typeof input !== 'object') throw new Error('Некорректный отчёт') const r = input as Record if ( typeof r.session !== 'string' || (r.channel !== 'main' && r.channel !== 'music') || !Number.isSafeInteger(r.operation) || !number(r.operation, 0) || typeof r.status !== 'string' || !['playing', 'paused', 'stopped', 'ended', 'error'].includes(r.status) || !number(r.position, 0) || (r.duration !== null && !number(r.duration, 0)) || (r.error !== undefined && typeof r.error !== 'string') ) throw new Error('Некорректный отчёт') const report = r as unknown as Report const c = this.state.channels[report.channel] if ( !this.state.outputOpen || report.session !== this.state.session || report.operation !== c.operation || !c.source ) return if (report.status === 'playing' && c.desired !== 'playing') return if (report.status === 'ended' && (c.desired !== 'playing' || c.status === 'ended')) return c.position = report.duration === null ? report.position : Math.min(report.position, report.duration) c.duration = report.duration c.status = report.status c.error = (report.error ?? '').slice(0, 500) const measured = this.measured.get(report.channel) if (report.status === 'playing' && measured?.operation === c.operation) { this.state.samples = [ ...this.state.samples.slice(-399), { channel: report.channel, media: c.source.kind, milliseconds: performance.now() - measured.startedAt, }, ] this.measured.delete(report.channel) } if (report.status === 'error') { c.desired = 'paused' if (report.channel === 'main' && [2, 4].includes(this.state.scene.mode)) { this.state.scene.status = 'error' this.state.scene.error = c.error } this.measured.delete(report.channel) } if (report.status === 'ended') { if (report.channel === 'music') { if (!this.onMusicEnded?.(report)) this.advance() } else c.desired = 'paused' } this.broadcast() } }