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 { Intent, PlanStatus, Report, ResultsSnapshot, ScreenPlan, } 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 { parsePlan } from './screen-plan.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 NetworkError extends Error {} 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, soundEnabled: 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 | null = null private writes: Promise = Promise.resolve() private cancellation: AbortController | null = null private downloadKey: string | null = null private previous: RemoteState | null = null private pollBusy = false private pollSession = '' private lastSound: boolean | null = null private lastResults = -Infinity private resultsBusy = false private advanceBusy = false private plan: ScreenPlan = { steps: [], currentStep: 0 } private planBusy = 0 private planCommands: Promise = Promise.resolve(true) private planPolling: Promise | null = null private lastPlan = -Infinity private planVersion = 0 private planWrites: Promise = Promise.resolve() private cachedPlan = '' private planNotice = '' planStatus(): PlanStatus { const pkg = this.selectedGame() return { key: this.controller.state.catalog.key, editable: !!pkg && !!this.credentials && pkg.package.server === this.credentials.server, notice: this.planNotice, } } private selectedGame() { return this.packages.list().find((p) => p.key === this.controller.state.catalog.key) } private planGame() { const pkg = this.selectedGame() if (!pkg || !this.credentials || pkg.package.server !== this.credentials.server) throw new Error('Войдите на сервер выбранной подготовленной игры') return pkg } private async acceptPlan(plan: ScreenPlan, key: string, generation: number, version: number) { if ( generation !== this.generation || key !== this.controller.state.catalog.key || version !== this.planVersion ) return const serialized = JSON.stringify(plan) const hasChanged = this.cachedPlan !== serialized this.plan = plan this.controller.setPlan(plan.steps) this.planNotice = '' if (!hasChanged) return try { const write = this.planWrites.then(async () => { if (generation === this.generation && version === this.planVersion) await this.atomic(`plan-${key}.json`, serialized) if (generation === this.generation && version === this.planVersion) this.cachedPlan = serialized }) this.planWrites = write.catch(() => {}) await write } catch { if (generation === this.generation && version === this.planVersion) this.planNotice = 'План получен с сервера, но локальную копию сохранить не удалось' } } async refreshPlan() { if (this.planPolling) return this.planPolling const task = this.loadPlan() this.planPolling = task try { await task } finally { this.planPolling = null } } private async loadPlan() { if (this.planBusy || !this.planStatus().editable) return const pkg = this.planGame() const generation = this.generation const version = this.planVersion this.lastPlan = Date.now() try { const result = await this.api( `/api/games/${pkg.package.game.id}/screen-plan`, undefined, true, AbortSignal.timeout(5000), ) await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) } catch (error) { if (generation === this.generation && version === this.planVersion) this.planNotice = `${error instanceof Error ? error.message : 'План недоступен'}. Используется локальная копия` } } private async savePlan(data: Record) { if (this.planBusy) throw new Error('Подождите завершения серверной команды') if (!Array.isArray(data.steps)) throw new Error('Некорректные шаги плана') const pkg = this.planGame() if (data.key !== pkg.key) throw new Error('Выбранная игра изменилась') const plan = parsePlan({ steps: data.steps, currentStep: 0 }) plan.currentStep = Math.min(this.plan.currentStep, Math.max(0, plan.steps.length - 1)) const generation = this.generation const version = ++this.planVersion this.planBusy++ try { const result = await this.json( await this.authorized(`/api/games/${pkg.package.game.id}/screen-plan`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(plan), }), ) if (generation !== this.generation) throw new Error('Сессия изменилась') await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) } finally { this.planBusy-- } } async planCommand(intent: Intent) { if (this.busy) throw new Error('Подождите завершения текущего действия') const pkg = this.selectedGame() // Локальная команда не ждёт сеть и никогда не откатывается при обрыве связи. send(this.controller, intent) const sequence = this.controller.state.sequence const version = ++this.planVersion if (!pkg || !this.planStatus().editable) { this.planNotice = 'Переход выполнен только локально; войдите на сервер игры' return } const generation = this.generation const session = this.controller.state.session const hasPendingCommand = this.planBusy > 0 const previousCommand = this.planCommands let complete!: (available: boolean) => void this.planCommands = new Promise((resolve) => { complete = resolve }) this.planBusy++ let isAvailable = true let expectedCommand = 0 try { // В сеть уходит только последняя команда; после обрыва ожидающие действия не повторяются. const wasAvailable = await previousCommand isAvailable = !hasPendingCommand || wasAvailable if ( generation !== this.generation || version !== this.planVersion || session !== this.controller.state.session ) return if (hasPendingCommand && !wasAvailable) { isAvailable = false this.previous = null this.planNotice = 'Команда выполнена локально; сервер недоступен' return } if (intent.action === 'step') { const result = await this.api( `/api/games/${pkg.package.game.id}/screen-plan/step`, { currentStep: intent.index }, true, AbortSignal.timeout(5000), ) const confirmed = parsePlan(result.plan) const shown = this.controller.state.catalog.steps[intent.index] await this.acceptPlan(confirmed, pkg.key, generation, version) if ( generation !== this.generation || version !== this.planVersion || session !== this.controller.state.session ) return if ( this.controller.state.sequence === sequence && JSON.stringify(shown) !== JSON.stringify(confirmed.steps[intent.index]) ) { if (confirmed.steps[intent.index]) send(this.controller, intent) } } else { const index = this.controller.state.scene.stepIndex if (index !== null && index !== this.plan.currentStep) { const result = await this.api( `/api/games/${pkg.package.game.id}/screen-plan/step`, { currentStep: index }, true, AbortSignal.timeout(5000), ) await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) if ( generation !== this.generation || version !== this.planVersion || session !== this.controller.state.session ) return } const commands = { play: 1, pause: 2, resume: 3, stop: 4, replay: 5, seek: 6, volume: 7, 'timer-start': 8, 'timer-pause': 9, 'timer-resume': 10, 'timer-reset': 11, } if (!(intent.action in commands)) throw new Error('Некорректная команда плана') expectedCommand = commands[intent.action as keyof typeof commands] await this.api( `/api/games/${pkg.package.game.id}/screens/command`, { command: expectedCommand, positionMs: 'position' in intent ? Math.round(intent.position * 1000) : 0, volume: 'volume' in intent ? intent.volume : this.controller.state.channels.main.volume, }, true, AbortSignal.timeout(5000), ) } if ( generation !== this.generation || session !== this.controller.state.session || version !== this.planVersion ) return this.planNotice = '' // Ответ следующего опроса становится базой: собственная команда не применяется дважды. if (this.screen) { const result = await this.api( '/api/screens/state', { screenID: this.screen.id, accessToken: this.screen.token }, false, AbortSignal.timeout(5000), ) if (generation !== this.generation || session !== this.controller.state.session) return const remote = parseRemote(result.state) if (remote.gameId !== pkg.package.game.id) throw new Error('Привязка экрана изменилась') if (version !== this.planVersion) return const scene = this.controller.state.scene if ( this.controller.state.sequence === sequence && (remote.command !== expectedCommand || remote.mode !== scene.mode || (remote.mode === 8 && remote.text !== scene.text) || (remote.mode === 7 && (remote.timer.label !== this.controller.state.timer.label || remote.timer.durationMs !== this.controller.state.timer.durationMs)) || (intent.action === 'seek' && remote.position !== intent.position) || (intent.action === 'volume' && remote.volume !== intent.volume)) ) { applyRemote(this.controller, { ...remote, version: remote.version - 1n }, remote) if (remote.command === 0) { const result = await this.api( `/api/games/${pkg.package.game.id}/screen-plan`, undefined, true, AbortSignal.timeout(5000), ) await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) if ( generation !== this.generation || version !== this.planVersion || session !== this.controller.state.session ) return this.controller.markStep(this.plan.currentStep) } } if (!this.previous || this.previous.sound !== remote.sound) send(this.controller, { action: 'mute', enabled: !remote.sound }) this.lastSound = remote.sound this.state.soundEnabled = remote.sound this.previous = { ...remote, music: this.previous?.music ?? null } } } catch (error) { isAvailable = !(error instanceof NetworkError) if ( generation !== this.generation || session !== this.controller.state.session || version !== this.planVersion ) return this.previous = null this.planNotice = 'Команда выполнена локально; сервер не подтвердил обновление' if (!(error instanceof NetworkError)) throw error } finally { this.planBusy-- complete(isAvailable) } } 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' } } 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.plan = { steps: structuredClone(this.controller.state.catalog.steps), currentStep: 0 } this.planNotice = '' this.lastPlan = -Infinity this.cachedPlan = '' if (key) { try { this.plan = parsePlan( JSON.parse(await readFile(join(this.directory, `plan-${key}.json`), 'utf8')), ) this.controller.setPlan(this.plan.steps) this.cachedPlan = JSON.stringify(this.plan) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') this.planNotice = 'Локальная копия плана повреждена; используется план пакета' } } this.lastResults = -Infinity if (key) await this.restoreResults(key) if (persist) { await this.saveSelection() await this.persistSecrets() } } private detach() { this.generation++ this.planVersion++ this.screen = null this.previous = null this.lastSound = null this.state.soundEnabled = null this.state.screenId = null this.state.connection = 'disabled' } private async raw(server: string, path: string, init: RequestInit = {}): Promise { 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 NetworkError('Сервер недоступен или превышено время ожидания') } } private async json(response: Response): Promise> { 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 { 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) { const action = data.action as OnlineIntent['action'] switch (action) { case 'plan-save': return this.savePlan(data) case 'plan-refresh': return this.refreshPlan() 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.plan = { steps: structuredClone(this.controller.state.catalog.steps), currentStep: 0, } this.lastPlan = -Infinity this.cachedPlan = JSON.stringify(this.plan) await this.atomic(`plan-${key}.json`, this.cachedPlan) 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 }) await rm(join(this.directory, `plan-${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' await this.persistSecrets() return } case 'disconnect': this.detach() await this.persistSecrets() return case 'sound': { const generation = this.generation if (!this.screen) throw new Error('Сначала подключите управление') await this.api(`/api/screens/${this.screen.id}/sound`, { screenID: this.screen.id, soundEnabled: true, }) if (generation === this.generation) this.state.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() { if (!this.busy && !this.planBusy && Date.now() - this.lastPlan >= 5000) await this.refreshPlan() if (this.planBusy) return const planVersion = this.planVersion 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 || planVersion !== this.planVersion ) 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.command === 0) { await this.refreshPlan() if ( generation !== this.generation || session !== this.controller.state.session || planVersion !== this.planVersion ) return } 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 this.state.soundEnabled = next.sound if (this.state.connection === 'offline') this.state.error = '' this.state.connection = 'connected' if (!previous && this.lastSound !== next.sound) { send(this.controller, { action: 'mute', enabled: !next.sound }) } this.lastSound = next.sound if (previous) { try { applyRemote(this.controller, previous, next) if (next.version > previous.version && next.command === 0) this.controller.markStep(this.plan.currentStep) } 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 || planVersion !== this.planVersion ) 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() } }