1860 lines
74 KiB
TypeScript
1860 lines
74 KiB
TypeScript
import { requestErrorContext } from './request-error.js'
|
|
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
|
|
import { apiScreenMode, apiScreenCommand, apiPlan } from './api-values.js'
|
|
import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js'
|
|
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { once } from 'node:events'
|
|
import {
|
|
DEFAULT_SERVER,
|
|
type GameMaterial,
|
|
type OnlineIntent,
|
|
type OnlineState,
|
|
type GameStatus,
|
|
type GameCommand,
|
|
} from '../src/types/online.js'
|
|
import { gameCommands, gameStatuses } from '../src/utils/game-status.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 { type ScreenPackage, prepareScreenPackage, resolveMediaURL } 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
|
|
}
|
|
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 === DEFAULT_SERVER
|
|
? 'https://evening-detective-api.crabs-games.art'
|
|
: 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 = {
|
|
game: null,
|
|
gameError: '',
|
|
server: DEFAULT_SERVER,
|
|
currentGameId: null,
|
|
materials: [],
|
|
authenticated: false,
|
|
remembered: false,
|
|
busy: false,
|
|
busyMessage: '',
|
|
canCancel: false,
|
|
games: [],
|
|
playlists: [],
|
|
screenId: null,
|
|
soundEnabled: null,
|
|
connection: 'disabled',
|
|
error: '',
|
|
notice: '',
|
|
progress: null,
|
|
preparation: { active: false, completed: 0, total: 0, downloadedBytes: 0, error: '' },
|
|
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 received: RemoteState | null = null
|
|
private mainApplied: RemoteState | null = null
|
|
private musicApplied: RemoteState | null = null
|
|
private materialAbort = new AbortController()
|
|
private materialRefresh: Promise<void> | null = null
|
|
private materialWork: Promise<void> | null = null
|
|
private lastMaterials = -Infinity
|
|
private mainWork: Promise<void> | null = null
|
|
private musicWork: Promise<void> | null = null
|
|
private mainSession = ''
|
|
private musicSession = ''
|
|
private receivedSignature = ''
|
|
private localCommandVersion = 0
|
|
private materialSignature = ''
|
|
private gameContext = ''
|
|
private lastGameRefresh = -Infinity
|
|
private gameRevision = 0
|
|
private gameRequest: { context: string; promise: Promise<void> } | null = null
|
|
private syncGameContext() {
|
|
const pkg = this.selectedGame()
|
|
const context = pkg && this.state.authenticated && this.credentials?.server === pkg.package.server
|
|
? `${this.generation}:${pkg.key}:${pkg.package.server}` : ''
|
|
if (context !== this.gameContext) {
|
|
this.gameContext = context
|
|
this.gameRevision++
|
|
this.lastGameRefresh = -Infinity
|
|
this.state.game = null
|
|
this.state.gameError = ''
|
|
}
|
|
return context
|
|
}
|
|
async refreshGameState(force = false): Promise<void> {
|
|
const context = this.syncGameContext()
|
|
if (!context) return
|
|
if (!force && this.gameRequest?.context === context) return this.gameRequest.promise
|
|
if (!force && Date.now() - this.lastGameRefresh < 2000) return
|
|
const pkg = this.selectedGame()!
|
|
const revision = force ? ++this.gameRevision : this.gameRevision
|
|
this.lastGameRefresh = Date.now()
|
|
const current = () => this.syncGameContext() === context && this.gameRevision === revision
|
|
const promise = (async () => {
|
|
try {
|
|
const result = await this.client(true, AbortSignal.timeout(5000)).GetGame({ ID: pkg.package.game.id })
|
|
if (!current()) return
|
|
const game = object(result.game)
|
|
const id = integer(game.ID, 1)
|
|
const status = text(game.status)
|
|
if (id !== pkg.package.game.id || !Object.hasOwn(gameStatuses, status))
|
|
throw new Error('Сервер вернул неизвестное состояние игры')
|
|
this.state.game = { key: pkg.key, id, name: text(game.name), status: status as GameStatus }
|
|
this.state.gameError = ''
|
|
const listed = this.state.games.find(g => g.id === id)
|
|
if (listed) listed.status = status
|
|
} catch (error) {
|
|
if (current()) this.state.gameError = error instanceof Error ? error.message : 'Состояние игры недоступно'
|
|
}
|
|
})()
|
|
const request = { context, promise }
|
|
this.gameRequest = request
|
|
try {
|
|
await promise
|
|
} finally {
|
|
if (this.gameRequest === request) this.gameRequest = null
|
|
}
|
|
}
|
|
private async gameCommand(value: unknown, key: unknown) {
|
|
const context = this.syncGameContext()
|
|
if (!context) throw new Error('Выберите игру и войдите на её сервер')
|
|
if (key !== this.controller.state.catalog.key) throw new Error('Выбранная игра изменилась')
|
|
const command = text(value) as GameCommand
|
|
if (!['start', 'pause', 'play', 'finish', 'reset'].includes(command))
|
|
throw new Error('Неизвестная команда игры')
|
|
await this.refreshGameState(true)
|
|
if (this.syncGameContext() !== context) throw new SessionChangedError()
|
|
const game = this.state.game
|
|
if (!game || this.state.gameError) throw new Error(this.state.gameError || 'Состояние игры недоступно')
|
|
if (!gameCommands[game.status].includes(command)) throw new Error('Команда недоступна в текущем состоянии игры')
|
|
const client = this.client(true, AbortSignal.timeout(5000))
|
|
const actions = {
|
|
start: () => client.StartGame({ ID: game.id }),
|
|
pause: () => client.PauseGame({ ID: game.id }),
|
|
play: () => client.PlayGame({ ID: game.id }),
|
|
finish: () => client.FinishGame({ ID: game.id }),
|
|
reset: () => client.ResetGame({ ID: game.id }),
|
|
}
|
|
this.gameRevision++
|
|
try {
|
|
await actions[command]()
|
|
} finally {
|
|
if (this.syncGameContext() === context) await this.refreshGameState(true)
|
|
}
|
|
}
|
|
private async description(gameId: number): Promise<ScreenPackage> {
|
|
if (!this.credentials) throw new ApiError(401)
|
|
return prepareScreenPackage(
|
|
{
|
|
server: this.credentials.server,
|
|
gameId,
|
|
resources: this.received?.gameId === gameId ? this.received.resources : undefined,
|
|
token: this.credentials.access,
|
|
signal: this.materialAbort.signal,
|
|
onRequestFailure: this.onRequestFailure,
|
|
},
|
|
(url, init) => {
|
|
const parsed = new URL(String(url))
|
|
if (parsed.origin !== this.credentials?.server) throw new Error('Недопустимый сервер API')
|
|
return this.authorized(parsed.pathname, init)
|
|
},
|
|
)
|
|
}
|
|
private async refreshMaterials() {
|
|
if (this.materialRefresh) return this.materialRefresh
|
|
const pkg = this.selectedGame()
|
|
if (!pkg || !this.planStatus().editable) return
|
|
const generation = this.generation
|
|
const signature = this.receivedSignature
|
|
this.lastMaterials = Date.now()
|
|
const work = (async () => {
|
|
try {
|
|
const description = await this.description(pkg.package.game.id)
|
|
if (generation !== this.generation) return
|
|
const current = this.selectedGame()
|
|
if (
|
|
current?.package.metadataVersion !== description.metadataVersion ||
|
|
!this.packages.description(pkg.key)
|
|
) {
|
|
const local = await this.packages.remember(description)
|
|
if (generation !== this.generation) return
|
|
this.controller.selectPackage(local, true)
|
|
await this.checkMaterials(local.package)
|
|
this.state.packagesRevision++
|
|
}
|
|
this.materialSignature = signature
|
|
this.prefetch()
|
|
} catch (error) {
|
|
if (generation === this.generation)
|
|
this.state.preparation.error =
|
|
error instanceof Error ? error.message : 'Описание игры недоступно'
|
|
}
|
|
})()
|
|
this.materialRefresh = work
|
|
try {
|
|
await work
|
|
} finally {
|
|
if (this.materialRefresh === work) this.materialRefresh = null
|
|
}
|
|
}
|
|
private wanted(next: RemoteState, music: boolean): string[] {
|
|
const pkg = this.selectedGame()?.package
|
|
if (!pkg) return []
|
|
if (music) {
|
|
const m = next.music
|
|
const track = pkg.playlists
|
|
.find((p) => p.id === m?.playlistId)
|
|
?.tracks.find((t) => t.id === m?.trackId)
|
|
if (m?.playlistId && m.trackId && !track)
|
|
throw new Error('Описание выбранного трека ещё не получено')
|
|
const expected = m?.filenames[m.trackIds.indexOf(m.trackId ?? 0)]
|
|
if (
|
|
track &&
|
|
expected &&
|
|
pkg.assets.find((a) => a.id === track.assetId)?.url !==
|
|
resolveMediaURL(pkg.server, expected)
|
|
)
|
|
throw new Error('Ожидание актуального описания трека')
|
|
return track ? [track.assetId] : []
|
|
}
|
|
for (const [id, expected] of [
|
|
[
|
|
next.mode === 1 ? pkg.media.cover : pkg.media.background,
|
|
next.mode === 1 ? next.resources.cover : next.resources.background,
|
|
],
|
|
[
|
|
next.mode === 2 ? pkg.media.intro : next.mode === 4 ? pkg.media.conclusion : null,
|
|
next.mode === 2 ? next.resources.intro : next.mode === 4 ? next.resources.conclusion : '',
|
|
],
|
|
]) {
|
|
if (
|
|
expected &&
|
|
pkg.assets.find((a) => a.id === id)?.url !== resolveMediaURL(pkg.server, expected)
|
|
)
|
|
throw new Error('Ожидание актуального описания экрана')
|
|
}
|
|
return [
|
|
...new Set(
|
|
[
|
|
next.mode === 1 ? pkg.media.cover : pkg.media.background,
|
|
next.mode === 2 ? pkg.media.intro : next.mode === 4 ? pkg.media.conclusion : null,
|
|
].filter((id): id is string => !!id),
|
|
),
|
|
]
|
|
}
|
|
private prefetch() {
|
|
if (this.materialWork) return
|
|
const pkg = this.selectedGame()
|
|
if (!pkg) return
|
|
const generation = this.generation
|
|
const signal = this.materialAbort.signal
|
|
const description = pkg.package
|
|
let wanted: string[] = []
|
|
if (this.received) {
|
|
try {
|
|
wanted = [...this.wanted(this.received, false), ...this.wanted(this.received, true)]
|
|
} catch {
|
|
/* Новый плейлист будет включён после обновления описания. */
|
|
}
|
|
}
|
|
const ids = [...new Set([...wanted, ...description.assets.map((a) => a.id)])]
|
|
this.state.preparation = {
|
|
active: true,
|
|
completed: 0,
|
|
total: ids.length,
|
|
downloadedBytes: 0,
|
|
error: '',
|
|
}
|
|
const work = (async () => {
|
|
for (const id of ids) {
|
|
if (generation !== this.generation || signal.aborted) return
|
|
try {
|
|
await this.packages.ensureAsset(description, id, signal, (bytes) => {
|
|
if (generation === this.generation) this.state.preparation.downloadedBytes = bytes
|
|
})
|
|
if (generation === this.generation) {
|
|
this.controller.markAssetReady(id)
|
|
this.state.preparation.completed++
|
|
}
|
|
} catch (error) {
|
|
if (generation !== this.generation || signal.aborted) return
|
|
this.state.preparation.error = error instanceof Error ? error.message : 'Файл недоступен'
|
|
}
|
|
}
|
|
})()
|
|
this.materialWork = work
|
|
void work.finally(() => {
|
|
if (this.materialWork === work) {
|
|
this.materialWork = null
|
|
if (generation === this.generation) this.state.preparation.active = false
|
|
}
|
|
})
|
|
}
|
|
private mainSourceMatches(next: RemoteState) {
|
|
const kind = next.mode === 2 ? 'audio' : next.mode === 4 ? 'video' : null
|
|
if (!kind) return true
|
|
return (
|
|
this.controller.state.channels.main.source?.url ===
|
|
this.controller.state.catalog.media.find((source) => source.kind === kind)?.url
|
|
)
|
|
}
|
|
private musicSourceMatches(next: RemoteState) {
|
|
try {
|
|
const id = this.wanted(next, true)[0]
|
|
const channel = this.controller.state.channels.music
|
|
return (
|
|
next.music?.playlistId === channel.playlistId &&
|
|
(id ? `screen-app://app/packages/${this.selectedGame()?.key}/${id}` : undefined) ===
|
|
channel.source?.url
|
|
)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
private async applyReceived(music: boolean, duringCommand = false) {
|
|
let next = this.received
|
|
const pkg = this.selectedGame()
|
|
if (!next || !pkg || !this.controller.state.outputOpen || (this.planBusy && !duringCommand))
|
|
return
|
|
const session = this.controller.state.session
|
|
const generation = this.generation
|
|
const version = this.planVersion
|
|
const old = music ? this.musicApplied : this.mainApplied
|
|
const sameSession = (music ? this.musicSession : this.mainSession) === session
|
|
if (
|
|
old &&
|
|
sameSession &&
|
|
(music
|
|
? JSON.stringify(old.music, (_key, value) =>
|
|
typeof value === 'bigint' ? String(value) : value,
|
|
) ===
|
|
JSON.stringify(next.music, (_key, value) =>
|
|
typeof value === 'bigint' ? String(value) : value,
|
|
)
|
|
: old.version === next.version &&
|
|
this.mainSourceMatches(next) &&
|
|
JSON.stringify(old.resources) === JSON.stringify(next.resources))
|
|
)
|
|
return
|
|
try {
|
|
if (this.materialSignature !== this.receivedSignature) await this.refreshMaterials()
|
|
const description = this.selectedGame()?.package
|
|
if (!description || generation !== this.generation) return
|
|
if (!music && next.command === 0) await this.refreshPlan()
|
|
const ids = this.wanted(next, music)
|
|
await Promise.all(
|
|
ids.map((id) => this.packages.ensureAsset(description, id, this.materialAbort.signal)),
|
|
)
|
|
if (
|
|
generation !== this.generation ||
|
|
session !== this.controller.state.session ||
|
|
version !== this.planVersion ||
|
|
!this.controller.state.outputOpen
|
|
)
|
|
return
|
|
for (const id of ids) this.controller.markAssetReady(id)
|
|
const latest = this.received
|
|
if (
|
|
!latest ||
|
|
(music
|
|
? latest.music?.version !== next.music?.version ||
|
|
JSON.stringify(latest.music?.filenames) !== JSON.stringify(next.music?.filenames)
|
|
: latest.version !== next.version ||
|
|
JSON.stringify(latest.resources) !== JSON.stringify(next.resources))
|
|
)
|
|
return
|
|
next = latest
|
|
const base =
|
|
old && sameSession
|
|
? { ...old }
|
|
: {
|
|
...next,
|
|
version: -1n,
|
|
mode: this.controller.state.scene.mode,
|
|
music: null,
|
|
}
|
|
if (
|
|
music &&
|
|
old &&
|
|
old.music?.trackId === next.music?.trackId &&
|
|
!this.musicSourceMatches(next)
|
|
)
|
|
base.music = null
|
|
if (!music && old && JSON.stringify(old.resources) !== JSON.stringify(next.resources))
|
|
base.version = -1n
|
|
if (music) {
|
|
applyRemote(this.controller, { ...base, version: next.version, sound: next.sound }, next)
|
|
this.musicApplied = next
|
|
this.musicSession = session
|
|
} else {
|
|
const key = this.controller.state.catalog.key
|
|
const url = (id: string | null) => (id ? `screen-app://app/packages/${key}/${id}` : null)
|
|
this.controller.setImages(url(description.media.cover), url(description.media.background))
|
|
applyRemote(this.controller, { ...base, music: next.music }, { ...next, music: next.music })
|
|
this.mainApplied = next
|
|
this.mainSession = session
|
|
if (next.command === 0) this.controller.markStep(this.plan.currentStep)
|
|
}
|
|
} catch (error) {
|
|
if (generation === this.generation && !this.materialAbort.signal.aborted)
|
|
this.state.preparation.error =
|
|
error instanceof Error ? error.message : 'Материал не подготовлен'
|
|
}
|
|
}
|
|
private synchronize() {
|
|
if (!this.mainWork) {
|
|
const work = this.applyReceived(false)
|
|
this.mainWork = work
|
|
void work.finally(() => {
|
|
if (this.mainWork === work) this.mainWork = null
|
|
})
|
|
}
|
|
if (!this.musicWork) {
|
|
const work = this.applyReceived(true)
|
|
this.musicWork = work
|
|
void work.finally(() => {
|
|
if (this.musicWork === work) this.musicWork = null
|
|
})
|
|
}
|
|
return Promise.all([this.mainWork, this.musicWork])
|
|
}
|
|
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<boolean> = Promise.resolve(true)
|
|
private planPolling: Promise<void> | null = null
|
|
private lastPlan = -Infinity
|
|
private planVersion = 0
|
|
private planWrites: Promise<void> = 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.client(true, AbortSignal.timeout(5000)).GetScreenPlan({
|
|
gameID: pkg.package.game.id,
|
|
})
|
|
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<string, unknown>) {
|
|
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.client().SaveScreenPlan({
|
|
gameID: pkg.package.game.id,
|
|
...apiPlan(plan),
|
|
})
|
|
if (generation !== this.generation) throw new SessionChangedError()
|
|
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
|
|
} finally {
|
|
this.planBusy--
|
|
}
|
|
}
|
|
private localAssets(intent: Intent, pkg: ScreenPackage): string[] {
|
|
if ('channel' in intent && intent.channel === 'music') {
|
|
const channel = this.controller.state.channels.music
|
|
const playlist = pkg.playlists.find(
|
|
(p) => p.id === (intent.action === 'playlist' ? intent.playlistId : channel.playlistId),
|
|
)
|
|
let index =
|
|
intent.action === 'track'
|
|
? intent.trackIndex
|
|
: intent.action === 'playlist'
|
|
? 0
|
|
: channel.trackIndex
|
|
if (intent.action === 'next' && playlist)
|
|
index = (index + 1) % Math.max(1, playlist.tracks.length)
|
|
const track = playlist?.tracks[index]
|
|
return track ? [track.assetId] : []
|
|
}
|
|
let mode = this.controller.state.scene.mode
|
|
if (intent.action === 'mode') mode = intent.mode
|
|
if (intent.action === 'step')
|
|
mode = this.controller.state.catalog.steps[intent.index]?.mode ?? mode
|
|
if (intent.action === 'text') mode = 8
|
|
if (intent.action === 'timer-set') mode = 7
|
|
if (intent.action === 'select') mode = intent.sourceId === 'intro' ? 2 : 4
|
|
return [
|
|
mode === 1 ? pkg.media.cover : pkg.media.background,
|
|
mode === 2 ? pkg.media.intro : mode === 4 ? pkg.media.conclusion : null,
|
|
].filter((id): id is string => !!id)
|
|
}
|
|
async planCommand(intent: Intent) {
|
|
if (this.busy) throw new Error('Подождите завершения текущего действия')
|
|
const pkg = this.selectedGame()
|
|
const localVersion = ++this.localCommandVersion
|
|
const localGeneration = this.generation
|
|
const localSession = this.controller.state.session
|
|
if (pkg) {
|
|
const ids = this.localAssets(intent, pkg.package)
|
|
const hasError =
|
|
this.controller.state.scene.status === 'error' ||
|
|
('channel' in intent && this.controller.state.channels[intent.channel].status === 'error')
|
|
if (hasError || ids.some((id) => !this.packages.canRead(pkg.key, id))) {
|
|
await Promise.all(
|
|
ids.map((id) => this.packages.ensureAsset(pkg.package, id, this.materialAbort.signal)),
|
|
)
|
|
if (
|
|
localVersion !== this.localCommandVersion ||
|
|
localGeneration !== this.generation ||
|
|
localSession !== this.controller.state.session
|
|
)
|
|
return
|
|
}
|
|
}
|
|
// Готовые локальные ресурсы включаются без ожидания ответа сервера.
|
|
send(this.controller, intent)
|
|
const localState = structuredClone(this.controller.state)
|
|
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 || session !== this.controller.state.session) return
|
|
if (
|
|
version !== this.planVersion &&
|
|
['step', 'mode', 'text', 'select', 'timer-set', 'playlist', 'track', 'next'].includes(
|
|
intent.action,
|
|
)
|
|
)
|
|
return
|
|
if (hasPendingCommand && !wasAvailable) {
|
|
isAvailable = false
|
|
this.previous = null
|
|
this.planNotice = 'Команда выполнена локально; сервер недоступен'
|
|
return
|
|
}
|
|
if (intent.action === 'step') {
|
|
const result = await this.client(true, AbortSignal.timeout(5000)).SetScreenPlanStep({
|
|
gameID: pkg.package.game.id,
|
|
currentStep: intent.index,
|
|
})
|
|
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 if ('channel' in intent && intent.channel === 'music') {
|
|
const c = localState.channels.music
|
|
await this.client(true, AbortSignal.timeout(5000)).SetGameMusic({
|
|
gameID: pkg.package.game.id,
|
|
playlistID: c.playlistId ?? 0,
|
|
trackIndex: c.trackIndex,
|
|
positionMs: Math.max(0, Math.round(c.position * 1000)),
|
|
volume: Math.round(c.volume),
|
|
loop: c.loop,
|
|
playback:
|
|
c.desired === 'playing'
|
|
? 'MUSIC_PLAYBACK_PLAYING'
|
|
: c.desired === 'paused'
|
|
? 'MUSIC_PLAYBACK_PAUSED'
|
|
: 'MUSIC_PLAYBACK_STOPPED',
|
|
})
|
|
} else if (intent.action === 'mode' || intent.action === 'select') {
|
|
await this.client(true, AbortSignal.timeout(5000)).SetScreenMode({
|
|
gameID: pkg.package.game.id,
|
|
mode: apiScreenMode(localState.scene.mode),
|
|
})
|
|
} else if (intent.action === 'text') {
|
|
const current = parsePlan(
|
|
(await this.client().GetScreenPlan({ gameID: pkg.package.game.id })).plan,
|
|
)
|
|
if (
|
|
generation !== this.generation ||
|
|
session !== this.controller.state.session ||
|
|
version !== this.planVersion
|
|
)
|
|
return
|
|
let index = current.steps.findIndex((step) => step.mode === 8 && step.text === intent.text)
|
|
if (index < 0) {
|
|
index = current.steps.length
|
|
current.steps.push({
|
|
mode: 8,
|
|
text: intent.text,
|
|
timerLabel: '',
|
|
timerDurationSeconds: 0,
|
|
})
|
|
await this.client().SaveScreenPlan({ gameID: pkg.package.game.id, ...apiPlan(current) })
|
|
}
|
|
if (
|
|
generation !== this.generation ||
|
|
session !== this.controller.state.session ||
|
|
version !== this.planVersion
|
|
)
|
|
return
|
|
const result = await this.client().SetScreenPlanStep({
|
|
gameID: pkg.package.game.id,
|
|
currentStep: index,
|
|
})
|
|
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
|
|
if (
|
|
generation !== this.generation ||
|
|
session !== this.controller.state.session ||
|
|
version !== this.planVersion
|
|
)
|
|
return
|
|
this.controller.markStep(index)
|
|
} else if (intent.action === 'timer-set') {
|
|
await this.client(true, AbortSignal.timeout(5000)).SetScreenTimer({
|
|
gameID: pkg.package.game.id,
|
|
label: intent.label,
|
|
durationSeconds: intent.durationSeconds,
|
|
})
|
|
} else {
|
|
const index = this.controller.state.scene.stepIndex
|
|
if (index !== null && index !== this.plan.currentStep) {
|
|
const result = await this.client(true, AbortSignal.timeout(5000)).SetScreenPlanStep({
|
|
gameID: pkg.package.game.id,
|
|
currentStep: index,
|
|
})
|
|
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.client(true, AbortSignal.timeout(5000)).SendScreenCommand({
|
|
gameID: pkg.package.game.id,
|
|
command: apiScreenCommand(expectedCommand),
|
|
positionMs: 'position' in intent ? Math.round(intent.position * 1000) : 0,
|
|
volume: 'volume' in intent ? intent.volume : this.controller.state.channels.main.volume,
|
|
})
|
|
}
|
|
if (
|
|
generation !== this.generation ||
|
|
session !== this.controller.state.session ||
|
|
version !== this.planVersion
|
|
)
|
|
return
|
|
this.planNotice = ''
|
|
// Ответ следующего опроса становится базой: собственная команда не применяется дважды.
|
|
if (this.screen) {
|
|
const result = await this.client(false, AbortSignal.timeout(5000)).GetScreenState({
|
|
screenID: this.screen.id,
|
|
accessToken: this.screen.token,
|
|
})
|
|
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
|
|
const isMusicCommand = 'channel' in intent && intent.channel === 'music'
|
|
let needsMainSync =
|
|
!isMusicCommand &&
|
|
(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) ||
|
|
!this.mainSourceMatches(remote))
|
|
try {
|
|
this.wanted(remote, false)
|
|
} catch {
|
|
needsMainSync = !isMusicCommand
|
|
}
|
|
if (needsMainSync && remote.command === 0) {
|
|
const result = await this.client(true, AbortSignal.timeout(5000)).GetScreenPlan({
|
|
gameID: pkg.package.game.id,
|
|
})
|
|
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
|
|
if (
|
|
generation !== this.generation ||
|
|
version !== this.planVersion ||
|
|
session !== this.controller.state.session
|
|
)
|
|
return
|
|
}
|
|
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
|
|
this.received = remote
|
|
this.receivedSignature = JSON.stringify([
|
|
remote.resources,
|
|
remote.music?.playlistId,
|
|
remote.music?.trackIds,
|
|
remote.music?.filenames,
|
|
])
|
|
if (!('channel' in intent && intent.channel === 'music')) {
|
|
if (needsMainSync) {
|
|
this.mainApplied = null
|
|
await this.applyReceived(false, true)
|
|
} else {
|
|
this.mainApplied = remote
|
|
this.mainSession = session
|
|
}
|
|
}
|
|
if ('channel' in intent && intent.channel === 'music') {
|
|
const c = localState.channels.music
|
|
const expectedTrack =
|
|
localState.catalog.playlists.find((p) => p.id === c.playlistId)?.tracks[c.trackIndex]
|
|
?.id ?? null
|
|
const m = remote.music
|
|
if (
|
|
m &&
|
|
m.playlistId === c.playlistId &&
|
|
this.musicSourceMatches(remote) &&
|
|
m.trackId === expectedTrack &&
|
|
m.volume === Math.round(c.volume) &&
|
|
m.loop === c.loop &&
|
|
m.playback === (c.desired === 'playing' ? 2 : c.desired === 'paused' ? 3 : 1) &&
|
|
Math.abs(m.position - c.position) < 0.01
|
|
) {
|
|
this.musicApplied = remote
|
|
this.musicSession = session
|
|
} else {
|
|
this.musicApplied = null
|
|
await this.applyReceived(true, true)
|
|
}
|
|
}
|
|
}
|
|
} 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,
|
|
private onRequestFailure: NonNullable<
|
|
import('./screen-package.js').PackageRequest['onRequestFailure']
|
|
> = () => {},
|
|
private chooseTrack: () => Promise<{ filename: string; data: Buffer } | null> = async () =>
|
|
null,
|
|
) {
|
|
controller.onMusicEnded = (report) => {
|
|
if (!this.screen) return false
|
|
void this.musicEnded(report)
|
|
return true
|
|
}
|
|
}
|
|
private clearingGames = false
|
|
get blocksOutput() {
|
|
return this.clearingGames || (this.downloadKey !== null && this.downloadKey === this.controller.state.catalog.key)
|
|
}
|
|
get busy() {
|
|
return this.state.busy
|
|
}
|
|
snapshot(): OnlineState {
|
|
this.syncGameContext()
|
|
const pkg = this.selectedGame()?.package
|
|
const names = new Map<string, string[]>()
|
|
const usages = new Map<string, GameMaterial['usages']>()
|
|
const add = (
|
|
id: string | null,
|
|
name: string,
|
|
order: number,
|
|
groupId = 'game',
|
|
groupName = 'Игра',
|
|
) => {
|
|
if (!id) return
|
|
names.set(id, [
|
|
...(names.get(id) ?? []),
|
|
groupId === 'game' ? name : `${groupName} · ${name}`,
|
|
])
|
|
usages.set(id, [...(usages.get(id) ?? []), { groupId, groupName, name, order }])
|
|
}
|
|
if (pkg) {
|
|
add(pkg.media.cover, 'Обложка', 0)
|
|
add(pkg.media.background, 'Фон', 1)
|
|
add(pkg.media.intro, 'Вступление', 2)
|
|
add(pkg.media.conclusion, 'Заключение', 3)
|
|
for (const playlist of pkg.playlists)
|
|
for (const track of playlist.tracks)
|
|
add(track.assetId, track.name, track.position, `playlist-${playlist.id}`, playlist.name)
|
|
}
|
|
const key = this.controller.state.catalog.key
|
|
return structuredClone({
|
|
...this.state,
|
|
currentGameId: pkg?.server === this.state.server ? pkg.game.id : null,
|
|
materials:
|
|
pkg && key
|
|
? pkg.assets.map((asset) => ({
|
|
id: asset.id,
|
|
name: names.get(asset.id)?.join(', ') ?? 'Материал игры',
|
|
usages: usages.get(asset.id) ?? [
|
|
{ groupId: 'game', groupName: 'Игра', name: 'Материал игры', order: 4 },
|
|
],
|
|
...this.packages.assetStatus(key, asset.id),
|
|
...(this.downloadKey === key && this.state.progress?.assetId === asset.id
|
|
? { status: 'loading' as const }
|
|
: {}),
|
|
}))
|
|
: [],
|
|
})
|
|
}
|
|
private async checkMaterials(pkg: ScreenPackage) {
|
|
const key = packageKey(pkg.server, pkg.game.id)
|
|
for (const asset of pkg.assets) await this.packages.checkAsset(key, asset.id)
|
|
}
|
|
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('Подождите завершения подготовки материалов')
|
|
if (this.controller.state.outputOpen) throw new Error('Закройте показ перед выбором игры')
|
|
let pkg
|
|
try {
|
|
pkg =
|
|
key === null ? null : (this.packages.description(key) ?? (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)
|
|
if (pkg) await this.checkMaterials(pkg.package)
|
|
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.materialAbort.abort()
|
|
this.materialAbort = new AbortController()
|
|
this.received = null
|
|
this.mainApplied = null
|
|
this.musicApplied = null
|
|
this.receivedSignature = ''
|
|
this.materialSignature = ''
|
|
this.lastMaterials = -Infinity
|
|
this.state.preparation = {
|
|
active: false,
|
|
completed: 0,
|
|
total: 0,
|
|
downloadedBytes: 0,
|
|
error: '',
|
|
}
|
|
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<Response> {
|
|
const signal = AbortSignal.any([
|
|
AbortSignal.timeout(
|
|
init.method === 'POST' && /^\/api\/playlists\/\d+\/tracks$/.test(path) ? 120_000 : 30_000,
|
|
),
|
|
...(init.signal ? [init.signal] : []),
|
|
])
|
|
try {
|
|
return await this.fetchAPI(`${server}${path}`, {
|
|
...init,
|
|
redirect: 'error',
|
|
credentials: 'omit',
|
|
signal,
|
|
})
|
|
} catch (error) {
|
|
throw networkFailure(error, signal)
|
|
}
|
|
}
|
|
private async json(response: Response): Promise<Record<string, unknown>> {
|
|
if (!response.ok) throw new ApiError(response.status)
|
|
let value: unknown
|
|
try {
|
|
value = await response.json()
|
|
} catch {
|
|
throw new Error('Сервер вернул некорректный JSON')
|
|
}
|
|
return object(value, 'response')
|
|
}
|
|
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 () => {
|
|
let result
|
|
try {
|
|
result = await this.client(false, undefined, c.server).Refresh({ refreshToken: c.refresh })
|
|
} catch (error) {
|
|
if (generation !== this.generation) throw new SessionChangedError()
|
|
if (error instanceof ApiError && error.status === 401) {
|
|
this.credentials = null
|
|
this.state.authenticated = false
|
|
await this.persistSecrets()
|
|
}
|
|
throw error
|
|
}
|
|
if (generation !== this.generation) throw new SessionChangedError()
|
|
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 SessionChangedError()
|
|
if (response.status === 401) {
|
|
await response.body?.cancel()
|
|
// Отмена прерывает ожидание, но общий refresh остаётся доступен другим запросам.
|
|
const waiting = new AbortController()
|
|
try {
|
|
init.signal?.throwIfAborted()
|
|
await Promise.race([
|
|
this.refresh(c.access, generation),
|
|
once(waiting.signal, 'abort', { signal: init.signal ?? undefined }),
|
|
])
|
|
init.signal?.throwIfAborted()
|
|
} catch (error) {
|
|
if (init.signal?.aborted) throw networkFailure(error, init.signal)
|
|
throw error
|
|
} finally {
|
|
waiting.abort()
|
|
}
|
|
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 client(authorized = true, signal?: AbortSignal, server = this.state.server) {
|
|
return createEveningDetectiveServerClient(async (request, meta) => {
|
|
const init: RequestInit = {
|
|
method: request.method,
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: request.body,
|
|
signal,
|
|
}
|
|
const path = `/${request.path}`
|
|
try {
|
|
const result = await this.json(
|
|
authorized ? await this.authorized(path, init) : await this.raw(server, path, init),
|
|
)
|
|
// Проверяем JSON до выхода из транспорта, пока доступен контекст запроса.
|
|
if (meta.method === 'GetScreenState') parseRemote(result.state)
|
|
if (meta.method === 'AdvanceScreenMusic') parseRemote({ music: result.state })
|
|
if (['GetScreenPlan', 'SaveScreenPlan', 'SetScreenPlanStep'].includes(meta.method))
|
|
parsePlan(result.plan)
|
|
return result
|
|
} catch (error) {
|
|
if (
|
|
error instanceof ApiError &&
|
|
error.status === 400 &&
|
|
['DeletePlaylist', 'DeletePlaylistTrack'].includes(meta.method)
|
|
)
|
|
error.message = 'Плейлист используется игрой или сценарием; удаление недоступно'
|
|
throw requestErrorContext(error, meta.method, request.method, path)
|
|
}
|
|
})
|
|
}
|
|
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('Подождите завершения текущего действия')
|
|
if (data.action === 'clear-games' && (this.state.preparation.active || this.resultsBusy || this.planBusy > 0))
|
|
throw new Error('Подождите завершения подготовки материалов и результатов')
|
|
const isBackgroundRefresh = data.action === 'playlists'
|
|
this.state.busy = true
|
|
const messages: Record<OnlineIntent['action'], string> = {
|
|
'game-command': 'Изменение состояния игры…',
|
|
playlists: 'Загрузка списка плейлистов…',
|
|
'download-material': 'Загрузка файла игры…',
|
|
'playlist-create': 'Создание плейлиста…',
|
|
'playlist-rename': 'Сохранение названия плейлиста…',
|
|
'playlist-delete': 'Удаление плейлиста…',
|
|
'playlist-upload': 'Загрузка трека на сервер…',
|
|
'playlist-use': 'Подключение плейлиста к игре…',
|
|
'playlist-track-delete': 'Удаление трека…',
|
|
'playlist-reorder': 'Сохранение порядка треков…',
|
|
'plan-save': 'Сохранение плана экранов…',
|
|
'plan-refresh': 'Загрузка плана экранов…',
|
|
login: 'Вход на сервер…',
|
|
logout: 'Выход из аккаунта…',
|
|
games: 'Загрузка списка игр…',
|
|
'select-game': 'Подготовка выбранной игры…',
|
|
connect: 'Подключение игры…',
|
|
disconnect: 'Отключение игры…',
|
|
download: 'Загрузка материалов игры…',
|
|
check: 'Проверка материалов игры…',
|
|
'retry-materials': 'Подготовка материалов игры…',
|
|
remove: 'Удаление локальных материалов…',
|
|
'clear-games': 'Удаление всех сохранённых игр…',
|
|
sound: 'Изменение назначения звука…',
|
|
results: 'Загрузка результатов игры…',
|
|
cancel: 'Отмена загрузки…',
|
|
}
|
|
this.state.busyMessage =
|
|
messages[data.action as OnlineIntent['action']] ?? 'Выполнение запроса…'
|
|
if (!isBackgroundRefresh) {
|
|
this.state.error = ''
|
|
this.state.notice = ''
|
|
}
|
|
try {
|
|
await this.perform(data)
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Действие не выполнено'
|
|
if (!isBackgroundRefresh || !this.state.error) this.state.error = message
|
|
throw new Error(message)
|
|
} finally {
|
|
this.state.busy = false
|
|
this.state.busyMessage = ''
|
|
this.state.progress = null
|
|
if (['login', 'select-game', 'connect'].includes(String(data.action)))
|
|
await this.refreshGameState(true)
|
|
}
|
|
return this.snapshot()
|
|
}
|
|
private async perform(data: Record<string, unknown>) {
|
|
const action = data.action as OnlineIntent['action']
|
|
switch (action) {
|
|
case 'game-command':
|
|
return this.gameCommand(data.command, data.key)
|
|
case 'download-material': {
|
|
const pkg = this.selectedGame()
|
|
const assetId = text(data.assetId)
|
|
if (!pkg || data.key !== pkg.key || !pkg.package.assets.some((a) => a.id === assetId))
|
|
throw new Error('Материал не принадлежит текущей игре')
|
|
const generation = this.generation
|
|
await this.packages.ensureAsset(pkg.package, assetId, this.materialAbort.signal)
|
|
if (generation === this.generation && this.controller.state.catalog.key === pkg.key)
|
|
this.controller.markAssetReady(assetId)
|
|
return
|
|
}
|
|
case 'playlists':
|
|
return this.loadPlaylists()
|
|
case 'playlist-create':
|
|
case 'playlist-rename':
|
|
case 'playlist-delete':
|
|
case 'playlist-upload':
|
|
case 'playlist-track-delete':
|
|
case 'playlist-reorder':
|
|
case 'playlist-use':
|
|
return this.editPlaylist(data)
|
|
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 = []
|
|
this.state.playlists = []
|
|
await this.persistSecrets()
|
|
this.state.server = server
|
|
const result = await this.client(false, undefined, server).Login({ email, password })
|
|
this.credentials = {
|
|
server,
|
|
access: token(result.accessToken),
|
|
refresh: token(result.refreshToken),
|
|
screen: null,
|
|
}
|
|
try {
|
|
const me = object((await this.client().GetMe({})).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 = []
|
|
this.state.playlists = []
|
|
await this.persistSecrets()
|
|
return
|
|
case 'games':
|
|
return this.loadGames()
|
|
case 'select-game':
|
|
case 'download':
|
|
case 'check': {
|
|
const gameId = integer(data.gameId, 1)
|
|
const key = packageKey(this.state.server, gameId)
|
|
if (action === 'select-game' && this.controller.state.outputOpen)
|
|
throw new Error('Закройте показ перед выбором игры')
|
|
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 === 'check' ? null : key
|
|
this.cancellation = new AbortController()
|
|
this.state.canCancel = true
|
|
try {
|
|
const description = await prepareScreenPackage(
|
|
{
|
|
server: this.state.server,
|
|
gameId,
|
|
resources: this.received?.gameId === gameId ? this.received.resources : undefined,
|
|
token: this.credentials.access,
|
|
signal: this.cancellation.signal,
|
|
onRequestFailure: this.onRequestFailure,
|
|
},
|
|
(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 (action === 'select-game' || key === this.controller.state.catalog.key) {
|
|
if (action === 'select-game') this.detach()
|
|
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)
|
|
if (action === 'select-game') {
|
|
await this.saveSelection()
|
|
await this.persistSecrets()
|
|
}
|
|
}
|
|
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 'clear-games': {
|
|
if (this.controller.state.outputOpen) throw new Error('Закройте показ перед очисткой игр')
|
|
this.clearingGames = true
|
|
this.detach()
|
|
this.controller.selectPackage(null)
|
|
this.controller.setResults(null)
|
|
this.plan = { steps: structuredClone(this.controller.state.catalog.steps), currentStep: 0 }
|
|
this.cachedPlan = ''
|
|
this.planNotice = ''
|
|
try {
|
|
await Promise.allSettled([
|
|
this.materialRefresh, this.materialWork, this.mainWork, this.musicWork, this.planWrites,
|
|
])
|
|
await this.saveSelection()
|
|
await this.persistSecrets()
|
|
await this.packages.clear()
|
|
for (const name of await readdir(this.directory)) {
|
|
if (/^(plan|results)-[a-f0-9]{64}\.json(?:\.tmp)?$/.test(name))
|
|
await rm(join(this.directory, name), { force: true })
|
|
}
|
|
this.state.notice = 'Все сохранённые игры удалены'
|
|
} catch {
|
|
await this.packages.initialize().catch(() => {})
|
|
throw new Error('Очистка не завершена. Повторите удаление оставшихся данных')
|
|
} finally {
|
|
this.clearingGames = false
|
|
this.state.packagesRevision++
|
|
}
|
|
return
|
|
}
|
|
case 'retry-materials':
|
|
this.lastMaterials = -Infinity
|
|
await this.refreshMaterials()
|
|
return
|
|
case 'connect': {
|
|
if (!this.credentials) throw new ApiError(401)
|
|
let pkg = this.selectedGame()
|
|
const gameId = data.gameId == null ? pkg?.package.game.id : integer(data.gameId, 1)
|
|
if (!gameId) throw new Error('Выберите игру на сервере')
|
|
if (this.screen) {
|
|
this.state.busyMessage = 'Отключение предыдущего подключения…'
|
|
try {
|
|
await this.client().UnbindScreen({ screenID: this.screen.id })
|
|
} catch (error) {
|
|
if (!(error instanceof ApiError && error.status === 404)) throw error
|
|
}
|
|
this.detach()
|
|
await this.persistSecrets()
|
|
} else this.detach()
|
|
this.state.busyMessage = 'Подключение игры…'
|
|
if (!pkg || pkg.package.game.id !== gameId || pkg.package.server !== this.state.server) {
|
|
pkg = await this.packages.remember(await this.description(gameId))
|
|
this.controller.selectPackage(pkg)
|
|
this.plan = { steps: this.controller.state.catalog.steps, currentStep: 0 }
|
|
await this.checkMaterials(pkg.package)
|
|
this.state.packagesRevision++
|
|
await this.saveSelection()
|
|
}
|
|
const result = await this.client(false).CreateScreenSession({})
|
|
const s: ScreenSession = {
|
|
id: String(integer(result.screenID, 1)),
|
|
token: token(result.accessToken),
|
|
gameId,
|
|
}
|
|
await this.client().BindScreen({
|
|
screenID: s.id,
|
|
accessToken: s.token,
|
|
gameID: s.gameId,
|
|
initializeAudio: true,
|
|
})
|
|
send(this.controller, { action: 'mute', enabled: false })
|
|
send(this.controller, { action: 'volume', channel: 'main', volume: 100 })
|
|
send(this.controller, { action: 'volume', channel: 'music', volume: 100 })
|
|
this.state.soundEnabled = true
|
|
this.screen = s
|
|
this.state.screenId = s.id
|
|
this.state.connection = 'waiting'
|
|
await this.persistSecrets()
|
|
return
|
|
}
|
|
case 'disconnect': {
|
|
const screen = this.screen
|
|
this.detach()
|
|
await this.persistSecrets()
|
|
if (screen) {
|
|
try {
|
|
await this.client(true, AbortSignal.timeout(5000)).UnbindScreen({ screenID: screen.id })
|
|
} catch (error) {
|
|
if (!(error instanceof ApiError && error.status === 404))
|
|
this.state.notice = 'Экран отключён локально. Сервер не подтвердил отвязку'
|
|
}
|
|
}
|
|
return
|
|
}
|
|
case 'sound': {
|
|
const generation = this.generation
|
|
if (!this.screen) throw new Error('Сначала подключите управление')
|
|
await this.client().SetScreenSound({ 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 loadPlaylists() {
|
|
const result = await this.client().GetPlaylists({})
|
|
if (!Array.isArray(result.playlists ?? [])) throw new Error('Некорректный список плейлистов')
|
|
this.state.playlists = (result.playlists ?? []).map((value) => {
|
|
const p = object(value)
|
|
if (!Array.isArray(p.tracks ?? [])) throw new Error('Некорректный список треков')
|
|
return {
|
|
id: integer(p.ID, 1),
|
|
name: text(p.name),
|
|
tracks: ((p.tracks ?? []) as unknown[]).map((value) => {
|
|
const t = object(value)
|
|
return { id: integer(t.ID, 1), name: text(t.name) }
|
|
}),
|
|
}
|
|
})
|
|
}
|
|
private async editPlaylist(data: Record<string, unknown>) {
|
|
if (!this.credentials) throw new ApiError(401)
|
|
const generation = this.generation
|
|
const id = data.action === 'playlist-create' ? 0 : integer(data.playlistId, 1, 2147483647)
|
|
const client = this.client()
|
|
if (data.action === 'playlist-create' || data.action === 'playlist-rename') {
|
|
const name = text(data.name).trim()
|
|
if (!name || Buffer.byteLength(name) > 255) throw new Error('Имя плейлиста: от 1 до 255 байт')
|
|
if (data.action === 'playlist-create') await client.CreatePlaylist({ name })
|
|
else await client.UpdatePlaylist({ ID: id, name })
|
|
} else if (data.action === 'playlist-delete') await client.DeletePlaylist({ ID: id })
|
|
else if (data.action === 'playlist-track-delete')
|
|
await client.DeletePlaylistTrack({
|
|
playlistID: id,
|
|
trackID: integer(data.trackId, 1, 2147483647),
|
|
})
|
|
else if (data.action === 'playlist-reorder') {
|
|
if (!Array.isArray(data.trackIds)) throw new Error('Некорректный порядок треков')
|
|
const ids = data.trackIds.map((value) => integer(value, 1, 2147483647))
|
|
if (new Set(ids).size !== ids.length) throw new Error('Треки не должны повторяться')
|
|
try {
|
|
await client.ReorderPlaylistTracks({ playlistID: id, trackIDs: ids })
|
|
} catch (error) {
|
|
if (error instanceof ApiError && error.status === 409)
|
|
throw new Error(
|
|
'Состав треков изменился на сервере. Обновите список и повторите перестановку',
|
|
)
|
|
throw error
|
|
}
|
|
} else if (data.action === 'playlist-upload') {
|
|
const file = await this.chooseTrack()
|
|
if (generation !== this.generation) throw new SessionChangedError()
|
|
if (!file) return
|
|
if (!/\.mp3$/i.test(file.filename) || !file.data.length || file.data.length > 64 * 1048576)
|
|
throw new Error('Выберите MP3 размером до 64 МиБ')
|
|
await client.UploadPlaylistTrack({
|
|
playlistID: id,
|
|
name: file.filename,
|
|
filename: file.filename,
|
|
data: file.data.toString('base64'),
|
|
})
|
|
} else if (data.action === 'playlist-use') {
|
|
const pkg = this.planGame()
|
|
await client.SetGameMusic({
|
|
gameID: pkg.package.game.id,
|
|
playlistID: id,
|
|
trackIndex: 0,
|
|
positionMs: 0,
|
|
volume: this.controller.state.channels.music.volume,
|
|
loop: this.controller.state.channels.music.loop,
|
|
playback: 'MUSIC_PLAYBACK_STOPPED',
|
|
})
|
|
}
|
|
await this.loadPlaylists()
|
|
this.lastMaterials = -Infinity
|
|
await this.refreshMaterials()
|
|
await this.poll()
|
|
}
|
|
private async loadGames() {
|
|
const result = await this.client().GetGames({})
|
|
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.client(false).GetScreenResults({
|
|
screenID: s.id,
|
|
accessToken: s.token,
|
|
})
|
|
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.pollBusy) return
|
|
const s = this.screen
|
|
if (!s) return
|
|
const generation = this.generation
|
|
const version = this.planVersion
|
|
this.pollBusy = true
|
|
try {
|
|
const result = await this.client(false, AbortSignal.timeout(5000)).GetScreenState({
|
|
screenID: s.id,
|
|
accessToken: s.token,
|
|
})
|
|
if (generation !== this.generation || version !== this.planVersion) return
|
|
let next = parseRemote(result.state)
|
|
if (next.gameId !== s.gameId) {
|
|
this.detach()
|
|
await this.persistSecrets()
|
|
this.state.error = 'Привязка экрана изменилась на сервере. Подключите нужную игру заново'
|
|
return
|
|
}
|
|
if (this.received && next.version < this.received.version)
|
|
next = { ...this.received, music: next.music, sound: next.sound }
|
|
if (this.received?.music && next.music && next.music.version < this.received.music.version)
|
|
next = { ...next, music: this.received.music }
|
|
// Стабильная ссылка позволяет завершить подготовку, пока приходят одинаковые ответы.
|
|
const equal = (a: unknown, b: unknown) =>
|
|
JSON.stringify(a, (_key, value) => (typeof value === 'bigint' ? String(value) : value)) ===
|
|
JSON.stringify(b, (_key, value) => (typeof value === 'bigint' ? String(value) : value))
|
|
if (!equal(this.received, next)) this.received = next
|
|
this.previous = next
|
|
this.receivedSignature = JSON.stringify([
|
|
next.resources,
|
|
next.music?.playlistId,
|
|
next.music?.trackIds,
|
|
next.music?.filenames,
|
|
])
|
|
this.state.soundEnabled = next.sound
|
|
if (this.lastSound !== next.sound)
|
|
send(this.controller, { action: 'mute', enabled: !next.sound })
|
|
this.lastSound = next.sound
|
|
this.state.connection = 'connected'
|
|
this.state.error = ''
|
|
if (!this.busy && Date.now() - this.lastPlan >= 5000) void this.refreshPlan()
|
|
if (
|
|
this.materialSignature !== this.receivedSignature ||
|
|
Date.now() - this.lastMaterials >= 5000
|
|
)
|
|
void this.refreshMaterials()
|
|
if (next.mode === 6 && Date.now() - this.lastResults >= 5000)
|
|
void this.refreshResults().catch((error) => {
|
|
if (generation === this.generation)
|
|
this.state.error = error instanceof Error ? error.message : 'Результаты недоступны'
|
|
})
|
|
} catch (error) {
|
|
if (generation !== this.generation) return
|
|
this.mainApplied = null
|
|
this.musicApplied = 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
|
|
}
|
|
if (generation === this.generation && this.state.connection === 'connected')
|
|
await this.synchronize()
|
|
}
|
|
async musicEnded(report: Report) {
|
|
const c = this.controller.state.channels.music
|
|
if (
|
|
this.advanceBusy ||
|
|
report.session !== this.controller.state.session ||
|
|
report.operation !== c.operation ||
|
|
c.desired !== 'playing'
|
|
)
|
|
return
|
|
const generation = this.generation
|
|
this.advanceBusy = true
|
|
try {
|
|
if (!this.screen || this.state.connection !== 'connected') throw new NetworkError('Нет связи')
|
|
if (this.received && !this.musicSourceMatches(this.received)) {
|
|
await this.synchronize()
|
|
return
|
|
}
|
|
const result = await this.client(false, AbortSignal.timeout(5000)).AdvanceScreenMusic({
|
|
screenID: this.screen.id,
|
|
accessToken: this.screen.token,
|
|
})
|
|
if (generation !== this.generation || report.session !== this.controller.state.session) return
|
|
const music = parseRemote({ music: result.state }).music
|
|
if (this.received) {
|
|
this.received = { ...this.received, music }
|
|
await this.applyReceived(true)
|
|
}
|
|
} catch (error) {
|
|
if (generation !== this.generation || report.session !== this.controller.state.session) return
|
|
const pkg = this.selectedGame()?.package
|
|
const playlist = pkg?.playlists.find((p) => p.id === c.playlistId)
|
|
if (pkg && playlist) {
|
|
const nextIndex = c.trackIndex + 1
|
|
const track =
|
|
playlist.tracks[nextIndex < playlist.tracks.length ? nextIndex : c.loop ? 0 : -1]
|
|
try {
|
|
if (track) await this.packages.ensureAsset(pkg, track.assetId, this.materialAbort.signal)
|
|
if (generation === this.generation && report.session === this.controller.state.session)
|
|
send(this.controller, { action: 'next', channel: 'music' })
|
|
} catch (failure) {
|
|
this.state.preparation.error =
|
|
failure instanceof Error ? failure.message : 'Следующий трек недоступен'
|
|
}
|
|
}
|
|
this.state.connection = 'offline'
|
|
this.state.error =
|
|
error instanceof Error ? error.message : 'Сервер не подтвердил переход музыки'
|
|
} finally {
|
|
this.advanceBusy = false
|
|
}
|
|
}
|
|
async close() {
|
|
this.cancellation?.abort()
|
|
this.packages.cancel()
|
|
this.detach()
|
|
await Promise.allSettled([
|
|
this.materialRefresh,
|
|
this.materialWork,
|
|
this.mainWork,
|
|
this.musicWork,
|
|
])
|
|
}
|
|
}
|