up
This commit is contained in:
+343
-55
@@ -1,84 +1,372 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import type { Command, State } from '../src/types/screen.js'
|
||||
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<string, unknown>
|
||||
if (
|
||||
typeof c.id !== 'string' ||
|
||||
!/^[a-zA-Z0-9-]{1,80}$/.test(c.id) ||
|
||||
typeof c.action !== 'string' ||
|
||||
!['play', 'pause', 'stop', 'seek'].includes(c.action) ||
|
||||
typeof c.media !== 'string' ||
|
||||
!['audio', 'video'].includes(c.media) ||
|
||||
typeof c.position !== 'number' ||
|
||||
!Number.isFinite(c.position) ||
|
||||
c.position < 0 ||
|
||||
c.position > 60
|
||||
) {
|
||||
throw new Error('Некорректная команда')
|
||||
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 {
|
||||
id: c.id,
|
||||
action: c.action as Command['action'],
|
||||
media: c.media as Command['media'],
|
||||
position: c.position,
|
||||
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 = { revision: 0, command: null, status: 'idle', error: '', samples: [] }
|
||||
private seen = new Set<string>()
|
||||
private startedAt = 0
|
||||
constructor(private publish: (state: State) => void) {}
|
||||
command(input: unknown) {
|
||||
const command = parseCommand(input)
|
||||
if (this.seen.has(command.id)) return
|
||||
this.seen.add(command.id)
|
||||
// shortcut: помним 4096 команд стенда; долговременная дедупликация — SCREEN-004.
|
||||
if (this.seen.size > 4096) this.seen.delete(this.seen.values().next().value!)
|
||||
this.startedAt = performance.now()
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
command,
|
||||
status: 'pending',
|
||||
state: State = {
|
||||
session: randomUUID(),
|
||||
sequence: 0,
|
||||
revision: 0,
|
||||
outputOpen: false,
|
||||
catalog: fixtureCatalog(),
|
||||
muted: false,
|
||||
channels: { main: initialChannel(), music: initialChannel() },
|
||||
samples: [],
|
||||
}
|
||||
private measured = new Map<Channel, { operation: number; startedAt: number }>()
|
||||
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<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.publish(this.state)
|
||||
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<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) ||
|
||||
typeof r.id !== 'string' ||
|
||||
!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 (
|
||||
r.id !== this.state.command?.id ||
|
||||
(this.state.status !== 'pending' && r.status !== 'ended' && r.status !== 'error')
|
||||
!this.state.outputOpen ||
|
||||
report.session !== this.state.session ||
|
||||
report.operation !== c.operation ||
|
||||
!c.source
|
||||
)
|
||||
return
|
||||
const samples =
|
||||
r.status === 'playing' && this.state.command.action === 'play'
|
||||
? [
|
||||
...this.state.samples.slice(-399),
|
||||
{
|
||||
media: this.state.command.media,
|
||||
milliseconds: performance.now() - this.startedAt,
|
||||
},
|
||||
]
|
||||
: this.state.samples
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
status: r.status as State['status'],
|
||||
error: String(r.error ?? '').slice(0, 500),
|
||||
samples,
|
||||
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)
|
||||
}
|
||||
this.publish(this.state)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user