This commit is contained in:
2026-10-18 03:11:10 +07:00
parent 8610f45a29
commit 3e2608eae3
23 changed files with 1460 additions and 302 deletions
+589 -115
View File
@@ -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,
])
}
}