up
This commit is contained in:
+217
-3
@@ -8,7 +8,10 @@ import type {
|
||||
Intent,
|
||||
Report,
|
||||
State,
|
||||
ScreenMode,
|
||||
TimerState,
|
||||
} 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 {
|
||||
@@ -27,6 +30,34 @@ export function parseCommand(value: unknown): Command {
|
||||
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
|
||||
@@ -84,6 +115,9 @@ const fixtureCatalog = (): Catalog => ({
|
||||
key: null,
|
||||
name: 'Тестовый режим SCREEN-001',
|
||||
playlists: [],
|
||||
cover: null,
|
||||
background: null,
|
||||
steps: [],
|
||||
media: [
|
||||
{
|
||||
id: 'fixture-audio',
|
||||
@@ -115,6 +149,15 @@ function initialChannel(): ChannelState {
|
||||
loop: false,
|
||||
}
|
||||
}
|
||||
function initialTimer(): TimerState {
|
||||
return {
|
||||
revision: 0,
|
||||
label: 'Перерыв',
|
||||
durationMs: 300000,
|
||||
remainingMs: 300000,
|
||||
status: 'stopped',
|
||||
}
|
||||
}
|
||||
export class Controller {
|
||||
state: State = {
|
||||
session: randomUUID(),
|
||||
@@ -123,11 +166,18 @@ export class Controller {
|
||||
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 }>()
|
||||
constructor(private publish: (state: State) => void) {
|
||||
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() {
|
||||
@@ -146,8 +196,10 @@ export class Controller {
|
||||
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',
|
||||
@@ -158,6 +210,7 @@ export class Controller {
|
||||
output(open: boolean) {
|
||||
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()
|
||||
@@ -182,6 +235,13 @@ export class Controller {
|
||||
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,
|
||||
@@ -194,6 +254,17 @@ export class Controller {
|
||||
}
|
||||
}
|
||||
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(),
|
||||
@@ -214,6 +285,134 @@ export class Controller {
|
||||
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()
|
||||
}
|
||||
setResults(value: unknown) {
|
||||
const key = this.state.catalog.key
|
||||
if (value !== null && !key) throw new Error('Сначала выберите пакет игры')
|
||||
const snapshot = value === null ? null : parseResults(value, key!)
|
||||
this.state.results = snapshot
|
||||
if (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]
|
||||
@@ -243,13 +442,16 @@ export class Controller {
|
||||
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 {
|
||||
else if (this.presentation(command)) {
|
||||
/* Режимы и таймер не зависят от транспортных каналов. */
|
||||
} else if ('channel' in command) {
|
||||
const channel = command.channel
|
||||
const c = this.state.channels[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',
|
||||
@@ -287,6 +489,14 @@ export class Controller {
|
||||
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
|
||||
@@ -361,6 +571,10 @@ export class Controller {
|
||||
}
|
||||
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') {
|
||||
|
||||
Reference in New Issue
Block a user