704 lines
25 KiB
TypeScript
704 lines
25 KiB
TypeScript
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import type { OnlineIntent, OnlineState } from '../src/types/online.js'
|
|
import type { Report, ResultsSnapshot } from '../src/types/screen.js'
|
|
import type { Controller } from './controller.js'
|
|
import { PackageStore, packageKey } from './package-store.js'
|
|
import { prepareScreenPackage } from './screen-package.js'
|
|
import { parseResults } from './results.js'
|
|
import {
|
|
applyRemote,
|
|
integer,
|
|
object,
|
|
parseRemote,
|
|
send,
|
|
text,
|
|
type RemoteState,
|
|
} from './remote-state.js'
|
|
|
|
export interface SecretStorage {
|
|
available(): boolean
|
|
encrypt(value: string): Buffer
|
|
decrypt(value: Buffer): string
|
|
}
|
|
interface ScreenSession {
|
|
id: string
|
|
token: string
|
|
gameId: number
|
|
}
|
|
interface Credentials {
|
|
server: string
|
|
access: string
|
|
refresh: string
|
|
screen: ScreenSession | null
|
|
}
|
|
class ApiError extends Error {
|
|
constructor(public status: number) {
|
|
super(
|
|
status === 401
|
|
? 'Сессия истекла. Войдите снова'
|
|
: status === 403
|
|
? 'Нет доступа к действию'
|
|
: status === 400
|
|
? 'Действие пока недоступно для этой игры'
|
|
: `Ошибка сервера: HTTP ${status}`,
|
|
)
|
|
}
|
|
}
|
|
function serverURL(value: unknown): string {
|
|
const url = new URL(text(value))
|
|
if (
|
|
!['http:', 'https:'].includes(url.protocol) ||
|
|
url.username ||
|
|
url.password ||
|
|
url.search ||
|
|
url.hash ||
|
|
(url.pathname !== '/' && url.pathname !== '')
|
|
)
|
|
throw new Error('Укажите адрес HTTP(S) сервера без пути и параметров')
|
|
return url.origin
|
|
}
|
|
function token(value: unknown): string {
|
|
const s = text(value)
|
|
if (!s || s.length > 16384 || /[\r\n]/.test(s))
|
|
throw new Error('Сервер не вернул действующие токены')
|
|
return s
|
|
}
|
|
export class Online {
|
|
readonly state: OnlineState = {
|
|
server: '',
|
|
authenticated: false,
|
|
remembered: false,
|
|
busy: false,
|
|
canCancel: false,
|
|
games: [],
|
|
screenId: null,
|
|
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<void> | null = null
|
|
private writes: Promise<void> = Promise.resolve()
|
|
private cancellation: AbortController | null = null
|
|
private downloadKey: string | null = null
|
|
private previous: RemoteState | null = null
|
|
private pollBusy = false
|
|
private pollSession = ''
|
|
private initialSound = true
|
|
private lastResults = -Infinity
|
|
private resultsBusy = false
|
|
private advanceBusy = false
|
|
constructor(
|
|
private directory: string,
|
|
private packages: PackageStore,
|
|
private controller: Controller,
|
|
private secrets: SecretStorage,
|
|
private fetchAPI: typeof fetch = fetch,
|
|
) {
|
|
controller.onMusicEnded = (report) => {
|
|
void this.musicEnded(report)
|
|
}
|
|
}
|
|
get blocksOutput() {
|
|
return this.downloadKey !== null && this.downloadKey === this.controller.state.catalog.key
|
|
}
|
|
get busy() {
|
|
return this.state.busy
|
|
}
|
|
snapshot(): OnlineState {
|
|
return structuredClone(this.state)
|
|
}
|
|
private async atomic(name: string, data: string | Buffer) {
|
|
await mkdir(this.directory, { recursive: true, mode: 0o700 })
|
|
const path = join(this.directory, name)
|
|
await writeFile(`${path}.tmp`, data, { mode: 0o600 })
|
|
await rename(`${path}.tmp`, path)
|
|
}
|
|
private persistSecrets() {
|
|
const data = this.credentials ? { ...this.credentials, screen: this.screen } : null
|
|
this.state.remembered = !!data && this.secrets.available()
|
|
let encrypted: Buffer | null = null
|
|
try {
|
|
if (this.state.remembered) encrypted = this.secrets.encrypt(JSON.stringify(data))
|
|
} catch {
|
|
this.state.remembered = false
|
|
this.state.notice =
|
|
'Не удалось сохранить вход средствами ОС. Сессия действует до закрытия приложения'
|
|
}
|
|
const write = this.writes.then(async () => {
|
|
if (encrypted) await this.atomic('credentials.bin', encrypted)
|
|
else await rm(join(this.directory, 'credentials.bin'), { force: true })
|
|
})
|
|
this.writes = write.catch(() => {})
|
|
return write
|
|
}
|
|
async initialize() {
|
|
try {
|
|
const config = object(
|
|
JSON.parse(await readFile(join(this.directory, 'selection.json'), 'utf8')),
|
|
)
|
|
if (config.server) this.state.server = serverURL(config.server)
|
|
if (typeof config.key === 'string' && /^[a-f0-9]{64}$/.test(config.key))
|
|
await this.select(config.key, false)
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT')
|
|
this.state.error = 'Не удалось восстановить выбранные материалы; выберите готовый пакет'
|
|
}
|
|
if (!this.secrets.available()) return
|
|
try {
|
|
const raw = object(
|
|
JSON.parse(this.secrets.decrypt(await readFile(join(this.directory, 'credentials.bin')))),
|
|
)
|
|
const server = serverURL(raw.server)
|
|
const c: Credentials = {
|
|
server,
|
|
access: token(raw.access),
|
|
refresh: token(raw.refresh),
|
|
screen: null,
|
|
}
|
|
if (raw.screen) {
|
|
const s = object(raw.screen)
|
|
c.screen = {
|
|
id: String(integer(s.id, 1)),
|
|
token: token(s.token),
|
|
gameId: integer(s.gameId, 1),
|
|
}
|
|
}
|
|
this.credentials = c
|
|
this.state.server = server
|
|
this.state.authenticated = true
|
|
this.state.remembered = true
|
|
const pkg = this.packages.list().find((p) => p.key === this.controller.state.catalog.key)
|
|
if (c.screen && pkg?.package.server === server && pkg.package.game.id === c.screen.gameId) {
|
|
this.screen = c.screen
|
|
this.state.screenId = c.screen.id
|
|
this.state.connection = 'waiting'
|
|
this.initialSound = true
|
|
}
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT')
|
|
this.state.error =
|
|
'Сохранённый вход недоступен. Войдите снова; локальные материалы доступны'
|
|
}
|
|
}
|
|
private async saveSelection() {
|
|
await this.atomic(
|
|
'selection.json',
|
|
JSON.stringify({ server: this.state.server, key: this.controller.state.catalog.key }),
|
|
)
|
|
}
|
|
async select(key: string | null, persist = true) {
|
|
if (this.busy) throw new Error('Подождите завершения подготовки материалов')
|
|
let pkg
|
|
try {
|
|
pkg = key === null ? null : await this.packages.verify(key)
|
|
} catch (error) {
|
|
if (key === this.controller.state.catalog.key) {
|
|
this.detach()
|
|
this.controller.selectPackage(null)
|
|
await this.saveSelection()
|
|
await this.persistSecrets()
|
|
this.state.packagesRevision++
|
|
}
|
|
throw error
|
|
}
|
|
this.detach()
|
|
this.controller.selectPackage(pkg)
|
|
this.lastResults = -Infinity
|
|
if (key) await this.restoreResults(key)
|
|
if (persist) {
|
|
await this.saveSelection()
|
|
await this.persistSecrets()
|
|
}
|
|
}
|
|
private detach() {
|
|
this.generation++
|
|
this.screen = null
|
|
this.previous = null
|
|
this.state.soundEnabled = null
|
|
this.state.screenId = null
|
|
this.state.connection = 'disabled'
|
|
}
|
|
private async raw(server: string, path: string, init: RequestInit = {}): Promise<Response> {
|
|
try {
|
|
return await this.fetchAPI(`${server}${path}`, {
|
|
...init,
|
|
redirect: 'error',
|
|
credentials: 'omit',
|
|
signal: AbortSignal.any([
|
|
AbortSignal.timeout(30_000),
|
|
...(init.signal ? [init.signal] : []),
|
|
]),
|
|
})
|
|
} catch {
|
|
throw new Error('Сервер недоступен или превышено время ожидания')
|
|
}
|
|
}
|
|
private async json(response: Response): Promise<Record<string, unknown>> {
|
|
if (!response.ok) throw new ApiError(response.status)
|
|
try {
|
|
return object(await response.json())
|
|
} catch {
|
|
throw new Error('Сервер вернул некорректный JSON')
|
|
}
|
|
}
|
|
private async refresh(failed: string, generation: number) {
|
|
if (this.credentials?.access !== failed) return
|
|
if (this.refreshPromise) return this.refreshPromise
|
|
const c = this.credentials!
|
|
this.refreshPromise = (async () => {
|
|
const response = await this.raw(c.server, '/api/auth/refresh', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ refreshToken: c.refresh }),
|
|
})
|
|
if (generation !== this.generation) throw new Error('Сессия изменилась')
|
|
if (response.status === 401) {
|
|
this.credentials = null
|
|
this.state.authenticated = false
|
|
await this.persistSecrets()
|
|
throw new ApiError(401)
|
|
}
|
|
const result = await this.json(response)
|
|
if (generation !== this.generation) throw new Error('Сессия изменилась')
|
|
this.credentials = {
|
|
...c,
|
|
access: token(result.accessToken),
|
|
refresh: token(result.refreshToken),
|
|
}
|
|
await this.persistSecrets()
|
|
})().finally(() => {
|
|
this.refreshPromise = null
|
|
})
|
|
return this.refreshPromise
|
|
}
|
|
private async authorized(path: string, init: RequestInit = {}) {
|
|
const c = this.credentials
|
|
const generation = this.generation
|
|
if (!c) throw new ApiError(401)
|
|
const request = (access: string) => {
|
|
const headers = new Headers(init.headers)
|
|
headers.set('Authorization', `Bearer ${access}`)
|
|
return this.raw(c.server, path, { ...init, headers })
|
|
}
|
|
let response = await request(c.access)
|
|
if (generation !== this.generation) throw new Error('Сессия изменилась')
|
|
if (response.status === 401) {
|
|
await response.body?.cancel()
|
|
await this.refresh(c.access, generation)
|
|
if (generation !== this.generation || !this.credentials) throw new ApiError(401)
|
|
response = await request(this.credentials.access)
|
|
if (response.status === 401) {
|
|
this.credentials = null
|
|
this.state.authenticated = false
|
|
await this.persistSecrets()
|
|
}
|
|
}
|
|
return response
|
|
}
|
|
private async api(path: string, body?: object, authorized = true, signal?: AbortSignal) {
|
|
const init: RequestInit =
|
|
body === undefined
|
|
? { signal }
|
|
: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
signal,
|
|
}
|
|
return this.json(
|
|
authorized
|
|
? await this.authorized(path, init)
|
|
: await this.raw(this.state.server, path, init),
|
|
)
|
|
}
|
|
async act(input: unknown): Promise<OnlineState> {
|
|
const data = object(input)
|
|
if (data.action === 'cancel') {
|
|
this.cancellation?.abort()
|
|
this.packages.cancel()
|
|
return this.snapshot()
|
|
}
|
|
if (this.busy) throw new Error('Подождите завершения текущего действия')
|
|
this.state.busy = true
|
|
this.state.error = ''
|
|
this.state.notice = ''
|
|
try {
|
|
await this.perform(data)
|
|
} catch (error) {
|
|
this.state.error = error instanceof Error ? error.message : 'Действие не выполнено'
|
|
throw new Error(this.state.error)
|
|
} finally {
|
|
this.state.busy = false
|
|
this.state.progress = null
|
|
}
|
|
return this.snapshot()
|
|
}
|
|
private async perform(data: Record<string, unknown>) {
|
|
const action = data.action as OnlineIntent['action']
|
|
switch (action) {
|
|
case 'login': {
|
|
const server = serverURL(data.server)
|
|
const email = text(data.email).trim()
|
|
const password = text(data.password)
|
|
if (!email.includes('@') || email.length > 320 || !password || password.length > 4096)
|
|
throw new Error('Укажите email и пароль')
|
|
this.detach()
|
|
this.credentials = null
|
|
this.state.authenticated = false
|
|
this.state.games = []
|
|
await this.persistSecrets()
|
|
this.state.server = server
|
|
const response = await this.raw(server, '/api/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password }),
|
|
})
|
|
const result = await this.json(response)
|
|
this.credentials = {
|
|
server,
|
|
access: token(result.accessToken),
|
|
refresh: token(result.refreshToken),
|
|
screen: null,
|
|
}
|
|
try {
|
|
const me = object((await this.api('/api/users/me')).user)
|
|
if (
|
|
!Array.isArray(me.roles) ||
|
|
!me.roles.some((r) => ['admin', 'organizer', 'author'].includes(String(r)))
|
|
)
|
|
throw new Error(
|
|
'Для управления экраном нужны права организатора, автора или администратора',
|
|
)
|
|
} catch (error) {
|
|
this.credentials = null
|
|
await this.persistSecrets()
|
|
throw error
|
|
}
|
|
this.state.authenticated = true
|
|
await this.persistSecrets()
|
|
await this.saveSelection()
|
|
await this.loadGames()
|
|
return
|
|
}
|
|
case 'logout':
|
|
this.detach()
|
|
this.credentials = null
|
|
this.state.authenticated = false
|
|
this.state.games = []
|
|
await this.persistSecrets()
|
|
return
|
|
case 'games':
|
|
return this.loadGames()
|
|
case 'download':
|
|
case 'check': {
|
|
const gameId = integer(data.gameId, 1)
|
|
const key = packageKey(this.state.server, gameId)
|
|
if (
|
|
action === 'download' &&
|
|
key === this.controller.state.catalog.key &&
|
|
this.controller.state.outputOpen
|
|
)
|
|
throw new Error('Закройте показ перед обновлением выбранной игры')
|
|
if (!this.credentials) throw new ApiError(401)
|
|
this.downloadKey = action === 'download' ? key : null
|
|
this.cancellation = new AbortController()
|
|
this.state.canCancel = true
|
|
try {
|
|
const description = await prepareScreenPackage(
|
|
{
|
|
server: this.state.server,
|
|
gameId,
|
|
token: this.credentials.access,
|
|
signal: this.cancellation.signal,
|
|
},
|
|
(url, init) => {
|
|
const parsed = new URL(String(url))
|
|
if (parsed.origin !== this.state.server) throw new Error('Недопустимый сервер API')
|
|
return this.authorized(parsed.pathname, init)
|
|
},
|
|
)
|
|
if (action === 'check') {
|
|
const previous = this.packages.list().find((p) => p.key === key)
|
|
this.state.notice = !previous
|
|
? 'Игра ещё не загружена'
|
|
: previous.package.metadataVersion === description.metadataVersion
|
|
? 'Описание не изменилось. Для проверки замены файлов по прежним адресам загрузите материалы повторно'
|
|
: 'На сервере есть изменения. Загрузите материалы при закрытом показе'
|
|
return
|
|
}
|
|
const pkg = await this.packages.download(description, {
|
|
signal: this.cancellation.signal,
|
|
onProgress: (p) => {
|
|
this.state.progress = p
|
|
},
|
|
})
|
|
this.state.packagesRevision++
|
|
if (key === this.controller.state.catalog.key) {
|
|
this.controller.selectPackage(pkg)
|
|
this.previous = null
|
|
await this.restoreResults(key)
|
|
}
|
|
this.state.notice = 'Материалы проверены и готовы к показу'
|
|
} finally {
|
|
this.cancellation = null
|
|
this.state.canCancel = false
|
|
this.downloadKey = null
|
|
}
|
|
return
|
|
}
|
|
case 'remove': {
|
|
const key = text(data.key)
|
|
if (!/^[a-f0-9]{64}$/.test(key)) throw new Error('Некорректный пакет')
|
|
if (key === this.controller.state.catalog.key)
|
|
throw new Error('Сначала выберите другой пакет или тестовый режим')
|
|
await this.packages.remove(key)
|
|
await rm(join(this.directory, `results-${key}.json`), { force: true })
|
|
this.state.packagesRevision++
|
|
return
|
|
}
|
|
case 'connect': {
|
|
const pkg = this.packages.list().find((p) => p.key === this.controller.state.catalog.key)
|
|
if (!pkg || pkg.package.server !== this.state.server)
|
|
throw new Error('Выберите готовую игру с этого сервера')
|
|
if (!this.credentials) throw new ApiError(401)
|
|
this.detach()
|
|
const result = await this.api('/api/screens', {}, false)
|
|
const s: ScreenSession = {
|
|
id: String(integer(result.screenID, 1)),
|
|
token: token(result.accessToken),
|
|
gameId: pkg.package.game.id,
|
|
}
|
|
await this.api(`/api/screens/${s.id}/bind`, {
|
|
screenID: s.id,
|
|
accessToken: s.token,
|
|
gameID: s.gameId,
|
|
})
|
|
this.screen = s
|
|
this.state.screenId = s.id
|
|
this.state.connection = 'waiting'
|
|
this.initialSound = true
|
|
await this.persistSecrets()
|
|
return
|
|
}
|
|
case 'disconnect':
|
|
this.detach()
|
|
await this.persistSecrets()
|
|
return
|
|
case 'sound': {
|
|
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() {
|
|
const s = this.screen
|
|
const session = this.controller.state.session
|
|
if (!s || !this.controller.state.outputOpen) {
|
|
this.previous = null
|
|
if (s) this.state.connection = 'waiting'
|
|
return
|
|
}
|
|
if (this.pollBusy) return
|
|
if (this.pollSession !== session) {
|
|
this.previous = null
|
|
this.pollSession = session
|
|
}
|
|
this.pollBusy = true
|
|
const generation = this.generation
|
|
try {
|
|
const result = await this.api(
|
|
'/api/screens/state',
|
|
{ screenID: s.id, accessToken: s.token },
|
|
false,
|
|
AbortSignal.timeout(5000),
|
|
)
|
|
if (
|
|
generation !== this.generation ||
|
|
session !== this.controller.state.session ||
|
|
!this.controller.state.outputOpen
|
|
)
|
|
return
|
|
let next = parseRemote(result.state)
|
|
if (next.gameId !== s.gameId) {
|
|
this.detach()
|
|
await this.persistSecrets()
|
|
this.state.error =
|
|
'Привязка экрана изменилась на сервере. Локальный показ сохранён; подключите управление заново'
|
|
return
|
|
}
|
|
const previous = this.previous
|
|
if (previous && next.version < previous.version)
|
|
next = { ...previous, sound: next.sound, music: next.music }
|
|
if (previous?.music && next.music && next.music.version < previous.music.version)
|
|
next = { ...next, music: previous.music }
|
|
this.previous = next
|
|
this.state.soundEnabled = next.sound
|
|
if (this.state.connection === 'offline') this.state.error = ''
|
|
this.state.connection = 'connected'
|
|
if (!previous && this.initialSound) {
|
|
this.initialSound = false
|
|
send(this.controller, { action: 'mute', enabled: !next.sound })
|
|
}
|
|
if (previous) {
|
|
try {
|
|
applyRemote(this.controller, previous, next)
|
|
} catch (error) {
|
|
this.state.error = error instanceof Error ? error.message : 'Команда сервера не выполнена'
|
|
}
|
|
}
|
|
if (this.controller.state.scene.mode === 6 && Date.now() - this.lastResults >= 5000)
|
|
void this.refreshResults().catch((error) => {
|
|
this.state.error =
|
|
error instanceof Error ? error.message : 'Не удалось обновить результаты'
|
|
})
|
|
} catch (error) {
|
|
if (generation !== this.generation || session !== this.controller.state.session) return
|
|
this.previous = null
|
|
if (error instanceof ApiError && error.status === 401) {
|
|
this.detach()
|
|
this.state.connection = 'expired'
|
|
await this.persistSecrets().catch(() => {})
|
|
} else this.state.connection = 'offline'
|
|
this.state.error =
|
|
error instanceof Error ? error.message : 'Не удалось получить состояние экрана'
|
|
} finally {
|
|
this.pollBusy = false
|
|
}
|
|
}
|
|
async musicEnded(report: Report) {
|
|
const m = this.previous?.music
|
|
const c = this.controller.state.channels.music
|
|
if (
|
|
!this.screen ||
|
|
!m ||
|
|
this.advanceBusy ||
|
|
this.state.connection !== 'connected' ||
|
|
report.channel !== 'music' ||
|
|
report.status !== 'ended' ||
|
|
report.session !== this.controller.state.session ||
|
|
report.operation !== c.operation ||
|
|
c.desired !== 'playing' ||
|
|
c.playlistId !== m.playlistId ||
|
|
c.source?.id !== `track-${m.playlistId}-${m.trackId}` ||
|
|
m.playback !== 2
|
|
)
|
|
return
|
|
this.advanceBusy = true
|
|
const generation = this.generation
|
|
try {
|
|
const result = await this.api(
|
|
'/api/screens/music/advance',
|
|
{ screenID: this.screen.id, accessToken: this.screen.token },
|
|
false,
|
|
AbortSignal.timeout(5000),
|
|
)
|
|
if (generation === this.generation && this.previous?.music?.version === m.version) {
|
|
const music = parseRemote({ music: result.state }).music
|
|
if (music) this.previous = { ...this.previous, music }
|
|
}
|
|
} catch {
|
|
if (generation === this.generation) {
|
|
this.previous = null
|
|
this.state.connection = 'offline'
|
|
this.state.error = 'Переход музыки выполнен локально; сервер не подтвердил обновление'
|
|
}
|
|
} finally {
|
|
this.advanceBusy = false
|
|
}
|
|
}
|
|
close() {
|
|
this.cancellation?.abort()
|
|
this.packages.cancel()
|
|
this.detach()
|
|
}
|
|
}
|