Files
evening_detective_screen/electron/controller.ts
T
2026-10-18 05:25:23 +07:00

663 lines
23 KiB
TypeScript

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<string, unknown>
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<Channel, { operation: number; startedAt: number }>()
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<ChannelState>) {
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<TimerState, 'revision'>) {
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<string, unknown>
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<ChannelState> = { 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<string, unknown>
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()
}
}