import { randomUUID } from 'node:crypto' import { performance } from 'node:perf_hooks' import type { Catalog, Channel, ChannelState, Command, Intent, Report, State, } from '../src/types/screen.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.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: [], 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: 35, error: '', playlistId: null, trackIndex: 0, loop: false, } } export class Controller { state: State = { session: randomUUID(), sequence: 0, revision: 0, outputOpen: false, catalog: fixtureCatalog(), muted: false, channels: { main: initialChannel(), music: initialChannel() }, samples: [], } private measured = new Map() constructor(private publish: (state: State) => void) { 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.state.session = randomUUID() this.state.sequence = 0 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) { this.newSession() this.state.outputOpen = open if (!open) for (const c of Object.values(this.state.channels)) c.status = c.source ? 'stopped' : 'idle' this.broadcast() } selectPackage(pkg: LocalPackage | null) { 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}`, }) 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, 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'), })), })), } } this.state.catalog = catalog 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 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 { const channel = command.channel const 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.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('Для канала не выбран ресурс') 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' this.measured.delete(report.channel) } if (report.status === 'ended') { if (report.channel === 'music') this.advance() else c.desired = 'paused' } this.broadcast() } }