up
This commit is contained in:
+589
-115
@@ -10,7 +10,7 @@ import type {
|
||||
} 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 { type ScreenPackage, prepareScreenPackage, resolveMediaURL } from './screen-package.js'
|
||||
import { parseResults } from './results.js'
|
||||
import { parsePlan } from './screen-plan.js'
|
||||
import {
|
||||
@@ -86,6 +86,7 @@ export class Online {
|
||||
error: '',
|
||||
notice: '',
|
||||
progress: null,
|
||||
preparation: { active: false, completed: 0, total: 0, downloadedBytes: 0, error: '' },
|
||||
packagesRevision: 0,
|
||||
}
|
||||
private credentials: Credentials | null = null
|
||||
@@ -96,6 +97,292 @@ export class Online {
|
||||
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 async description(gameId: number): Promise<ScreenPackage> {
|
||||
if (!this.credentials) throw new ApiError(401)
|
||||
return prepareScreenPackage(
|
||||
{
|
||||
server: this.credentials.server,
|
||||
gameId,
|
||||
token: this.credentials.access,
|
||||
signal: this.materialAbort.signal,
|
||||
},
|
||||
(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)
|
||||
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 &&
|
||||
JSON.stringify(old.music?.filenames) !== JSON.stringify(next.music?.filenames)
|
||||
)
|
||||
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
|
||||
@@ -208,11 +495,61 @@ export class Online {
|
||||
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) {
|
||||
@@ -234,10 +571,12 @@ export class Online {
|
||||
// В сеть уходит только последняя команда; после обрыва ожидающие действия не повторяются.
|
||||
const wasAvailable = await previousCommand
|
||||
isAvailable = !hasPendingCommand || wasAvailable
|
||||
if (generation !== this.generation || session !== this.controller.state.session) return
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
version !== this.planVersion ||
|
||||
session !== this.controller.state.session
|
||||
version !== this.planVersion &&
|
||||
['step', 'mode', 'text', 'select', 'timer-set', 'playlist', 'track', 'next'].includes(
|
||||
intent.action,
|
||||
)
|
||||
)
|
||||
return
|
||||
if (hasPendingCommand && !wasAvailable) {
|
||||
@@ -268,6 +607,76 @@ export class Online {
|
||||
) {
|
||||
if (confirmed.steps[intent.index]) send(this.controller, intent)
|
||||
}
|
||||
} else if ('channel' in intent && intent.channel === 'music') {
|
||||
const c = localState.channels.music
|
||||
await this.api(
|
||||
`/api/games/${pkg.package.game.id}/music`,
|
||||
{
|
||||
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' ? 2 : c.desired === 'paused' ? 3 : 1,
|
||||
},
|
||||
true,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
} else if (intent.action === 'mode' || intent.action === 'select') {
|
||||
await this.api(
|
||||
`/api/games/${pkg.package.game.id}/screens/mode`,
|
||||
{ mode: localState.scene.mode },
|
||||
true,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
} else if (intent.action === 'text') {
|
||||
const path = `/api/games/${pkg.package.game.id}/screen-plan`
|
||||
const current = parsePlan((await this.api(path)).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.json(
|
||||
await this.authorized(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(current),
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
session !== this.controller.state.session ||
|
||||
version !== this.planVersion
|
||||
)
|
||||
return
|
||||
const result = await this.api(`${path}/step`, { 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.api(
|
||||
`/api/games/${pkg.package.game.id}/screens/timer`,
|
||||
{ label: intent.label, durationSeconds: intent.durationSeconds },
|
||||
true,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
} else {
|
||||
const index = this.controller.state.scene.stepIndex
|
||||
if (index !== null && index !== this.plan.currentStep) {
|
||||
@@ -331,8 +740,9 @@ export class Online {
|
||||
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 &&
|
||||
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) ||
|
||||
@@ -340,31 +750,72 @@ export class Online {
|
||||
(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)
|
||||
}
|
||||
(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.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
|
||||
}
|
||||
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 }
|
||||
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)
|
||||
@@ -390,7 +841,9 @@ export class Online {
|
||||
private fetchAPI: typeof fetch = fetch,
|
||||
) {
|
||||
controller.onMusicEnded = (report) => {
|
||||
if (!this.screen) return false
|
||||
void this.musicEnded(report)
|
||||
return true
|
||||
}
|
||||
}
|
||||
get blocksOutput() {
|
||||
@@ -484,7 +937,8 @@ export class Online {
|
||||
if (this.busy) throw new Error('Подождите завершения подготовки материалов')
|
||||
let pkg
|
||||
try {
|
||||
pkg = key === null ? null : await this.packages.verify(key)
|
||||
pkg =
|
||||
key === null ? null : (this.packages.description(key) ?? (await this.packages.verify(key)))
|
||||
} catch (error) {
|
||||
if (key === this.controller.state.catalog.key) {
|
||||
this.detach()
|
||||
@@ -521,6 +975,21 @@ export class Online {
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -780,17 +1249,28 @@ export class Online {
|
||||
this.state.packagesRevision++
|
||||
return
|
||||
}
|
||||
case 'retry-materials':
|
||||
this.lastMaterials = -Infinity
|
||||
await this.refreshMaterials()
|
||||
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)
|
||||
let pkg = this.selectedGame()
|
||||
const gameId = data.gameId == null ? pkg?.package.game.id : integer(data.gameId, 1)
|
||||
if (!gameId) throw new Error('Выберите игру на сервере')
|
||||
this.detach()
|
||||
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 }
|
||||
this.state.packagesRevision++
|
||||
await this.saveSelection()
|
||||
}
|
||||
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,
|
||||
gameId,
|
||||
}
|
||||
await this.api(`/api/screens/${s.id}/bind`, {
|
||||
screenID: s.id,
|
||||
@@ -896,23 +1376,12 @@ export class Online {
|
||||
}
|
||||
}
|
||||
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 s = this.screen
|
||||
if (!s) return
|
||||
const generation = this.generation
|
||||
const version = this.planVersion
|
||||
this.pollBusy = true
|
||||
try {
|
||||
const result = await this.api(
|
||||
'/api/screens/state',
|
||||
@@ -920,120 +1389,125 @@ export class Online {
|
||||
false,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
session !== this.controller.state.session ||
|
||||
!this.controller.state.outputOpen ||
|
||||
planVersion !== this.planVersion
|
||||
)
|
||||
return
|
||||
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 =
|
||||
'Привязка экрана изменилась на сервере. Локальный показ сохранён; подключите управление заново'
|
||||
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 }
|
||||
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.state.connection === 'offline') this.state.error = ''
|
||||
this.state.connection = 'connected'
|
||||
if (!previous && this.lastSound !== next.sound) {
|
||||
if (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)
|
||||
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) => {
|
||||
this.state.error =
|
||||
error instanceof Error ? error.message : 'Не удалось обновить результаты'
|
||||
if (generation === this.generation)
|
||||
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 (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 : 'Не удалось получить состояние экрана'
|
||||
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 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
|
||||
c.desired !== 'playing'
|
||||
)
|
||||
return
|
||||
this.advanceBusy = true
|
||||
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.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 }
|
||||
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 {
|
||||
if (generation === this.generation) {
|
||||
this.previous = null
|
||||
this.state.connection = 'offline'
|
||||
this.state.error = 'Переход музыки выполнен локально; сервер не подтвердил обновление'
|
||||
} 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
|
||||
}
|
||||
}
|
||||
close() {
|
||||
async close() {
|
||||
this.cancellation?.abort()
|
||||
this.packages.cancel()
|
||||
this.detach()
|
||||
await Promise.allSettled([
|
||||
this.materialRefresh,
|
||||
this.materialWork,
|
||||
this.mainWork,
|
||||
this.musicWork,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user