This commit is contained in:
2026-10-17 20:16:24 +07:00
parent f5dc9c1e5b
commit 40456eebf1
16 changed files with 2122 additions and 54 deletions
+10 -2
View File
@@ -159,6 +159,7 @@ function initialTimer(): TimerState {
}
}
export class Controller {
onMusicEnded: ((report: Report) => void) | null = null
state: State = {
session: randomUUID(),
sequence: 0,
@@ -330,6 +331,11 @@ export class Controller {
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()
}
setResults(value: unknown) {
const key = this.state.catalog.key
if (value !== null && !key) throw new Error('Сначала выберите пакет игры')
@@ -578,8 +584,10 @@ export class Controller {
this.measured.delete(report.channel)
}
if (report.status === 'ended') {
if (report.channel === 'music') this.advance()
else c.desired = 'paused'
if (report.channel === 'music') {
this.onMusicEnded?.(report)
this.advance()
} else c.desired = 'paused'
}
this.broadcast()
}
+44 -6
View File
@@ -1,10 +1,12 @@
import { app, BrowserWindow, ipcMain, protocol, screen, session } from 'electron'
import { app, BrowserWindow, ipcMain, protocol, screen, session, safeStorage } from 'electron'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { join } from 'node:path'
import { Readable } from 'node:stream'
import { Controller, parseCommand } from './controller.js'
import { parseRange } from './range.js'
import { Online } from './online.js'
import type { OnlineState } from '../src/types/online.js'
import { PackageStore } from './package-store.js'
import { packageMediaResponse } from './package-media.js'
import type { HostInfo } from '../src/types/screen.js'
@@ -21,6 +23,7 @@ protocol.registerSchemesAsPrivileged([
let control: BrowserWindow | null = null
let output: BrowserWindow | null = null
let selectedDisplay: number | null = null
let online: Online | null = null
const controller = new Controller((state) => {
for (const window of [control, output])
if (window && !window.isDestroyed()) window.webContents.send('state', state)
@@ -62,6 +65,7 @@ function requireControl(event: Electron.IpcMainInvokeEvent) {
throw new Error('Недоступно')
}
function show(displayId: number) {
if (online?.blocksOutput) throw new Error('Подождите завершения подготовки материалов')
const display = screen.getAllDisplays().find((d) => d.id === displayId)
if (!display || display.id === screen.getPrimaryDisplay().id)
throw new Error('Выберите внешний дисплей; включите расширенный рабочий стол')
@@ -98,9 +102,23 @@ app
.then(async () => {
const packages = new PackageStore(join(app.getPath('userData'), 'packages'))
await packages.initialize()
online = new Online(join(app.getPath('userData'), 'connection'), packages, controller, {
available: () =>
safeStorage.isEncryptionAvailable() &&
(process.platform !== 'linux' ||
!['basic_text', 'unknown'].includes(safeStorage.getSelectedStorageBackend())),
encrypt: (value) => safeStorage.encryptString(value),
decrypt: (value) => safeStorage.decryptString(value),
})
await online.initialize()
const onlineTick = setInterval(() => {
void online?.poll()
}, 1000)
const timerTick = setInterval(() => controller.tick(), 250)
app.on('before-quit', () => {
clearInterval(timerTick)
clearInterval(onlineTick)
online?.close()
packages.cancel()
})
session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) =>
@@ -182,12 +200,26 @@ app
throw new Error('Недоступно')
return controller.state
})
ipcMain.handle('online-state', (event): OnlineState => {
requireControl(event)
return online!.snapshot()
})
ipcMain.handle('online-action', async (event, intent: unknown) => {
requireControl(event)
if (selectingPackage) throw new Error('Подождите проверки пакета')
return online!.act(intent)
})
ipcMain.handle('close-output', (event) => {
requireControl(event)
output?.close()
})
ipcMain.handle('host', (event) => {
requireControl(event)
return host()
})
ipcMain.handle('show', (event, id: unknown) => {
requireControl(event)
if (selectingPackage) throw new Error('Подождите проверки пакета')
if (typeof id !== 'number') throw new Error('Некорректный дисплей')
show(id)
})
@@ -204,15 +236,14 @@ app
if (key !== null && typeof key !== 'string') throw new Error('Некорректный пакет')
selectingPackage = true
try {
controller.selectPackage(key === null ? null : await packages.verify(key))
await online!.select(key)
return controller.state
} catch (error) {
if (key === controller.state.catalog.key) controller.selectPackage(null)
throw error
} finally {
selectingPackage = false
}
})
let localSession = ''
let localSequence = 0
ipcMain.handle('command', (event, command: unknown) => {
requireControl(event)
if (selectingPackage) throw new Error('Подождите проверки пакета')
@@ -222,7 +253,14 @@ app
['play', 'pause', 'resume', 'stop', 'replay', 'seek', 'next'].includes(parsed.action)
)
throw new Error('Сначала откройте окно показа')
controller.command(parsed)
if (parsed.session !== controller.state.session) return
if (localSession !== parsed.session) {
localSession = parsed.session
localSequence = 0
}
if (parsed.sequence <= localSequence) return
controller.command({ ...parsed, sequence: controller.state.sequence + 1 })
localSequence = parsed.sequence
})
let capturing = false
let lastCapture = -Infinity
+543
View File
@@ -0,0 +1,543 @@
import assert from 'node:assert/strict'
import { test, type TestContext } from 'node:test'
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Controller } from './controller.js'
import { Online, type SecretStorage } from './online.js'
import { PackageStore } from './package-store.js'
import { applyRemote, parseRemote, send } from './remote-state.js'
async function fixture(t: TestContext, secure = true) {
const directory = await mkdtemp(join(tmpdir(), 'screen-online-'))
t.after(() => rm(directory, { recursive: true, force: true }))
const key = randomBytes(32)
const secrets: SecretStorage = {
available: () => secure,
encrypt(value) {
assert.ok(secure)
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key, iv)
const bytes = Buffer.concat([cipher.update(value), cipher.final()])
return Buffer.concat([iv, cipher.getAuthTag(), bytes])
},
decrypt(value) {
const cipher = createDecipheriv('aes-256-gcm', key, value.subarray(0, 12))
cipher.setAuthTag(value.subarray(12, 28))
return Buffer.concat([cipher.update(value.subarray(28)), cipher.final()]).toString()
},
}
const model = {
offline: false,
failMedia: false,
expired: false,
refreshExpired: false,
accessExpired: false,
refreshed: false,
refreshCalls: 0,
advanceCalls: 0,
resultError: false,
title: 'Дело',
roles: ['organizer'],
remote: {
gameID: 7,
mode: 2,
command: 1,
stateVersion: '9007199254740993',
soundEnabled: true,
positionMs: '0',
volume: 80,
} as Record<string, unknown>,
stateResponse: null as (() => Promise<Response>) | null,
}
const json = (value: unknown, status = 200) => Response.json(value, { status })
const requests: { path: string; auth: string | null }[] = []
const fetchAPI: typeof fetch = async (input, init) => {
if (model.offline) throw new Error('network secret-detail')
const url = new URL(String(input))
const auth = new Headers(init?.headers).get('Authorization')
const path = url.pathname
requests.push({ path, auth })
assert.equal(init?.credentials, 'omit')
if (url.origin === 'https://media.test') {
assert.equal(auth, null)
if (model.failMedia) return new Response(null, { status: 404 })
return new Response(Buffer.from('RIFFtest-WAVE'), {
headers: { 'Content-Type': 'audio/wav' },
})
}
assert.equal(url.origin, 'https://server.test')
if (path === '/api/auth/login')
return json({ accessToken: 'ACCESS-SECRET', refreshToken: 'REFRESH-SECRET' })
if (path === '/api/auth/refresh') {
model.refreshCalls++
await new Promise((resolve) => setTimeout(resolve, 5))
if (model.refreshExpired) return json({}, 401)
model.refreshed = true
return json({ accessToken: 'ACCESS-ROTATED', refreshToken: 'REFRESH-ROTATED' })
}
if (path === '/api/screens') return json({ screenID: '12', accessToken: 'SCREEN-SECRET' })
if (path === '/api/screens/state') {
assert.equal(auth, null)
if (model.stateResponse) return model.stateResponse()
return model.expired ? json({}, 401) : json({ state: model.remote })
}
if (path === '/api/screens/music/advance') {
model.advanceCalls++
return json({ state: objectMusic(2, 1) })
}
if (path === '/api/screens/results') {
if (model.resultError) return json({}, 400)
return json({
questions: [{ code: 'A', text: 'Вопрос' }],
teams: [
{
team: {
ID: 'team',
name: 'Команда',
password: 'TEAM-SECRET',
captainEmail: 'private@example.test',
applications: [{ name: 'PRIVATE' }],
},
place: 1,
totalScore: 5,
actionsCount: 2,
answers: [{ questionCode: 'A', score: 5, answer: 'ANSWER-SECRET' }],
},
],
})
}
if (auth === 'Bearer ACCESS-SECRET' && model.accessExpired && !model.refreshed)
return json({}, 401)
if (model.refreshExpired) return json({}, 401)
if (path === '/api/users/me') return json({ user: { roles: model.roles } })
if (path === '/api/games')
return json({
games: [
{ ID: 7, name: model.title, status: 'finish', teams: [{ password: 'TEAM-SECRET' }] },
],
})
if (path === '/api/games/7')
return json({
game: {
ID: 7,
name: model.title,
scenario: {
story: {
introduction: { audio: 'https://media.test/intro.wav' },
conclusion: { video: '' },
},
},
},
})
if (path === '/api/games/7/screen-plan') return json({ plan: { steps: [{ mode: 2 }] } })
if (path === '/api/games/7/music')
return json({
gamePlaylistId: 1,
playlists: [
{
ID: 1,
name: 'Музыка',
tracks: [0, 1].map((i) => ({
ID: i + 1,
name: `Трек ${i}`,
filename: `https://media.test/${i}.wav`,
position: i,
})),
},
],
})
if (path.endsWith('/bind') || path.endsWith('/sound')) return json({})
throw new Error(`Unexpected request ${path}`)
}
const store = new PackageStore(join(directory, 'packages'), {
fetch: fetchAPI,
freeBytes: async () => 1n << 40n,
wait: async () => {},
})
await store.initialize()
const controller = new Controller(() => {})
const online = new Online(join(directory, 'connection'), store, controller, secrets, fetchAPI)
await online.initialize()
const login = () =>
online.act({
action: 'login',
server: 'https://server.test/',
email: 'test@example.test',
password: 'PASSWORD-SECRET',
})
const prepare = async () => {
await login()
await online.act({ action: 'download', gameId: 7 })
await online.select(store.list()[0]!.key)
await online.act({ action: 'connect' })
}
return {
directory,
store,
controller,
online,
secrets,
fetchAPI,
model,
requests,
login,
prepare,
}
}
function objectMusic(version: number, index = 0) {
return {
stateVersion: String(version),
playlist: { ID: 1, tracks: [{ ID: 1 }, { ID: 2 }] },
trackIndex: index,
playback: 'MUSIC_PLAYBACK_PLAYING',
volume: 40,
loop: true,
}
}
test('вход, подготовка и зашифрованные секреты; после перезапуска игра и результаты работают офлайн', async (t) => {
const f = await fixture(t)
await f.prepare()
await f.online.act({ action: 'results' })
const snapshot = f.online.snapshot()
assert.equal(snapshot.authenticated, true)
assert.equal(snapshot.remembered, true)
const files = await readdir(join(f.directory, 'connection'))
for (const file of files) {
const bytes = await readFile(join(f.directory, 'connection', file))
for (const secret of [
'ACCESS-SECRET',
'REFRESH-SECRET',
'SCREEN-SECRET',
'PASSWORD-SECRET',
'TEAM-SECRET',
'ANSWER-SECRET',
'private@example.test',
])
assert.equal(bytes.includes(secret), false)
}
assert.equal(JSON.stringify(snapshot).includes('SECRET'), false)
f.model.offline = true
const store = new PackageStore(join(f.directory, 'packages'))
await store.initialize()
const c = new Controller(() => {})
const online = new Online(join(f.directory, 'connection'), store, c, f.secrets, f.fetchAPI)
await online.initialize()
assert.equal(c.state.catalog.key, f.controller.state.catalog.key)
assert.equal(c.state.results?.teams[0]?.totalScore, 5)
assert.ok(c.state.results?.capturedAt)
assert.equal(c.state.outputOpen, false)
assert.equal(c.state.channels.main.desired, 'stopped')
c.output(true)
send(c, { action: 'play', channel: 'main' })
await online.poll()
assert.equal(online.state.connection, 'offline')
assert.equal(c.state.channels.main.desired, 'playing')
})
test('без хранилища ОС вход только в памяти; выход не останавливает показ', async (t) => {
const f = await fixture(t, false)
await f.prepare()
assert.equal(f.online.state.remembered, false)
await assert.rejects(readFile(join(f.directory, 'connection', 'credentials.bin')), {
code: 'ENOENT',
})
f.controller.output(true)
send(f.controller, { action: 'play', channel: 'main' })
await f.online.act({ action: 'logout' })
assert.equal(f.controller.state.channels.main.desired, 'playing')
assert.equal(f.online.state.screenId, null)
})
test('три параллельных запроса описания используют одно обновление токена', async (t) => {
const f = await fixture(t)
await f.login()
f.model.accessExpired = true
await f.online.act({ action: 'download', gameId: 7 })
assert.equal(f.model.refreshCalls, 1)
assert.equal(f.store.list().length, 1)
})
test('обновление запрещено при показе, ошибка сохраняет старый пакет, новая версия включается остановленной', async (t) => {
const f = await fixture(t)
await f.prepare()
const old = f.store.list()[0]!
f.controller.output(true)
await assert.rejects(f.online.act({ action: 'download', gameId: 7 }), /Закройте показ/)
f.controller.output(false)
f.model.title = 'Новое дело'
await f.online.act({ action: 'check', gameId: 7 })
assert.match(f.online.state.notice, /есть изменения/)
f.model.failMedia = true
await assert.rejects(f.online.act({ action: 'download', gameId: 7 }))
assert.equal(f.store.list()[0]?.version, old.version)
f.model.failMedia = false
await f.online.act({ action: 'download', gameId: 7 })
assert.notEqual(f.store.list()[0]?.version, old.version)
assert.equal(f.controller.state.catalog.name, 'Новое дело')
assert.equal(f.controller.state.channels.main.desired, 'stopped')
await assert.rejects(f.online.act({ action: 'remove', key: old.key }), /другой пакет/)
})
test('пропускает исходную и накопленную версии; повторная версия не отменяет локальную паузу', async (t) => {
const f = await fixture(t)
await f.prepare()
const c = f.controller
c.output(true)
await f.online.poll()
assert.equal(c.state.channels.main.desired, 'stopped')
f.model.remote.stateVersion = '9007199254740994'
await f.online.poll()
assert.equal(c.state.channels.main.desired, 'playing')
send(c, { action: 'pause', channel: 'main' })
await f.online.poll()
assert.equal(c.state.channels.main.desired, 'paused')
f.model.offline = true
await f.online.poll()
f.model.offline = false
f.model.remote.stateVersion = '9007199254740995'
await f.online.poll()
assert.equal(c.state.channels.main.desired, 'paused')
f.model.remote.stateVersion = '9007199254740996'
await f.online.poll()
assert.equal(c.state.channels.main.desired, 'playing')
c.output(false)
c.output(true)
await f.online.poll()
assert.equal(c.state.channels.main.desired, 'stopped')
})
test('недействительная сессия и смена серверной привязки сохраняют локальный показ', async (t) => {
const f = await fixture(t)
await f.prepare()
f.controller.output(true)
send(f.controller, { action: 'play', channel: 'main' })
f.model.expired = true
await f.online.poll()
assert.equal(f.online.state.connection, 'expired')
assert.equal(f.controller.state.channels.main.desired, 'playing')
f.model.expired = false
await f.online.act({ action: 'connect' })
f.model.remote.gameID = 8
await f.online.poll()
assert.equal(f.online.state.screenId, null)
assert.equal(f.controller.state.channels.main.desired, 'playing')
})
test('не применяет ответ от предыдущего окна и не допускает параллельный опрос', async (t) => {
const f = await fixture(t)
await f.prepare()
f.controller.output(true)
await f.online.poll()
let release!: (response: Response) => void
f.model.stateResponse = () =>
new Promise((resolve) => {
release = resolve
})
const pending = f.online.poll()
await f.online.poll()
f.controller.output(false)
f.controller.output(true)
release(Response.json({ state: { ...f.model.remote, stateVersion: '9007199254740999' } }))
await pending
assert.equal(f.controller.state.channels.main.desired, 'stopped')
})
test('результаты сохраняются при ошибке API и не содержат секретных полей', async (t) => {
const f = await fixture(t)
await f.prepare()
await f.online.act({ action: 'results' })
const before = structuredClone(f.controller.state.results)
f.model.resultError = true
await assert.rejects(f.online.act({ action: 'results' }), /пока недоступно/)
assert.deepEqual(f.controller.state.results, before)
assert.equal(JSON.stringify(before).includes('SECRET'), false)
})
test('таймер синхронизируется один раз на версию; звук меняется без увеличения версии', () => {
let now = 0
const c = new Controller(
() => {},
() => now,
)
const previous = parseRemote({
gameID: 7,
mode: 7,
stateVersion: '1',
timerDurationSeconds: '60',
})
const next = parseRemote({
gameID: 7,
mode: 7,
stateVersion: '2',
soundEnabled: true,
timerDurationSeconds: '60',
timerRemainingSeconds: '40',
timerState: 2,
command: 8,
})
applyRemote(c, previous, next)
assert.equal(c.state.timer.remainingMs, 40000)
now += 3000
c.tick()
applyRemote(c, next, next)
assert.equal(c.state.timer.remainingMs, 37000)
send(c, { action: 'mute', enabled: true })
applyRemote(c, { ...next, sound: false }, next)
assert.equal(c.state.muted, false)
assert.throws(() => parseRemote({ stateVersion: '9223372036854775808' }))
assert.throws(() => parseRemote({ mode: 99 }))
})
test('музыкальная громкость не возвращает старый трек; новый серверный трек использует локальные файлы', async (t) => {
const f = await fixture(t)
await f.prepare()
f.controller.output(true)
const previous = parseRemote({ music: objectMusic(1) })
send(f.controller, { action: 'next', channel: 'music' })
const next = parseRemote({ music: { ...objectMusic(2), volume: 70 } })
applyRemote(f.controller, previous, next)
assert.equal(f.controller.state.channels.music.trackIndex, 1)
assert.equal(f.controller.state.channels.music.volume, 70)
applyRemote(f.controller, next, parseRemote({ music: objectMusic(3, 1) }))
assert.equal(f.controller.state.channels.music.desired, 'playing')
assert.match(f.controller.state.channels.music.source!.url, /^screen-app:/)
assert.throws(
() =>
applyRemote(
f.controller,
next,
parseRemote({ music: { ...objectMusic(4), playlist: { ID: 99, tracks: [{ ID: 1 }] } } }),
),
/Музыка изменилась/,
)
})
test('истечение JWT не обрывает показ и оставляет доступным повторный вход', async (t) => {
const f = await fixture(t)
await f.prepare()
f.controller.output(true)
send(f.controller, { action: 'play', channel: 'main' })
f.model.accessExpired = true
f.model.refreshExpired = true
await assert.rejects(f.online.act({ action: 'games' }), /Сессия истекла/)
assert.equal(f.online.state.authenticated, false)
assert.equal(f.controller.state.channels.main.desired, 'playing')
assert.equal(f.controller.state.catalog.key, f.store.list()[0]?.key)
})
test('отмена подготовки не меняет готовую версию и снимает запрет открытия', async (t) => {
const f = await fixture(t)
await f.prepare()
const old = f.store.list()[0]!.version
const original = f.store.download.bind(f.store)
let started!: () => void
const ready = new Promise<void>((resolve) => {
started = resolve
})
f.store.download = async (description, options) => {
started()
await new Promise<void>((resolve) =>
options!.signal!.addEventListener('abort', () => resolve(), { once: true }),
)
return original(description, options)
}
const pending = f.online.act({ action: 'download', gameId: 7 })
const failure = assert.rejects(pending)
await ready
assert.equal(f.online.blocksOutput, true)
await assert.rejects(f.online.select(null), /Подождите/)
await f.online.act({ action: 'cancel' })
await failure
assert.equal(f.online.blocksOutput, false)
assert.equal(f.store.list()[0]?.version, old)
})
test('одинаковая серверная сцена с новой версией отменяет локальное переключение', () => {
const c = new Controller(() => {})
const previous = parseRemote({ mode: 1, stateVersion: '1' })
send(c, { action: 'mode', mode: 8 })
applyRemote(c, previous, parseRemote({ mode: 1, stateVersion: '2' }))
assert.equal(c.state.scene.mode, 1)
send(c, { action: 'mode', mode: 8 })
applyRemote(
c,
parseRemote({ mode: 7, stateVersion: '2' }),
parseRemote({ mode: 7, stateVersion: '3', command: 8, timerState: 2 }),
)
assert.equal(c.state.scene.mode, 7)
assert.equal(c.state.timer.status, 'running')
})
test('откат версии не позволяет повторно выполнить уже полученную команду', async (t) => {
const f = await fixture(t)
await f.prepare()
f.controller.output(true)
await f.online.poll()
f.model.remote.stateVersion = '9007199254740995'
await f.online.poll()
send(f.controller, { action: 'pause', channel: 'main' })
f.model.remote.stateVersion = '9007199254740994'
await f.online.poll()
f.model.remote.stateVersion = '9007199254740995'
await f.online.poll()
assert.equal(f.controller.state.channels.main.desired, 'paused')
})
test('окончание серверного трека обновляет сервер один раз и не перематывает локальный следующий', async (t) => {
const f = await fixture(t)
await f.prepare()
f.controller.output(true)
f.model.remote.music = objectMusic(1)
await f.online.poll()
f.model.remote.music = objectMusic(2)
f.model.remote.stateVersion = '9007199254740994'
await f.online.poll()
send(f.controller, { action: 'play', channel: 'music' })
const report = {
session: f.controller.state.session,
channel: 'music' as const,
operation: f.controller.state.channels.music.operation,
status: 'ended' as const,
position: 4,
duration: 4,
}
f.controller.report(report)
f.controller.report(report)
await new Promise((resolve) => setTimeout(resolve, 10))
assert.equal(f.model.advanceCalls, 1)
assert.equal(f.controller.state.channels.music.trackIndex, 1)
send(f.controller, { action: 'seek', channel: 'music', position: 2 })
f.model.remote.music = objectMusic(2, 1)
await f.online.poll()
assert.equal(f.controller.state.channels.music.position, 2)
})
test('protobuf JSON с опущенными нулями сохраняет тишину и завершённый таймер', () => {
const state = parseRemote({
mode: 7,
timerDurationSeconds: '60',
timerState: 'SCREEN_TIMER_STATE_FINISHED',
music: { stateVersion: '1' },
})
assert.equal(state.volume, 0)
assert.equal(state.music?.volume, 0)
assert.equal(state.timer.remainingMs, 0)
assert.equal(state.timer.status, 'finished')
assert.throws(() => parseRemote({ stateVersion: 9007199254740992 }))
})
test('вход без прав управления не сохраняет токены даже после их обновления', async (t) => {
const f = await fixture(t)
f.model.accessExpired = true
f.model.roles = ['player']
await assert.rejects(f.login(), /нужны права/)
assert.equal(f.online.state.authenticated, false)
assert.equal(f.online.state.remembered, false)
assert.equal(f.model.refreshCalls, 1)
await assert.rejects(readFile(join(f.directory, 'connection', 'credentials.bin')), {
code: 'ENOENT',
})
})
+697
View File
@@ -0,0 +1,697 @@
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { OnlineIntent, OnlineState } from '../src/types/online.js'
import type { Report, ResultsSnapshot } from '../src/types/screen.js'
import type { Controller } from './controller.js'
import { PackageStore, packageKey } from './package-store.js'
import { prepareScreenPackage } from './screen-package.js'
import { parseResults } from './results.js'
import {
applyRemote,
integer,
object,
parseRemote,
send,
text,
type RemoteState,
} from './remote-state.js'
export interface SecretStorage {
available(): boolean
encrypt(value: string): Buffer
decrypt(value: Buffer): string
}
interface ScreenSession {
id: string
token: string
gameId: number
}
interface Credentials {
server: string
access: string
refresh: string
screen: ScreenSession | null
}
class ApiError extends Error {
constructor(public status: number) {
super(
status === 401
? 'Сессия истекла. Войдите снова'
: status === 403
? 'Нет доступа к действию'
: status === 400
? 'Действие пока недоступно для этой игры'
: `Ошибка сервера: HTTP ${status}`,
)
}
}
function serverURL(value: unknown): string {
const url = new URL(text(value))
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash ||
(url.pathname !== '/' && url.pathname !== '')
)
throw new Error('Укажите адрес HTTP(S) сервера без пути и параметров')
return url.origin
}
function token(value: unknown): string {
const s = text(value)
if (!s || s.length > 16384 || /[\r\n]/.test(s))
throw new Error('Сервер не вернул действующие токены')
return s
}
export class Online {
readonly state: OnlineState = {
server: '',
authenticated: false,
remembered: false,
busy: false,
canCancel: false,
games: [],
screenId: null,
connection: 'disabled',
error: '',
notice: '',
progress: null,
packagesRevision: 0,
}
private credentials: Credentials | null = null
private screen: ScreenSession | null = null
private generation = 0
private refreshPromise: Promise<void> | null = null
private writes: Promise<void> = Promise.resolve()
private cancellation: AbortController | null = null
private downloadKey: string | null = null
private previous: RemoteState | null = null
private pollBusy = false
private pollSession = ''
private initialSound = true
private lastResults = -Infinity
private resultsBusy = false
private advanceBusy = false
constructor(
private directory: string,
private packages: PackageStore,
private controller: Controller,
private secrets: SecretStorage,
private fetchAPI: typeof fetch = fetch,
) {
controller.onMusicEnded = (report) => {
void this.musicEnded(report)
}
}
get blocksOutput() {
return this.downloadKey !== null && this.downloadKey === this.controller.state.catalog.key
}
get busy() {
return this.state.busy
}
snapshot(): OnlineState {
return structuredClone(this.state)
}
private async atomic(name: string, data: string | Buffer) {
await mkdir(this.directory, { recursive: true, mode: 0o700 })
const path = join(this.directory, name)
await writeFile(`${path}.tmp`, data, { mode: 0o600 })
await rename(`${path}.tmp`, path)
}
private persistSecrets() {
const data = this.credentials ? { ...this.credentials, screen: this.screen } : null
this.state.remembered = !!data && this.secrets.available()
let encrypted: Buffer | null = null
try {
if (this.state.remembered) encrypted = this.secrets.encrypt(JSON.stringify(data))
} catch {
this.state.remembered = false
this.state.notice =
'Не удалось сохранить вход средствами ОС. Сессия действует до закрытия приложения'
}
const write = this.writes.then(async () => {
if (encrypted) await this.atomic('credentials.bin', encrypted)
else await rm(join(this.directory, 'credentials.bin'), { force: true })
})
this.writes = write.catch(() => {})
return write
}
async initialize() {
try {
const config = object(
JSON.parse(await readFile(join(this.directory, 'selection.json'), 'utf8')),
)
if (config.server) this.state.server = serverURL(config.server)
if (typeof config.key === 'string' && /^[a-f0-9]{64}$/.test(config.key))
await this.select(config.key, false)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT')
this.state.error = 'Не удалось восстановить выбранные материалы; выберите готовый пакет'
}
if (!this.secrets.available()) return
try {
const raw = object(
JSON.parse(this.secrets.decrypt(await readFile(join(this.directory, 'credentials.bin')))),
)
const server = serverURL(raw.server)
const c: Credentials = {
server,
access: token(raw.access),
refresh: token(raw.refresh),
screen: null,
}
if (raw.screen) {
const s = object(raw.screen)
c.screen = {
id: String(integer(s.id, 1)),
token: token(s.token),
gameId: integer(s.gameId, 1),
}
}
this.credentials = c
this.state.server = server
this.state.authenticated = true
this.state.remembered = true
const pkg = this.packages.list().find((p) => p.key === this.controller.state.catalog.key)
if (c.screen && pkg?.package.server === server && pkg.package.game.id === c.screen.gameId) {
this.screen = c.screen
this.state.screenId = c.screen.id
this.state.connection = 'waiting'
this.initialSound = true
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT')
this.state.error =
'Сохранённый вход недоступен. Войдите снова; локальные материалы доступны'
}
}
private async saveSelection() {
await this.atomic(
'selection.json',
JSON.stringify({ server: this.state.server, key: this.controller.state.catalog.key }),
)
}
async select(key: string | null, persist = true) {
if (this.busy) throw new Error('Подождите завершения подготовки материалов')
let pkg
try {
pkg = key === null ? null : await this.packages.verify(key)
} catch (error) {
if (key === this.controller.state.catalog.key) {
this.detach()
this.controller.selectPackage(null)
await this.saveSelection()
await this.persistSecrets()
this.state.packagesRevision++
}
throw error
}
this.detach()
this.controller.selectPackage(pkg)
this.lastResults = -Infinity
if (key) await this.restoreResults(key)
if (persist) {
await this.saveSelection()
await this.persistSecrets()
}
}
private detach() {
this.generation++
this.screen = null
this.previous = null
this.state.screenId = null
this.state.connection = 'disabled'
}
private async raw(server: string, path: string, init: RequestInit = {}): Promise<Response> {
try {
return await this.fetchAPI(`${server}${path}`, {
...init,
redirect: 'error',
credentials: 'omit',
signal: AbortSignal.any([
AbortSignal.timeout(30_000),
...(init.signal ? [init.signal] : []),
]),
})
} catch {
throw new Error('Сервер недоступен или превышено время ожидания')
}
}
private async json(response: Response): Promise<Record<string, unknown>> {
if (!response.ok) throw new ApiError(response.status)
try {
return object(await response.json())
} catch {
throw new Error('Сервер вернул некорректный JSON')
}
}
private async refresh(failed: string, generation: number) {
if (this.credentials?.access !== failed) return
if (this.refreshPromise) return this.refreshPromise
const c = this.credentials!
this.refreshPromise = (async () => {
const response = await this.raw(c.server, '/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: c.refresh }),
})
if (generation !== this.generation) throw new Error('Сессия изменилась')
if (response.status === 401) {
this.credentials = null
this.state.authenticated = false
await this.persistSecrets()
throw new ApiError(401)
}
const result = await this.json(response)
if (generation !== this.generation) throw new Error('Сессия изменилась')
this.credentials = {
...c,
access: token(result.accessToken),
refresh: token(result.refreshToken),
}
await this.persistSecrets()
})().finally(() => {
this.refreshPromise = null
})
return this.refreshPromise
}
private async authorized(path: string, init: RequestInit = {}) {
const c = this.credentials
const generation = this.generation
if (!c) throw new ApiError(401)
const request = (access: string) => {
const headers = new Headers(init.headers)
headers.set('Authorization', `Bearer ${access}`)
return this.raw(c.server, path, { ...init, headers })
}
let response = await request(c.access)
if (generation !== this.generation) throw new Error('Сессия изменилась')
if (response.status === 401) {
await response.body?.cancel()
await this.refresh(c.access, generation)
if (generation !== this.generation || !this.credentials) throw new ApiError(401)
response = await request(this.credentials.access)
if (response.status === 401) {
this.credentials = null
this.state.authenticated = false
await this.persistSecrets()
}
}
return response
}
private async api(path: string, body?: object, authorized = true, signal?: AbortSignal) {
const init: RequestInit =
body === undefined
? { signal }
: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal,
}
return this.json(
authorized
? await this.authorized(path, init)
: await this.raw(this.state.server, path, init),
)
}
async act(input: unknown): Promise<OnlineState> {
const data = object(input)
if (data.action === 'cancel') {
this.cancellation?.abort()
this.packages.cancel()
return this.snapshot()
}
if (this.busy) throw new Error('Подождите завершения текущего действия')
this.state.busy = true
this.state.error = ''
this.state.notice = ''
try {
await this.perform(data)
} catch (error) {
this.state.error = error instanceof Error ? error.message : 'Действие не выполнено'
throw new Error(this.state.error)
} finally {
this.state.busy = false
this.state.progress = null
}
return this.snapshot()
}
private async perform(data: Record<string, unknown>) {
const action = data.action as OnlineIntent['action']
switch (action) {
case 'login': {
const server = serverURL(data.server)
const email = text(data.email).trim()
const password = text(data.password)
if (!email.includes('@') || email.length > 320 || !password || password.length > 4096)
throw new Error('Укажите email и пароль')
this.detach()
this.credentials = null
this.state.authenticated = false
this.state.games = []
await this.persistSecrets()
this.state.server = server
const response = await this.raw(server, '/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const result = await this.json(response)
this.credentials = {
server,
access: token(result.accessToken),
refresh: token(result.refreshToken),
screen: null,
}
try {
const me = object((await this.api('/api/users/me')).user)
if (
!Array.isArray(me.roles) ||
!me.roles.some((r) => ['admin', 'organizer', 'author'].includes(String(r)))
)
throw new Error(
'Для управления экраном нужны права организатора, автора или администратора',
)
} catch (error) {
this.credentials = null
await this.persistSecrets()
throw error
}
this.state.authenticated = true
await this.persistSecrets()
await this.saveSelection()
await this.loadGames()
return
}
case 'logout':
this.detach()
this.credentials = null
this.state.authenticated = false
this.state.games = []
await this.persistSecrets()
return
case 'games':
return this.loadGames()
case 'download':
case 'check': {
const gameId = integer(data.gameId, 1)
const key = packageKey(this.state.server, gameId)
if (
action === 'download' &&
key === this.controller.state.catalog.key &&
this.controller.state.outputOpen
)
throw new Error('Закройте показ перед обновлением выбранной игры')
if (!this.credentials) throw new ApiError(401)
this.downloadKey = action === 'download' ? key : null
this.cancellation = new AbortController()
this.state.canCancel = true
try {
const description = await prepareScreenPackage(
{
server: this.state.server,
gameId,
token: this.credentials.access,
signal: this.cancellation.signal,
},
(url, init) => {
const parsed = new URL(String(url))
if (parsed.origin !== this.state.server) throw new Error('Недопустимый сервер API')
return this.authorized(parsed.pathname, init)
},
)
if (action === 'check') {
const previous = this.packages.list().find((p) => p.key === key)
this.state.notice = !previous
? 'Игра ещё не загружена'
: previous.package.metadataVersion === description.metadataVersion
? 'Описание не изменилось. Для проверки замены файлов по прежним адресам загрузите материалы повторно'
: 'На сервере есть изменения. Загрузите материалы при закрытом показе'
return
}
const pkg = await this.packages.download(description, {
signal: this.cancellation.signal,
onProgress: (p) => {
this.state.progress = p
},
})
this.state.packagesRevision++
if (key === this.controller.state.catalog.key) {
this.controller.selectPackage(pkg)
this.previous = null
await this.restoreResults(key)
}
this.state.notice = 'Материалы проверены и готовы к показу'
} finally {
this.cancellation = null
this.state.canCancel = false
this.downloadKey = null
}
return
}
case 'remove': {
const key = text(data.key)
if (!/^[a-f0-9]{64}$/.test(key)) throw new Error('Некорректный пакет')
if (key === this.controller.state.catalog.key)
throw new Error('Сначала выберите другой пакет или тестовый режим')
await this.packages.remove(key)
await rm(join(this.directory, `results-${key}.json`), { force: true })
this.state.packagesRevision++
return
}
case 'connect': {
const pkg = this.packages.list().find((p) => p.key === this.controller.state.catalog.key)
if (!pkg || pkg.package.server !== this.state.server)
throw new Error('Выберите готовую игру с этого сервера')
if (!this.credentials) throw new ApiError(401)
this.detach()
const result = await this.api('/api/screens', {}, false)
const s: ScreenSession = {
id: String(integer(result.screenID, 1)),
token: token(result.accessToken),
gameId: pkg.package.game.id,
}
await this.api(`/api/screens/${s.id}/bind`, {
screenID: s.id,
accessToken: s.token,
gameID: s.gameId,
})
this.screen = s
this.state.screenId = s.id
this.state.connection = 'waiting'
this.initialSound = true
await this.persistSecrets()
return
}
case 'disconnect':
this.detach()
await this.persistSecrets()
return
case 'sound':
if (!this.screen) throw new Error('Сначала подключите управление')
await this.api(`/api/screens/${this.screen.id}/sound`, {
screenID: this.screen.id,
soundEnabled: true,
})
return
case 'results':
return this.refreshResults()
default:
throw new Error('Неизвестное действие')
}
}
private async loadGames() {
const result = await this.api('/api/games')
if (!Array.isArray(result.games ?? [])) throw new Error('Некорректный список игр')
this.state.games = ((result.games ?? []) as unknown[]).map((value) => {
const g = object(value)
return { id: integer(g.ID, 1), name: text(g.name), status: text(g.status ?? '') }
})
}
private async restoreResults(key: string) {
try {
this.controller.setResults(
parseResults(
JSON.parse(await readFile(join(this.directory, `results-${key}.json`), 'utf8')),
key,
),
)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT')
this.state.error = 'Сохранённые результаты недоступны; обновите снимок'
}
}
async refreshResults() {
if (this.resultsBusy) return
const s = this.screen
const key = this.controller.state.catalog.key
if (!s || !key) throw new Error('Подключите экран к выбранной игре для получения результатов')
const generation = this.generation
this.resultsBusy = true
this.lastResults = Date.now()
try {
const result = await this.api(
'/api/screens/results',
{ screenID: s.id, accessToken: s.token },
false,
)
if (generation !== this.generation || key !== this.controller.state.catalog.key) return
if (!Array.isArray(result.questions ?? []) || !Array.isArray(result.teams ?? []))
throw new Error('Некорректные результаты')
const snapshot: ResultsSnapshot = parseResults(
{
packageKey: key,
capturedAt: new Date().toISOString(),
questions: result.questions ?? [],
teams: ((result.teams ?? []) as unknown[]).map((value) => {
const row = object(value)
const team = object(row.team)
if (!Array.isArray(row.answers ?? [])) throw new Error('Некорректные ответы')
return {
id: text(team.ID),
name: text(team.name),
place: integer(row.place, 1),
totalScore: integer(row.totalScore ?? 0, -2147483648),
actionsCount: integer(row.actionsCount ?? 0),
answers: ((row.answers ?? []) as unknown[]).map((value) => {
const a = object(value)
return {
questionCode: text(a.questionCode),
score: integer(a.score ?? 0, -2147483648),
}
}),
}
}),
},
key,
)
await this.atomic(`results-${key}.json`, JSON.stringify(snapshot))
if (generation === this.generation && key === this.controller.state.catalog.key)
this.controller.setResults(snapshot)
} finally {
this.resultsBusy = false
}
}
async poll() {
const s = this.screen
const session = this.controller.state.session
if (!s || !this.controller.state.outputOpen) {
this.previous = null
if (s) this.state.connection = 'waiting'
return
}
if (this.pollBusy) return
if (this.pollSession !== session) {
this.previous = null
this.pollSession = session
}
this.pollBusy = true
const generation = this.generation
try {
const result = await this.api(
'/api/screens/state',
{ screenID: s.id, accessToken: s.token },
false,
AbortSignal.timeout(5000),
)
if (
generation !== this.generation ||
session !== this.controller.state.session ||
!this.controller.state.outputOpen
)
return
let next = parseRemote(result.state)
if (next.gameId !== s.gameId) {
this.detach()
await this.persistSecrets()
this.state.error =
'Привязка экрана изменилась на сервере. Локальный показ сохранён; подключите управление заново'
return
}
const previous = this.previous
if (previous && next.version < previous.version)
next = { ...previous, sound: next.sound, music: next.music }
if (previous?.music && next.music && next.music.version < previous.music.version)
next = { ...next, music: previous.music }
this.previous = next
if (this.state.connection === 'offline') this.state.error = ''
this.state.connection = 'connected'
if (!previous && this.initialSound) {
this.initialSound = false
send(this.controller, { action: 'mute', enabled: !next.sound })
}
if (previous) {
try {
applyRemote(this.controller, previous, next)
} catch (error) {
this.state.error = error instanceof Error ? error.message : 'Команда сервера не выполнена'
}
}
if (this.controller.state.scene.mode === 6 && Date.now() - this.lastResults >= 5000)
void this.refreshResults().catch((error) => {
this.state.error =
error instanceof Error ? error.message : 'Не удалось обновить результаты'
})
} catch (error) {
if (generation !== this.generation || session !== this.controller.state.session) return
this.previous = null
if (error instanceof ApiError && error.status === 401) {
this.detach()
this.state.connection = 'expired'
await this.persistSecrets().catch(() => {})
} else this.state.connection = 'offline'
this.state.error =
error instanceof Error ? error.message : 'Не удалось получить состояние экрана'
} finally {
this.pollBusy = false
}
}
async musicEnded(report: Report) {
const m = this.previous?.music
const c = this.controller.state.channels.music
if (
!this.screen ||
!m ||
this.advanceBusy ||
this.state.connection !== 'connected' ||
report.channel !== 'music' ||
report.status !== 'ended' ||
report.session !== this.controller.state.session ||
report.operation !== c.operation ||
c.desired !== 'playing' ||
c.playlistId !== m.playlistId ||
c.source?.id !== `track-${m.playlistId}-${m.trackId}` ||
m.playback !== 2
)
return
this.advanceBusy = true
const generation = this.generation
try {
const result = await this.api(
'/api/screens/music/advance',
{ screenID: this.screen.id, accessToken: this.screen.token },
false,
AbortSignal.timeout(5000),
)
if (generation === this.generation && this.previous?.music?.version === m.version) {
const music = parseRemote({ music: result.state }).music
if (music) this.previous = { ...this.previous, music }
}
} catch {
if (generation === this.generation) {
this.previous = null
this.state.connection = 'offline'
this.state.error = 'Переход музыки выполнен локально; сервер не подтвердил обновление'
}
} finally {
this.advanceBusy = false
}
}
close() {
this.cancellation?.abort()
this.packages.cancel()
this.detach()
}
}
+4
View File
@@ -1,7 +1,11 @@
import type { OnlineIntent } from '../src/types/online.js'
import { contextBridge, ipcRenderer } from 'electron'
import type { Command, Report, SceneReport, State } from '../src/types/screen.js'
contextBridge.exposeInMainWorld('screenBridge', {
onlineState: () => ipcRenderer.invoke('online-state'),
onlineAction: (intent: OnlineIntent) => ipcRenderer.invoke('online-action', intent),
closeOutput: () => ipcRenderer.invoke('close-output'),
command: (command: Command) => ipcRenderer.invoke('command', command),
report: (report: Report) => ipcRenderer.send('report', report),
reportScene: (report: SceneReport) => ipcRenderer.send('scene-report', report),
+224
View File
@@ -0,0 +1,224 @@
import type { Controller } from './controller.js'
import type { Intent, ScreenMode } from '../src/types/screen.js'
export function object(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new Error('Некорректный ответ сервера')
return value as Record<string, unknown>
}
export function integer(value: unknown, min = 0, max = Number.MAX_SAFE_INTEGER): number {
const n = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value
if (typeof n !== 'number' || !Number.isSafeInteger(n) || n < min || n > max)
throw new Error('Некорректное число в ответе сервера')
return n
}
export function text(value: unknown): string {
if (typeof value !== 'string') throw new Error('Некорректный текст в ответе сервера')
return value
}
function enumeration(value: unknown, prefix: string, names: string[]): number {
const n =
typeof value === 'string' && value.startsWith(prefix)
? names.indexOf(value.slice(prefix.length))
: integer(value ?? 0)
return integer(n, 0, names.length - 1)
}
function version(value: unknown): bigint {
if (typeof value === 'number' && !Number.isSafeInteger(value))
throw new Error('Некорректная версия состояния')
const v = String(value ?? '0')
if (!/^\d{1,19}$/.test(v) || BigInt(v) > 9223372036854775807n)
throw new Error('Некорректная версия состояния')
return BigInt(v)
}
export interface RemoteState {
gameId: number | null
version: bigint
mode: ScreenMode
command: number
position: number
volume: number
sound: boolean
text: string
timer: {
label: string
durationMs: number
remainingMs: number
status: 'stopped' | 'running' | 'paused' | 'finished'
}
music: {
version: bigint
playlistId: number | null
trackId: number | null
trackIds: number[]
position: number
volume: number
loop: boolean
playback: number
} | null
}
export function parseRemote(value: unknown): RemoteState {
const s = object(value)
const mode =
enumeration(s.mode, 'SCREEN_MODE_', [
'UNSPECIFIED',
'COVER',
'INTRO',
'INTRO_DONE',
'CONCLUSION',
'CONCLUSION_DONE',
'RESULTS',
'TIMER',
'TEXT',
]) || 1
const duration = integer(s.timerDurationSeconds ?? 600, 1, 86400)
const remaining = integer(s.timerRemainingSeconds ?? 0, 0, duration)
const timerState = enumeration(s.timerState, 'SCREEN_TIMER_STATE_', [
'UNSPECIFIED',
'STOPPED',
'RUNNING',
'PAUSED',
'FINISHED',
])
let music: RemoteState['music'] = null
if (s.music) {
const m = object(s.music)
const p = m.playlist ? object(m.playlist) : null
const tracks = p?.tracks ?? []
if (!Array.isArray(tracks)) throw new Error('Некорректный плейлист')
const trackIds = tracks.map((t) => integer(object(t).ID, 1))
if (new Set(trackIds).size !== trackIds.length) throw new Error('Повтор трека в плейлисте')
const index = integer(m.trackIndex ?? 0)
music = {
version: version(m.stateVersion),
playlistId: p ? integer(p.ID, 1) : null,
trackId: trackIds[index] ?? null,
trackIds,
position: integer(m.positionMs ?? 0) / 1000,
volume: integer(m.volume ?? 0, 0, 100),
loop: m.loop === true,
playback: enumeration(m.playback, 'MUSIC_PLAYBACK_', [
'UNSPECIFIED',
'STOPPED',
'PLAYING',
'PAUSED',
]),
}
}
return {
gameId: s.gameID == null ? null : integer(s.gameID, 1),
version: version(s.stateVersion),
mode: mode as ScreenMode,
command: enumeration(s.command, 'SCREEN_COMMAND_', [
'UNSPECIFIED',
'PLAY',
'PAUSE',
'RESUME',
'STOP',
'REPLAY',
'SEEK',
'VOLUME',
'TIMER_START',
'TIMER_PAUSE',
'TIMER_RESUME',
'TIMER_RESET',
]),
position: integer(s.positionMs ?? 0) / 1000,
volume: integer(s.volume ?? 0, 0, 100),
sound: s.soundEnabled === true,
text: text(s.text ?? '').slice(0, 10000),
timer: {
label: text(s.timerLabel ?? 'Перерыв').slice(0, 200),
durationMs: duration * 1000,
remainingMs: remaining * 1000,
status: (['stopped', 'stopped', 'running', 'paused', 'finished'] as const)[timerState]!,
},
music,
}
}
export function send(controller: Controller, intent: Intent) {
controller.command({
...intent,
session: controller.state.session,
sequence: controller.state.sequence + 1,
})
}
// Сравниваем с предыдущим ответом сервера, а не с локальными действиями ведущего.
export function applyRemote(controller: Controller, previous: RemoteState, next: RemoteState) {
if (next.sound !== previous.sound) send(controller, { action: 'mute', enabled: !next.sound })
if (next.version > previous.version) {
if (
next.command === 0 ||
(next.command >= 8 && controller.state.scene.mode !== 7) ||
next.mode !== previous.mode ||
next.text !== previous.text ||
(next.mode === 7 &&
(next.timer.label !== previous.timer.label ||
next.timer.durationMs !== previous.timer.durationMs))
) {
if (next.mode === 8 && next.text.trim()) send(controller, { action: 'text', text: next.text })
else send(controller, { action: 'mode', mode: next.mode })
}
if (next.mode === 7) controller.synchronizeTimer(next.timer)
if (next.command > 0 && next.command < 8) {
if (next.command === 7)
send(controller, { action: 'volume', channel: 'main', volume: next.volume })
else if (next.mode === 2 || next.mode === 4) {
if (controller.state.scene.mode !== next.mode)
send(controller, { action: 'mode', mode: next.mode })
send(controller, { action: 'volume', channel: 'main', volume: next.volume })
const action = (['', 'play', 'pause', 'resume', 'stop', 'replay', 'seek'] as const)[
next.command
]!
if (action === 'seek')
send(controller, { action, channel: 'main', position: next.position })
else if (action) {
send(controller, { action, channel: 'main' })
}
}
}
}
const m = next.music
if (!m || (previous.music && m.version <= previous.music.version)) return
if (!m.playlistId) {
if (controller.state.channels.music.source)
send(controller, { action: 'stop', channel: 'music' })
return
}
const playlist = controller.state.catalog.playlists.find((p) => p.id === m.playlistId)
if (
!playlist ||
playlist.tracks.length !== m.trackIds.length ||
m.trackIds.some((id) => !playlist.tracks.some((t) => t.id === id))
) {
if (controller.state.channels.music.source)
send(controller, { action: 'stop', channel: 'music' })
throw new Error('Музыка изменилась на сервере. Подготовьте материалы при закрытом показе')
}
const index = playlist.tracks.findIndex((t) => t.id === m.trackId)
if (index < 0) throw new Error('Трек отсутствует в подготовленных материалах')
const old = previous.music
const transportChanged =
!old ||
old.playlistId !== m.playlistId ||
old.trackId !== m.trackId ||
old.playback !== m.playback ||
old.position !== m.position
if (transportChanged) {
const c = controller.state.channels.music
if (c.playlistId !== playlist.id)
send(controller, { action: 'playlist', channel: 'music', playlistId: playlist.id })
if (controller.state.channels.music.trackIndex !== index)
send(controller, { action: 'track', channel: 'music', trackIndex: index })
}
send(controller, { action: 'volume', channel: 'music', volume: m.volume })
send(controller, { action: 'loop', channel: 'music', enabled: m.loop })
if (transportChanged) {
send(controller, {
action: m.playback === 2 ? 'resume' : m.playback === 3 ? 'pause' : 'stop',
channel: 'music',
})
if (m.playback !== 1)
send(controller, { action: 'seek', channel: 'music', position: m.position })
}
}