up
This commit is contained in:
@@ -188,3 +188,37 @@
|
|||||||
применимые требования стандарта; нарушений не найдено. GUI: Linux x64,
|
применимые требования стандарта; нарушений не найдено. GUI: Linux x64,
|
||||||
синтетический дисплей, no-sandbox/mute-audio/disable-gpu; физический звук,
|
синтетический дисплей, no-sandbox/mute-audio/disable-gpu; физический звук,
|
||||||
проектор и остальные ОС не проверялись, релизные архивы не пересобирались.
|
проектор и остальные ОС не проверялись, релизные архивы не пересобирались.
|
||||||
|
|
||||||
|
|
||||||
|
## SCREEN-010 — Редактирование серверного плана экранов в пульте
|
||||||
|
|
||||||
|
- Статус: `DONE`.
|
||||||
|
- Дата: 2026-10-18.
|
||||||
|
- Результат: список шагов с «Показать», выделением текущего шага и переходами
|
||||||
|
назад/вперёд; редактор добавления, удаления, порядка, восьми режимов, текста и
|
||||||
|
параметров таймера. Сохранение и удаление общего плана через существующий API;
|
||||||
|
отмена грязного черновика с подтверждением. Ошибка сохранения сохраняет черновик.
|
||||||
|
- Сервер: права и JWT проверяются прежним Online-транспортом; серверные операции
|
||||||
|
доступны только пульту. Бэкенд, сайт, protobuf и зависимости не изменены.
|
||||||
|
- Офлайн: подтверждённый план хранится отдельно от пакета по ключу игры,
|
||||||
|
восстанавливается после перезапуска. Локальная команда выполняется до сети;
|
||||||
|
серверные запросы упорядочены, устаревшие ответы и неотправленные промежуточные
|
||||||
|
переходы пропускаются. После обрыва ожидающие действия не повторяются;
|
||||||
|
возврат сети сам не меняет показ. Следующее новое действие снова пробует сервер.
|
||||||
|
- Синхронизация: план обновляется и при закрытом показе; фоновый опрос не меняет
|
||||||
|
черновик. Последнее сохранение заменяет общий план. Сохранение не меняет сцену;
|
||||||
|
изменение активного шага снимает выделение. Собственные подтверждения не
|
||||||
|
сбрасывают повторно таймер/медиа, конкурентный переход с сайта учитывается.
|
||||||
|
- Проверки: type-check, ESLint, Stylelint, 88 тестов, сборка и полный Electron
|
||||||
|
GUI smoke — PASS. Smoke покрывает редактор, отказ сохранения, текст/таймер,
|
||||||
|
перестановку, отмену, удаление и запрет IPC из окна показа. Отдельные тесты
|
||||||
|
покрывают офлайн/перезапуск, быстрые переходы, запоздалые ответы, смену игры,
|
||||||
|
конкурентный таймер и назначение звука. Проверки HTTP/GUI выполнялись вне
|
||||||
|
песочницы; внутри неё HTTP-тест получает EPERM.
|
||||||
|
- Ревью: ручная проверка diff по применимым правилам и независимое ревью
|
||||||
|
синхронизации; найденные ошибки исправлены, добавлены регрессионные тесты.
|
||||||
|
git diff --check — PASS. README и руководство ведущего обновлены.
|
||||||
|
- Ограничения: GUI использует синтетический внешний дисплей и локальный тестовый
|
||||||
|
сервер; физический проектор, звук и рабочий сервер отдельно не проверялись.
|
||||||
|
Сборка предупреждает о JS chunk >500 kB. Установочные архивы не пересобирались;
|
||||||
|
commit и push не выполнялись.
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
офлайн без автоматического открытия показа и запуска звука.
|
офлайн без автоматического открытия показа и запуска звука.
|
||||||
Управление с телефона идёт через существующий сервер сайта; прямого LAN-подключения нет.
|
Управление с телефона идёт через существующий сервер сайта; прямого LAN-подключения нет.
|
||||||
|
|
||||||
|
SCREEN-010 реализована: в пульте доступен редактор общего серверного плана
|
||||||
|
экранов с локальной копией для офлайн-переходов. Подробности — в руководстве.
|
||||||
|
Установочные архивы кандидата эти изменения пока не содержат.
|
||||||
|
|
||||||
Цель: ведущий управляет показом на компьютере с macOS, Windows или Linux, а подключённый монитор или
|
Цель: ведущий управляет показом на компьютере с macOS, Windows или Linux, а подключённый монитор или
|
||||||
проектор показывает материалы игрокам. Все материалы показа заранее находятся
|
проектор показывает материалы игрокам. Все материалы показа заранее находятся
|
||||||
на диске; локальная команда не ждёт сеть или ответ сервера.
|
на диске; локальная команда не ждёт сеть или ответ сервера.
|
||||||
@@ -223,8 +227,10 @@ URL, независимо от исходного имени файла. Лок
|
|||||||
## Пульт и режимы SCREEN-005
|
## Пульт и режимы SCREEN-005
|
||||||
|
|
||||||
Пульт включает готовый пакет, план шагов, переключатель восьми режимов, текст,
|
Пульт включает готовый пакет, план шагов, переключатель восьми режимов, текст,
|
||||||
таймер, предпросмотр и прежние независимые каналы медиа/музыки. План из пакета
|
таймер, предпросмотр и прежние независимые каналы медиа/музыки. План экранов редактируется в пульте и сохраняется на сервере выбранной игры;
|
||||||
доступен только для чтения. Выбор шага применяет его параметры без автоматического
|
последняя подтверждённая копия хранится отдельно от пакета для офлайн-показа.
|
||||||
|
Онлайн-переход меняет общий шаг, офлайн-переход остаётся локальным и не отправляется
|
||||||
|
позже. Восстановление сети само не переключает показ. Выбор шага применяет его параметры без автоматического
|
||||||
запуска звука/таймера; окончание медиа не переключает шаг. Пуск основного ресурса
|
запуска звука/таймера; окончание медиа не переключает шаг. Пуск основного ресурса
|
||||||
показывает соответствующий режим вступления или видео. Уход в другую сцену
|
показывает соответствующий режим вступления или видео. Уход в другую сцену
|
||||||
останавливает основной канал, сохраняя независимую музыку.
|
останавливает основной канал, сохраняя независимую музыку.
|
||||||
|
|||||||
+22
-1
@@ -81,13 +81,34 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
|||||||
|
|
||||||
## Проведение показа
|
## Проведение показа
|
||||||
|
|
||||||
В «План показа» выберите шаг, используйте «Предыдущий шаг» / «Следующий шаг»
|
В «План экранов» нажмите «Показать» у нужного шага, используйте «Предыдущий шаг» / «Следующий шаг»
|
||||||
либо переключатель «Режим показа». Доступны восемь режимов: «Обложка»,
|
либо переключатель «Режим показа». Доступны восемь режимов: «Обложка»,
|
||||||
«Введение», «Успехов», «Закрывашка», «Спасибо за игру»,
|
«Введение», «Успехов», «Закрывашка», «Спасибо за игру»,
|
||||||
«Результаты», «Таймер» и «Текст». Переход к шагу или режиму сам не запускает
|
«Результаты», «Таймер» и «Текст». Переход к шагу или режиму сам не запускает
|
||||||
медиа и таймер. Дождитесь готовности; отправленная команда ещё не означает
|
медиа и таймер. Дождитесь готовности; отправленная команда ещё не означает
|
||||||
успешного воспроизведения — смотрите статус и фактическое изображение/звук.
|
успешного воспроизведения — смотрите статус и фактическое изображение/звук.
|
||||||
|
|
||||||
|
### Редактирование плана
|
||||||
|
|
||||||
|
Войдите на сервер выбранной подготовленной игры. Нажмите «Создать план» или
|
||||||
|
«Редактировать»: добавляйте и удаляйте шаги, меняйте порядок кнопками ↑ / ↓,
|
||||||
|
режим, текст, надпись и длительность таймера. «Сохранить план» отправляет план
|
||||||
|
на сервер; «Отменить» возвращает сохранённый вариант. Для удаления всего плана
|
||||||
|
нажмите «Удалить план», затем «Сохранить план». При ошибке сохранения черновик
|
||||||
|
остаётся в редакторе. Изменения черновика и сохранение сами не меняют проектор.
|
||||||
|
|
||||||
|
План общий с сайтом и телефоном. При параллельных правках последнее сохранение
|
||||||
|
заменяет его целиком. Во время редактирования фоновое обновление не меняет
|
||||||
|
черновик. Сохранение требует сети; локально хранится последняя подтверждённая
|
||||||
|
копия отдельно от материалов игры.
|
||||||
|
|
||||||
|
Кнопки «Показать», переходы назад/вперёд и управление текущим шагом работают
|
||||||
|
локально сразу. При наличии связи действие отправляется на сервер для остальных
|
||||||
|
привязанных экранов. Без сети показ продолжается по последней копии; статус
|
||||||
|
предупреждает о неподтверждённом обновлении. Офлайн-команды не накапливаются:
|
||||||
|
восстановление связи само не меняет проектор и не отправляет прошлые переходы.
|
||||||
|
Следующее действие онлайн снова управляет общим планом.
|
||||||
|
|
||||||
### Вступление, видео и музыка
|
### Вступление, видео и музыка
|
||||||
|
|
||||||
В «Вступление и видео» выберите медиа; в «Музыка» — плейлист и трек.
|
В «Вступление и видео» выберите медиа; в «Музыка» — плейлист и трек.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
State,
|
State,
|
||||||
ScreenMode,
|
ScreenMode,
|
||||||
TimerState,
|
TimerState,
|
||||||
|
ScreenStep,
|
||||||
} from '../src/types/screen.js'
|
} from '../src/types/screen.js'
|
||||||
import { parseResults } from './results.js'
|
import { parseResults } from './results.js'
|
||||||
import type { LocalPackage } from './package-store.js'
|
import type { LocalPackage } from './package-store.js'
|
||||||
@@ -345,6 +346,28 @@ export class Controller {
|
|||||||
this.timerDeadline = timer.status === 'running' ? this.now() + timer.remainingMs : null
|
this.timerDeadline = timer.status === 'running' ? this.now() + timer.remainingMs : null
|
||||||
this.broadcast()
|
this.broadcast()
|
||||||
}
|
}
|
||||||
|
setPlan(steps: ScreenStep[]) {
|
||||||
|
if (isDeepStrictEqual(steps, this.state.catalog.steps)) return
|
||||||
|
const index = this.state.scene.stepIndex
|
||||||
|
const old = index === null ? null : this.state.catalog.steps[index]
|
||||||
|
this.state.catalog.steps = structuredClone(steps)
|
||||||
|
if (index !== null && !isDeepStrictEqual(old, steps[index])) this.state.scene.stepIndex = null
|
||||||
|
this.broadcast()
|
||||||
|
}
|
||||||
|
markStep(index: number) {
|
||||||
|
const step = this.state.catalog.steps[index]
|
||||||
|
const scene = this.state.scene
|
||||||
|
if (!step || step.mode !== scene.mode || (step.mode === 8 && step.text !== scene.text)) return
|
||||||
|
if (
|
||||||
|
step.mode === 7 &&
|
||||||
|
(step.timerLabel !== this.state.timer.label ||
|
||||||
|
step.timerDurationSeconds * 1000 !== this.state.timer.durationMs)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if (scene.stepIndex === index) return
|
||||||
|
scene.stepIndex = index
|
||||||
|
this.broadcast()
|
||||||
|
}
|
||||||
setResults(value: unknown) {
|
setResults(value: unknown) {
|
||||||
const key = this.state.catalog.key
|
const key = this.state.catalog.key
|
||||||
if (value !== null && !key) throw new Error('Сначала выберите пакет игры')
|
if (value !== null && !key) throw new Error('Сначала выберите пакет игры')
|
||||||
|
|||||||
+23
-3
@@ -419,6 +419,10 @@ app
|
|||||||
throw new Error('Недоступно')
|
throw new Error('Недоступно')
|
||||||
return controller.state
|
return controller.state
|
||||||
})
|
})
|
||||||
|
ipcMain.handle('plan-status', (event) => {
|
||||||
|
requireControl(event)
|
||||||
|
return online!.planStatus()
|
||||||
|
})
|
||||||
ipcMain.handle('online-state', (event): OnlineState => {
|
ipcMain.handle('online-state', (event): OnlineState => {
|
||||||
requireControl(event)
|
requireControl(event)
|
||||||
return online!.snapshot()
|
return online!.snapshot()
|
||||||
@@ -476,7 +480,7 @@ app
|
|||||||
})
|
})
|
||||||
let localSession = ''
|
let localSession = ''
|
||||||
let localSequence = 0
|
let localSequence = 0
|
||||||
ipcMain.handle('command', (event, command: unknown) => {
|
ipcMain.handle('command', async (event, command: unknown) => {
|
||||||
requireControl(event)
|
requireControl(event)
|
||||||
if (selectingPackage) throw new Error('Подождите проверки пакета')
|
if (selectingPackage) throw new Error('Подождите проверки пакета')
|
||||||
const parsed = parseCommand(command)
|
const parsed = parseCommand(command)
|
||||||
@@ -491,8 +495,24 @@ app
|
|||||||
localSequence = 0
|
localSequence = 0
|
||||||
}
|
}
|
||||||
if (parsed.sequence <= localSequence) return
|
if (parsed.sequence <= localSequence) return
|
||||||
controller.command({ ...parsed, sequence: controller.state.sequence + 1 })
|
if (
|
||||||
localSequence = parsed.sequence
|
parsed.action === 'step' ||
|
||||||
|
(controller.state.scene.stepIndex !== null &&
|
||||||
|
((parsed.action.startsWith('timer-') && parsed.action !== 'timer-set') ||
|
||||||
|
('channel' in parsed &&
|
||||||
|
parsed.channel === 'main' &&
|
||||||
|
['play', 'pause', 'resume', 'stop', 'replay', 'seek', 'volume'].includes(
|
||||||
|
parsed.action,
|
||||||
|
))))
|
||||||
|
) {
|
||||||
|
const before = controller.state.sequence
|
||||||
|
const operation = online!.planCommand(parsed)
|
||||||
|
if (controller.state.sequence !== before) localSequence = parsed.sequence
|
||||||
|
await operation
|
||||||
|
} else {
|
||||||
|
controller.command({ ...parsed, sequence: controller.state.sequence + 1 })
|
||||||
|
localSequence = parsed.sequence
|
||||||
|
}
|
||||||
})
|
})
|
||||||
let capturing = false
|
let capturing = false
|
||||||
let lastCapture = -Infinity
|
let lastCapture = -Infinity
|
||||||
|
|||||||
+322
-2
@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os'
|
|||||||
import { Controller } from './controller.js'
|
import { Controller } from './controller.js'
|
||||||
import { Online, type SecretStorage } from './online.js'
|
import { Online, type SecretStorage } from './online.js'
|
||||||
import { PackageStore } from './package-store.js'
|
import { PackageStore } from './package-store.js'
|
||||||
|
import { parsePlan } from './screen-plan.js'
|
||||||
import { applyRemote, parseRemote, send } from './remote-state.js'
|
import { applyRemote, parseRemote, send } from './remote-state.js'
|
||||||
|
|
||||||
async function fixture(t: TestContext, secure = true) {
|
async function fixture(t: TestContext, secure = true) {
|
||||||
@@ -49,6 +50,12 @@ async function fixture(t: TestContext, secure = true) {
|
|||||||
positionMs: '0',
|
positionMs: '0',
|
||||||
volume: 80,
|
volume: 80,
|
||||||
} as Record<string, unknown>,
|
} as Record<string, unknown>,
|
||||||
|
plan: parsePlan({ steps: [{ mode: 2 }] }),
|
||||||
|
planError: 0,
|
||||||
|
planSaves: 0,
|
||||||
|
planTransitions: 0,
|
||||||
|
planResponse: null as (() => Promise<Response>) | null,
|
||||||
|
stepResponse: null as (() => Promise<Response>) | null,
|
||||||
stateResponse: null as (() => Promise<Response>) | null,
|
stateResponse: null as (() => Promise<Response>) | null,
|
||||||
}
|
}
|
||||||
const json = (value: unknown, status = 200) => Response.json(value, { status })
|
const json = (value: unknown, status = 200) => Response.json(value, { status })
|
||||||
@@ -131,7 +138,43 @@ async function fixture(t: TestContext, secure = true) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (path === '/api/games/7/screen-plan') return json({ plan: { steps: [{ mode: 2 }] } })
|
if (path === '/api/games/7/screen-plan') {
|
||||||
|
if (model.planResponse) return model.planResponse()
|
||||||
|
if (model.planError) return json({}, model.planError)
|
||||||
|
if (init?.method === 'PUT') {
|
||||||
|
model.planSaves++
|
||||||
|
model.plan = parsePlan(JSON.parse(String(init.body)))
|
||||||
|
}
|
||||||
|
return json({ plan: model.plan.steps.length ? model.plan : null })
|
||||||
|
}
|
||||||
|
if (path === '/api/games/7/screen-plan/step') {
|
||||||
|
if (model.stepResponse) return model.stepResponse()
|
||||||
|
model.planTransitions++
|
||||||
|
if (model.planError) return json({}, model.planError)
|
||||||
|
const data = JSON.parse(String(init?.body)) as { currentStep: number }
|
||||||
|
const step = model.plan.steps[data.currentStep]
|
||||||
|
if (!step) return json({}, 400)
|
||||||
|
model.plan.currentStep = data.currentStep
|
||||||
|
model.remote = {
|
||||||
|
...model.remote,
|
||||||
|
mode: step.mode,
|
||||||
|
text: step.text,
|
||||||
|
command: 0,
|
||||||
|
timerLabel: step.timerLabel,
|
||||||
|
timerDurationSeconds: step.timerDurationSeconds || 600,
|
||||||
|
timerState: 1,
|
||||||
|
timerRemainingSeconds: step.timerDurationSeconds,
|
||||||
|
stateVersion: String(BigInt(String(model.remote.stateVersion)) + 1n),
|
||||||
|
}
|
||||||
|
return json({ plan: model.plan })
|
||||||
|
}
|
||||||
|
if (path === '/api/games/7/screens/command') {
|
||||||
|
if (model.planError) return json({}, model.planError)
|
||||||
|
const data = JSON.parse(String(init?.body)) as { command: number }
|
||||||
|
model.remote.command = data.command
|
||||||
|
model.remote.stateVersion = String(BigInt(String(model.remote.stateVersion)) + 1n)
|
||||||
|
return json({})
|
||||||
|
}
|
||||||
if (path === '/api/games/7/music')
|
if (path === '/api/games/7/music')
|
||||||
return json({
|
return json({
|
||||||
gamePlaylistId: 1,
|
gamePlaylistId: 1,
|
||||||
@@ -426,7 +469,10 @@ test('перестановка прежних треков останавлив
|
|||||||
})
|
})
|
||||||
assert.throws(() => applyRemote(f.controller, previous, next), /Музыка изменилась/)
|
assert.throws(() => applyRemote(f.controller, previous, next), /Музыка изменилась/)
|
||||||
assert.equal(f.controller.state.channels.music.desired, 'stopped')
|
assert.equal(f.controller.state.channels.music.desired, 'stopped')
|
||||||
assert.deepEqual(f.controller.state.catalog.playlists[0]!.tracks.map((track) => track.id), [1, 2])
|
assert.deepEqual(
|
||||||
|
f.controller.state.catalog.playlists[0]!.tracks.map((track) => track.id),
|
||||||
|
[1, 2],
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('назначение звука восстанавливается после обрыва и открытия окна без повтора команд', async (t) => {
|
test('назначение звука восстанавливается после обрыва и открытия окна без повтора команд', async (t) => {
|
||||||
@@ -625,3 +671,277 @@ test('назначение звука подтверждается сервер
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('план: сохранение и удаление на сервере, восстановление копии офлайн, ошибки валидации и доступа', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.prepare()
|
||||||
|
const key = f.controller.state.catalog.key!
|
||||||
|
assert.equal(f.online.planStatus().editable, true)
|
||||||
|
const steps = parsePlan({
|
||||||
|
steps: [
|
||||||
|
{ mode: 'SCREEN_MODE_TEXT', text: ' Новый текст ' },
|
||||||
|
{ mode: 7, timerDurationSeconds: '60' },
|
||||||
|
],
|
||||||
|
}).steps
|
||||||
|
const sceneRevision = f.controller.state.scene.revision
|
||||||
|
await f.online.act({ action: 'plan-save', key, steps })
|
||||||
|
assert.deepEqual(f.model.plan.steps, steps)
|
||||||
|
assert.deepEqual(f.controller.state.catalog.steps, steps)
|
||||||
|
assert.equal(f.controller.state.scene.revision, sceneRevision)
|
||||||
|
assert.equal(f.model.planSaves, 1)
|
||||||
|
await assert.rejects(
|
||||||
|
f.online.act({ action: 'plan-save', key, steps: [{ mode: 8, text: ' ' }] }),
|
||||||
|
/Текст шага/,
|
||||||
|
)
|
||||||
|
await assert.rejects(
|
||||||
|
f.online.act({ action: 'plan-save', key: 'wrong', steps }),
|
||||||
|
/игра изменилась/,
|
||||||
|
)
|
||||||
|
assert.equal(f.model.planSaves, 1)
|
||||||
|
f.model.planError = 403
|
||||||
|
await assert.rejects(f.online.act({ action: 'plan-save', key, steps: [] }), /Нет доступа/)
|
||||||
|
assert.deepEqual(f.controller.state.catalog.steps, steps)
|
||||||
|
f.model.planError = 0
|
||||||
|
f.model.offline = true
|
||||||
|
await assert.rejects(f.online.act({ action: 'plan-save', key, steps: [] }), /Сервер недоступен/)
|
||||||
|
const restoredController = new Controller(() => {})
|
||||||
|
const restored = new Online(
|
||||||
|
join(f.directory, 'connection'),
|
||||||
|
f.store,
|
||||||
|
restoredController,
|
||||||
|
f.secrets,
|
||||||
|
f.fetchAPI,
|
||||||
|
)
|
||||||
|
await restored.initialize()
|
||||||
|
assert.deepEqual(restoredController.state.catalog.steps, steps)
|
||||||
|
assert.equal(restored.planStatus().key, key)
|
||||||
|
await restored.select(key)
|
||||||
|
f.model.offline = false
|
||||||
|
await f.online.act({ action: 'plan-save', key, steps: [] })
|
||||||
|
assert.deepEqual(f.controller.state.catalog.steps, [])
|
||||||
|
assert.deepEqual(
|
||||||
|
JSON.parse(await readFile(join(f.directory, 'connection', `plan-${key}.json`), 'utf8')).steps,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('план: онлайн-переход общий, без автозапуска и повторного применения; офлайн-переход не отправляется после возврата сети', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.prepare()
|
||||||
|
const steps = parsePlan({
|
||||||
|
steps: [
|
||||||
|
{ mode: 7, timerLabel: 'Отдых', timerDurationSeconds: 60 },
|
||||||
|
{ mode: 8, text: 'Локальный шаг' },
|
||||||
|
],
|
||||||
|
}).steps
|
||||||
|
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
||||||
|
f.controller.output(true)
|
||||||
|
await f.online.poll()
|
||||||
|
await f.online.planCommand({ action: 'step', index: 0 })
|
||||||
|
assert.equal(f.model.planTransitions, 1)
|
||||||
|
assert.equal(f.model.plan.currentStep, 0)
|
||||||
|
assert.equal(f.controller.state.scene.stepIndex, 0)
|
||||||
|
assert.equal(f.controller.state.timer.status, 'stopped')
|
||||||
|
assert.equal(f.controller.state.channels.main.desired, 'stopped')
|
||||||
|
await f.online.planCommand({ action: 'timer-start' })
|
||||||
|
const timerRevision = f.controller.state.timer.revision
|
||||||
|
await f.online.poll()
|
||||||
|
assert.equal(f.controller.state.timer.revision, timerRevision)
|
||||||
|
assert.equal(f.controller.state.timer.status, 'running')
|
||||||
|
f.model.offline = true
|
||||||
|
await f.online.planCommand({ action: 'step', index: 1 })
|
||||||
|
assert.equal(f.controller.state.scene.text, 'Локальный шаг')
|
||||||
|
assert.match(f.online.planStatus().notice, /локально/)
|
||||||
|
f.model.offline = false
|
||||||
|
await f.online.poll()
|
||||||
|
assert.equal(f.controller.state.scene.text, 'Локальный шаг')
|
||||||
|
assert.equal(f.controller.state.scene.stepIndex, 1)
|
||||||
|
assert.equal(f.model.planTransitions, 1)
|
||||||
|
await f.online.planCommand({ action: 'step', index: 0 })
|
||||||
|
assert.equal(f.model.planTransitions, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('план: изменение на сайте между опросом и переходом применяет подтверждённое содержимое', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.prepare()
|
||||||
|
await f.online.refreshPlan()
|
||||||
|
f.model.plan = parsePlan({ steps: [{ mode: 8, text: 'Изменено на сайте' }] })
|
||||||
|
await f.online.planCommand({ action: 'step', index: 0 })
|
||||||
|
assert.equal(f.controller.state.scene.mode, 8)
|
||||||
|
assert.equal(f.controller.state.scene.text, 'Изменено на сайте')
|
||||||
|
assert.equal(f.controller.state.scene.stepIndex, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('план: поздний ответ старой игры и опрос до сохранения не заменяют новый план', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.prepare()
|
||||||
|
const key = f.controller.state.catalog.key!
|
||||||
|
let resolve!: (response: Response) => void
|
||||||
|
f.model.planResponse = () =>
|
||||||
|
new Promise((done) => {
|
||||||
|
resolve = done
|
||||||
|
})
|
||||||
|
const pending = f.online.refreshPlan()
|
||||||
|
await new Promise((done) => setImmediate(done))
|
||||||
|
f.model.planResponse = null
|
||||||
|
const steps = parsePlan({ steps: [{ mode: 8, text: 'Сохранено позже' }] }).steps
|
||||||
|
await f.online.act({ action: 'plan-save', key, steps })
|
||||||
|
resolve(Response.json({ plan: { steps: [{ mode: 2 }] } }))
|
||||||
|
await pending
|
||||||
|
assert.deepEqual(f.controller.state.catalog.steps, steps)
|
||||||
|
f.model.planResponse = () =>
|
||||||
|
new Promise((done) => {
|
||||||
|
resolve = done
|
||||||
|
})
|
||||||
|
const oldGame = f.online.refreshPlan()
|
||||||
|
await new Promise((done) => setImmediate(done))
|
||||||
|
await f.online.select(null)
|
||||||
|
resolve(Response.json({ plan: { steps: [{ mode: 8, text: 'Старая игра' }] } }))
|
||||||
|
await oldGame
|
||||||
|
assert.deepEqual(f.controller.state.catalog.steps, [])
|
||||||
|
assert.equal(f.online.planStatus().editable, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('план: локальные переходы не ждут незавершённый запрос и поздний ответ не меняет показ', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.prepare()
|
||||||
|
const steps = parsePlan({
|
||||||
|
steps: [
|
||||||
|
{ mode: 8, text: 'Первый' },
|
||||||
|
{ mode: 8, text: 'Второй' },
|
||||||
|
],
|
||||||
|
}).steps
|
||||||
|
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
||||||
|
let finish!: (response: Response) => void
|
||||||
|
f.model.stepResponse = () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
finish = resolve
|
||||||
|
})
|
||||||
|
const pending = f.online.planCommand({ action: 'step', index: 0 })
|
||||||
|
await new Promise((done) => setImmediate(done))
|
||||||
|
f.model.stepResponse = null
|
||||||
|
f.model.offline = true
|
||||||
|
const second = f.online.planCommand({ action: 'step', index: 1 })
|
||||||
|
assert.equal(f.controller.state.scene.text, 'Второй')
|
||||||
|
finish(Response.json({ plan: { steps: [{ mode: 8, text: 'Устаревший' }], currentStep: 0 } }))
|
||||||
|
await Promise.all([pending, second])
|
||||||
|
assert.equal(f.controller.state.scene.text, 'Второй')
|
||||||
|
assert.deepEqual(f.controller.state.catalog.steps, steps)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('план: изменения с сайта загружаются при закрытом показе; удалённый переход выделяет актуальный шаг', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.prepare()
|
||||||
|
f.model.plan = parsePlan({
|
||||||
|
steps: [
|
||||||
|
{ mode: 8, text: 'Один' },
|
||||||
|
{ mode: 8, text: 'Два' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await f.online.poll()
|
||||||
|
assert.equal(f.controller.state.outputOpen, false)
|
||||||
|
assert.deepEqual(f.controller.state.catalog.steps, f.model.plan.steps)
|
||||||
|
f.controller.output(true)
|
||||||
|
await f.online.poll()
|
||||||
|
f.model.plan.currentStep = 1
|
||||||
|
f.model.remote = {
|
||||||
|
...f.model.remote,
|
||||||
|
mode: 8,
|
||||||
|
text: 'Два',
|
||||||
|
command: 0,
|
||||||
|
stateVersion: String(BigInt(String(f.model.remote.stateVersion)) + 1n),
|
||||||
|
}
|
||||||
|
await f.online.poll()
|
||||||
|
assert.equal(f.controller.state.scene.text, 'Два')
|
||||||
|
assert.equal(f.controller.state.scene.stepIndex, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('план: быстрые переходы сериализованы, неотправленные промежуточные шаги пропускаются', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.prepare()
|
||||||
|
const steps = parsePlan({ steps: [0, 1, 2].map((i) => ({ mode: 8, text: `Шаг ${i}` })) }).steps
|
||||||
|
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
||||||
|
let finish!: (response: Response) => void
|
||||||
|
f.model.stepResponse = () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
finish = resolve
|
||||||
|
})
|
||||||
|
const first = f.online.planCommand({ action: 'step', index: 0 })
|
||||||
|
await new Promise((done) => setImmediate(done))
|
||||||
|
const second = f.online.planCommand({ action: 'step', index: 1 })
|
||||||
|
const third = f.online.planCommand({ action: 'step', index: 2 })
|
||||||
|
assert.equal(f.controller.state.scene.text, 'Шаг 2')
|
||||||
|
assert.equal(f.model.planTransitions, 0)
|
||||||
|
f.model.stepResponse = null
|
||||||
|
finish(Response.json({ plan: { steps, currentStep: 0 } }))
|
||||||
|
await Promise.all([first, second, third])
|
||||||
|
assert.equal(f.model.planTransitions, 1)
|
||||||
|
assert.equal(f.model.plan.currentStep, 2)
|
||||||
|
assert.equal(f.controller.state.scene.stepIndex, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('план: конкурентный серверный таймер не теряется при подтверждении собственного перехода', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.prepare()
|
||||||
|
const steps = parsePlan({
|
||||||
|
steps: [
|
||||||
|
{ mode: 7, timerLabel: 'Первый', timerDurationSeconds: 60 },
|
||||||
|
{ mode: 7, timerLabel: 'Второй', timerDurationSeconds: 120 },
|
||||||
|
],
|
||||||
|
}).steps
|
||||||
|
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
||||||
|
f.controller.output(true)
|
||||||
|
f.model.stateResponse = async () => {
|
||||||
|
f.model.plan.currentStep = 1
|
||||||
|
return Response.json({
|
||||||
|
state: {
|
||||||
|
gameID: 7,
|
||||||
|
mode: 7,
|
||||||
|
command: 0,
|
||||||
|
stateVersion: '99',
|
||||||
|
timerLabel: 'Второй',
|
||||||
|
timerDurationSeconds: 120,
|
||||||
|
timerRemainingSeconds: 120,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await f.online.planCommand({ action: 'step', index: 0 })
|
||||||
|
assert.equal(f.controller.state.timer.label, 'Второй')
|
||||||
|
assert.equal(f.controller.state.timer.durationMs, 120000)
|
||||||
|
assert.equal(f.controller.state.scene.stepIndex, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('план: сетевой сбой отбрасывает все ожидающие переходы, включая устаревшие промежуточные', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.prepare()
|
||||||
|
const steps = parsePlan({ steps: [0, 1, 2].map((i) => ({ mode: 8, text: `Шаг ${i}` })) }).steps
|
||||||
|
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
||||||
|
let fail!: (error: Error) => void
|
||||||
|
f.model.stepResponse = () =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
fail = reject
|
||||||
|
})
|
||||||
|
const first = f.online.planCommand({ action: 'step', index: 0 })
|
||||||
|
await new Promise((done) => setImmediate(done))
|
||||||
|
const second = f.online.planCommand({ action: 'step', index: 1 })
|
||||||
|
const third = f.online.planCommand({ action: 'step', index: 2 })
|
||||||
|
f.model.stepResponse = null
|
||||||
|
fail(new Error('network'))
|
||||||
|
await Promise.all([first, second, third])
|
||||||
|
assert.equal(f.model.planTransitions, 0)
|
||||||
|
assert.equal(f.controller.state.scene.text, 'Шаг 2')
|
||||||
|
await f.online.planCommand({ action: 'step', index: 0 })
|
||||||
|
assert.equal(f.model.planTransitions, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('план: первое подтверждение собственной команды учитывает назначение звука', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.prepare()
|
||||||
|
f.controller.output(true)
|
||||||
|
f.model.remote.soundEnabled = false
|
||||||
|
await f.online.planCommand({ action: 'step', index: 0 })
|
||||||
|
assert.equal(f.controller.state.muted, true)
|
||||||
|
assert.equal(f.online.state.soundEnabled, false)
|
||||||
|
await f.online.poll()
|
||||||
|
assert.equal(f.controller.state.muted, true)
|
||||||
|
})
|
||||||
|
|||||||
+341
-4
@@ -1,11 +1,18 @@
|
|||||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import type { OnlineIntent, OnlineState } from '../src/types/online.js'
|
import type { OnlineIntent, OnlineState } from '../src/types/online.js'
|
||||||
import type { Report, ResultsSnapshot } from '../src/types/screen.js'
|
import type {
|
||||||
|
Intent,
|
||||||
|
PlanStatus,
|
||||||
|
Report,
|
||||||
|
ResultsSnapshot,
|
||||||
|
ScreenPlan,
|
||||||
|
} from '../src/types/screen.js'
|
||||||
import type { Controller } from './controller.js'
|
import type { Controller } from './controller.js'
|
||||||
import { PackageStore, packageKey } from './package-store.js'
|
import { PackageStore, packageKey } from './package-store.js'
|
||||||
import { prepareScreenPackage } from './screen-package.js'
|
import { prepareScreenPackage } from './screen-package.js'
|
||||||
import { parseResults } from './results.js'
|
import { parseResults } from './results.js'
|
||||||
|
import { parsePlan } from './screen-plan.js'
|
||||||
import {
|
import {
|
||||||
applyRemote,
|
applyRemote,
|
||||||
integer,
|
integer,
|
||||||
@@ -32,6 +39,7 @@ interface Credentials {
|
|||||||
refresh: string
|
refresh: string
|
||||||
screen: ScreenSession | null
|
screen: ScreenSession | null
|
||||||
}
|
}
|
||||||
|
class NetworkError extends Error {}
|
||||||
class ApiError extends Error {
|
class ApiError extends Error {
|
||||||
constructor(public status: number) {
|
constructor(public status: number) {
|
||||||
super(
|
super(
|
||||||
@@ -94,6 +102,286 @@ export class Online {
|
|||||||
private lastResults = -Infinity
|
private lastResults = -Infinity
|
||||||
private resultsBusy = false
|
private resultsBusy = false
|
||||||
private advanceBusy = 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.api(
|
||||||
|
`/api/games/${pkg.package.game.id}/screen-plan`,
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
AbortSignal.timeout(5000),
|
||||||
|
)
|
||||||
|
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
|
||||||
|
} catch (error) {
|
||||||
|
if (generation === this.generation && version === this.planVersion)
|
||||||
|
this.planNotice = `${error instanceof Error ? error.message : 'План недоступен'}. Используется локальная копия`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async savePlan(data: Record<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.json(
|
||||||
|
await this.authorized(`/api/games/${pkg.package.game.id}/screen-plan`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(plan),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (generation !== this.generation) throw new Error('Сессия изменилась')
|
||||||
|
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
|
||||||
|
} finally {
|
||||||
|
this.planBusy--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async planCommand(intent: Intent) {
|
||||||
|
if (this.busy) throw new Error('Подождите завершения текущего действия')
|
||||||
|
const pkg = this.selectedGame()
|
||||||
|
// Локальная команда не ждёт сеть и никогда не откатывается при обрыве связи.
|
||||||
|
send(this.controller, intent)
|
||||||
|
const sequence = this.controller.state.sequence
|
||||||
|
const version = ++this.planVersion
|
||||||
|
if (!pkg || !this.planStatus().editable) {
|
||||||
|
this.planNotice = 'Переход выполнен только локально; войдите на сервер игры'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const generation = this.generation
|
||||||
|
const session = this.controller.state.session
|
||||||
|
const hasPendingCommand = this.planBusy > 0
|
||||||
|
const previousCommand = this.planCommands
|
||||||
|
let complete!: (available: boolean) => void
|
||||||
|
this.planCommands = new Promise((resolve) => {
|
||||||
|
complete = resolve
|
||||||
|
})
|
||||||
|
this.planBusy++
|
||||||
|
let isAvailable = true
|
||||||
|
let expectedCommand = 0
|
||||||
|
try {
|
||||||
|
// В сеть уходит только последняя команда; после обрыва ожидающие действия не повторяются.
|
||||||
|
const wasAvailable = await previousCommand
|
||||||
|
isAvailable = !hasPendingCommand || wasAvailable
|
||||||
|
if (
|
||||||
|
generation !== this.generation ||
|
||||||
|
version !== this.planVersion ||
|
||||||
|
session !== this.controller.state.session
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if (hasPendingCommand && !wasAvailable) {
|
||||||
|
isAvailable = false
|
||||||
|
this.previous = null
|
||||||
|
this.planNotice = 'Команда выполнена локально; сервер недоступен'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (intent.action === 'step') {
|
||||||
|
const result = await this.api(
|
||||||
|
`/api/games/${pkg.package.game.id}/screen-plan/step`,
|
||||||
|
{ currentStep: intent.index },
|
||||||
|
true,
|
||||||
|
AbortSignal.timeout(5000),
|
||||||
|
)
|
||||||
|
const confirmed = parsePlan(result.plan)
|
||||||
|
const shown = this.controller.state.catalog.steps[intent.index]
|
||||||
|
await this.acceptPlan(confirmed, pkg.key, generation, version)
|
||||||
|
if (
|
||||||
|
generation !== this.generation ||
|
||||||
|
version !== this.planVersion ||
|
||||||
|
session !== this.controller.state.session
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
this.controller.state.sequence === sequence &&
|
||||||
|
JSON.stringify(shown) !== JSON.stringify(confirmed.steps[intent.index])
|
||||||
|
) {
|
||||||
|
if (confirmed.steps[intent.index]) send(this.controller, intent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const index = this.controller.state.scene.stepIndex
|
||||||
|
if (index !== null && index !== this.plan.currentStep) {
|
||||||
|
const result = await this.api(
|
||||||
|
`/api/games/${pkg.package.game.id}/screen-plan/step`,
|
||||||
|
{ currentStep: index },
|
||||||
|
true,
|
||||||
|
AbortSignal.timeout(5000),
|
||||||
|
)
|
||||||
|
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
|
||||||
|
if (
|
||||||
|
generation !== this.generation ||
|
||||||
|
version !== this.planVersion ||
|
||||||
|
session !== this.controller.state.session
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const commands = {
|
||||||
|
play: 1,
|
||||||
|
pause: 2,
|
||||||
|
resume: 3,
|
||||||
|
stop: 4,
|
||||||
|
replay: 5,
|
||||||
|
seek: 6,
|
||||||
|
volume: 7,
|
||||||
|
'timer-start': 8,
|
||||||
|
'timer-pause': 9,
|
||||||
|
'timer-resume': 10,
|
||||||
|
'timer-reset': 11,
|
||||||
|
}
|
||||||
|
if (!(intent.action in commands)) throw new Error('Некорректная команда плана')
|
||||||
|
expectedCommand = commands[intent.action as keyof typeof commands]
|
||||||
|
await this.api(
|
||||||
|
`/api/games/${pkg.package.game.id}/screens/command`,
|
||||||
|
{
|
||||||
|
command: expectedCommand,
|
||||||
|
positionMs: 'position' in intent ? Math.round(intent.position * 1000) : 0,
|
||||||
|
volume: 'volume' in intent ? intent.volume : this.controller.state.channels.main.volume,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
AbortSignal.timeout(5000),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
generation !== this.generation ||
|
||||||
|
session !== this.controller.state.session ||
|
||||||
|
version !== this.planVersion
|
||||||
|
)
|
||||||
|
return
|
||||||
|
this.planNotice = ''
|
||||||
|
// Ответ следующего опроса становится базой: собственная команда не применяется дважды.
|
||||||
|
if (this.screen) {
|
||||||
|
const result = await this.api(
|
||||||
|
'/api/screens/state',
|
||||||
|
{ screenID: this.screen.id, accessToken: this.screen.token },
|
||||||
|
false,
|
||||||
|
AbortSignal.timeout(5000),
|
||||||
|
)
|
||||||
|
if (generation !== this.generation || session !== this.controller.state.session) return
|
||||||
|
const remote = parseRemote(result.state)
|
||||||
|
if (remote.gameId !== pkg.package.game.id) throw new Error('Привязка экрана изменилась')
|
||||||
|
if (version !== this.planVersion) return
|
||||||
|
const scene = this.controller.state.scene
|
||||||
|
if (
|
||||||
|
this.controller.state.sequence === sequence &&
|
||||||
|
(remote.command !== expectedCommand ||
|
||||||
|
remote.mode !== scene.mode ||
|
||||||
|
(remote.mode === 8 && remote.text !== scene.text) ||
|
||||||
|
(remote.mode === 7 &&
|
||||||
|
(remote.timer.label !== this.controller.state.timer.label ||
|
||||||
|
remote.timer.durationMs !== this.controller.state.timer.durationMs)) ||
|
||||||
|
(intent.action === 'seek' && remote.position !== intent.position) ||
|
||||||
|
(intent.action === 'volume' && remote.volume !== intent.volume))
|
||||||
|
) {
|
||||||
|
applyRemote(this.controller, { ...remote, version: remote.version - 1n }, remote)
|
||||||
|
if (remote.command === 0) {
|
||||||
|
const result = await this.api(
|
||||||
|
`/api/games/${pkg.package.game.id}/screen-plan`,
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
AbortSignal.timeout(5000),
|
||||||
|
)
|
||||||
|
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
|
||||||
|
if (
|
||||||
|
generation !== this.generation ||
|
||||||
|
version !== this.planVersion ||
|
||||||
|
session !== this.controller.state.session
|
||||||
|
)
|
||||||
|
return
|
||||||
|
this.controller.markStep(this.plan.currentStep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!this.previous || this.previous.sound !== remote.sound)
|
||||||
|
send(this.controller, { action: 'mute', enabled: !remote.sound })
|
||||||
|
this.lastSound = remote.sound
|
||||||
|
this.state.soundEnabled = remote.sound
|
||||||
|
this.previous = { ...remote, music: this.previous?.music ?? null }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
isAvailable = !(error instanceof NetworkError)
|
||||||
|
if (
|
||||||
|
generation !== this.generation ||
|
||||||
|
session !== this.controller.state.session ||
|
||||||
|
version !== this.planVersion
|
||||||
|
)
|
||||||
|
return
|
||||||
|
this.previous = null
|
||||||
|
this.planNotice = 'Команда выполнена локально; сервер не подтвердил обновление'
|
||||||
|
if (!(error instanceof NetworkError)) throw error
|
||||||
|
} finally {
|
||||||
|
this.planBusy--
|
||||||
|
complete(isAvailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
constructor(
|
constructor(
|
||||||
private directory: string,
|
private directory: string,
|
||||||
private packages: PackageStore,
|
private packages: PackageStore,
|
||||||
@@ -209,6 +497,22 @@ export class Online {
|
|||||||
}
|
}
|
||||||
this.detach()
|
this.detach()
|
||||||
this.controller.selectPackage(pkg)
|
this.controller.selectPackage(pkg)
|
||||||
|
this.plan = { steps: structuredClone(this.controller.state.catalog.steps), currentStep: 0 }
|
||||||
|
this.planNotice = ''
|
||||||
|
this.lastPlan = -Infinity
|
||||||
|
this.cachedPlan = ''
|
||||||
|
if (key) {
|
||||||
|
try {
|
||||||
|
this.plan = parsePlan(
|
||||||
|
JSON.parse(await readFile(join(this.directory, `plan-${key}.json`), 'utf8')),
|
||||||
|
)
|
||||||
|
this.controller.setPlan(this.plan.steps)
|
||||||
|
this.cachedPlan = JSON.stringify(this.plan)
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT')
|
||||||
|
this.planNotice = 'Локальная копия плана повреждена; используется план пакета'
|
||||||
|
}
|
||||||
|
}
|
||||||
this.lastResults = -Infinity
|
this.lastResults = -Infinity
|
||||||
if (key) await this.restoreResults(key)
|
if (key) await this.restoreResults(key)
|
||||||
if (persist) {
|
if (persist) {
|
||||||
@@ -218,6 +522,7 @@ export class Online {
|
|||||||
}
|
}
|
||||||
private detach() {
|
private detach() {
|
||||||
this.generation++
|
this.generation++
|
||||||
|
this.planVersion++
|
||||||
this.screen = null
|
this.screen = null
|
||||||
this.previous = null
|
this.previous = null
|
||||||
this.lastSound = null
|
this.lastSound = null
|
||||||
@@ -237,7 +542,7 @@ export class Online {
|
|||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Сервер недоступен или превышено время ожидания')
|
throw new NetworkError('Сервер недоступен или превышено время ожидания')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private async json(response: Response): Promise<Record<string, unknown>> {
|
private async json(response: Response): Promise<Record<string, unknown>> {
|
||||||
@@ -343,6 +648,10 @@ export class Online {
|
|||||||
private async perform(data: Record<string, unknown>) {
|
private async perform(data: Record<string, unknown>) {
|
||||||
const action = data.action as OnlineIntent['action']
|
const action = data.action as OnlineIntent['action']
|
||||||
switch (action) {
|
switch (action) {
|
||||||
|
case 'plan-save':
|
||||||
|
return this.savePlan(data)
|
||||||
|
case 'plan-refresh':
|
||||||
|
return this.refreshPlan()
|
||||||
case 'login': {
|
case 'login': {
|
||||||
const server = serverURL(data.server)
|
const server = serverURL(data.server)
|
||||||
const email = text(data.email).trim()
|
const email = text(data.email).trim()
|
||||||
@@ -442,6 +751,13 @@ export class Online {
|
|||||||
this.state.packagesRevision++
|
this.state.packagesRevision++
|
||||||
if (key === this.controller.state.catalog.key) {
|
if (key === this.controller.state.catalog.key) {
|
||||||
this.controller.selectPackage(pkg)
|
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
|
this.previous = null
|
||||||
await this.restoreResults(key)
|
await this.restoreResults(key)
|
||||||
}
|
}
|
||||||
@@ -460,6 +776,7 @@ export class Online {
|
|||||||
throw new Error('Сначала выберите другой пакет или тестовый режим')
|
throw new Error('Сначала выберите другой пакет или тестовый режим')
|
||||||
await this.packages.remove(key)
|
await this.packages.remove(key)
|
||||||
await rm(join(this.directory, `results-${key}.json`), { force: true })
|
await rm(join(this.directory, `results-${key}.json`), { force: true })
|
||||||
|
await rm(join(this.directory, `plan-${key}.json`), { force: true })
|
||||||
this.state.packagesRevision++
|
this.state.packagesRevision++
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -579,6 +896,9 @@ export class Online {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async poll() {
|
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 s = this.screen
|
||||||
const session = this.controller.state.session
|
const session = this.controller.state.session
|
||||||
if (!s || !this.controller.state.outputOpen) {
|
if (!s || !this.controller.state.outputOpen) {
|
||||||
@@ -603,7 +923,8 @@ export class Online {
|
|||||||
if (
|
if (
|
||||||
generation !== this.generation ||
|
generation !== this.generation ||
|
||||||
session !== this.controller.state.session ||
|
session !== this.controller.state.session ||
|
||||||
!this.controller.state.outputOpen
|
!this.controller.state.outputOpen ||
|
||||||
|
planVersion !== this.planVersion
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
let next = parseRemote(result.state)
|
let next = parseRemote(result.state)
|
||||||
@@ -615,6 +936,15 @@ export class Online {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const previous = this.previous
|
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)
|
if (previous && next.version < previous.version)
|
||||||
next = { ...previous, sound: next.sound, music: next.music }
|
next = { ...previous, sound: next.sound, music: next.music }
|
||||||
if (previous?.music && next.music && next.music.version < previous.music.version)
|
if (previous?.music && next.music && next.music.version < previous.music.version)
|
||||||
@@ -630,6 +960,8 @@ export class Online {
|
|||||||
if (previous) {
|
if (previous) {
|
||||||
try {
|
try {
|
||||||
applyRemote(this.controller, previous, next)
|
applyRemote(this.controller, previous, next)
|
||||||
|
if (next.version > previous.version && next.command === 0)
|
||||||
|
this.controller.markStep(this.plan.currentStep)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.state.error = error instanceof Error ? error.message : 'Команда сервера не выполнена'
|
this.state.error = error instanceof Error ? error.message : 'Команда сервера не выполнена'
|
||||||
}
|
}
|
||||||
@@ -640,7 +972,12 @@ export class Online {
|
|||||||
error instanceof Error ? error.message : 'Не удалось обновить результаты'
|
error instanceof Error ? error.message : 'Не удалось обновить результаты'
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (generation !== this.generation || session !== this.controller.state.session) return
|
if (
|
||||||
|
generation !== this.generation ||
|
||||||
|
session !== this.controller.state.session ||
|
||||||
|
planVersion !== this.planVersion
|
||||||
|
)
|
||||||
|
return
|
||||||
this.previous = null
|
this.previous = null
|
||||||
if (error instanceof ApiError && error.status === 401) {
|
if (error instanceof ApiError && error.status === 401) {
|
||||||
this.detach()
|
this.detach()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { contextBridge, ipcRenderer } from 'electron'
|
|||||||
import type { Command, Report, SceneReport, State } from '../src/types/screen.js'
|
import type { Command, Report, SceneReport, State } from '../src/types/screen.js'
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('screenBridge', {
|
contextBridge.exposeInMainWorld('screenBridge', {
|
||||||
|
planStatus: () => ipcRenderer.invoke('plan-status'),
|
||||||
onlineState: () => ipcRenderer.invoke('online-state'),
|
onlineState: () => ipcRenderer.invoke('online-state'),
|
||||||
onlineAction: (intent: OnlineIntent) => ipcRenderer.invoke('online-action', intent),
|
onlineAction: (intent: OnlineIntent) => ipcRenderer.invoke('online-action', intent),
|
||||||
closeOutput: () => ipcRenderer.invoke('close-output'),
|
closeOutput: () => ipcRenderer.invoke('close-output'),
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { parsePlan } from './screen-plan.js'
|
||||||
|
|
||||||
|
test('план: режимы protobuf, значения по умолчанию и UTF-8 ограничения сервера', () => {
|
||||||
|
assert.deepEqual(parsePlan(null), { steps: [], currentStep: 0 })
|
||||||
|
assert.equal(
|
||||||
|
parsePlan({ steps: [{ mode: 'SCREEN_MODE_TIMER', timerDurationSeconds: '60' }] }).steps[0]
|
||||||
|
?.timerLabel,
|
||||||
|
'Перерыв',
|
||||||
|
)
|
||||||
|
for (const value of [
|
||||||
|
{ steps: {} },
|
||||||
|
{ steps: [{ mode: 0 }] },
|
||||||
|
{ steps: [{ mode: 9 }] },
|
||||||
|
{ steps: [{ mode: 8, text: ' ' }] },
|
||||||
|
{ steps: [{ mode: 8, text: 'Я'.repeat(5001) }] },
|
||||||
|
{ steps: [{ mode: 7, timerDurationSeconds: 0 }] },
|
||||||
|
{ steps: [{ mode: 7, timerDurationSeconds: 86401 }] },
|
||||||
|
{ steps: [{ mode: 7, timerDurationSeconds: 1, timerLabel: 'Я'.repeat(101) }] },
|
||||||
|
{ steps: [{ mode: 1 }], currentStep: 1 },
|
||||||
|
])
|
||||||
|
assert.throws(() => parsePlan(value))
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { ScreenMode, ScreenPlan } from '../src/types/screen.js'
|
||||||
|
import { integer, object, text } from './remote-state.js'
|
||||||
|
|
||||||
|
const modes = [
|
||||||
|
'UNSPECIFIED',
|
||||||
|
'COVER',
|
||||||
|
'INTRO',
|
||||||
|
'INTRO_DONE',
|
||||||
|
'CONCLUSION',
|
||||||
|
'CONCLUSION_DONE',
|
||||||
|
'RESULTS',
|
||||||
|
'TIMER',
|
||||||
|
'TEXT',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function parsePlan(value: unknown): ScreenPlan {
|
||||||
|
const plan = value == null ? {} : object(value)
|
||||||
|
const list = plan.steps ?? []
|
||||||
|
if (!Array.isArray(list)) throw new Error('Некорректные шаги плана')
|
||||||
|
const steps = list.map((value) => {
|
||||||
|
const step = object(value)
|
||||||
|
const mode = integer(
|
||||||
|
typeof step.mode === 'string' && step.mode.startsWith('SCREEN_MODE_')
|
||||||
|
? modes.indexOf(step.mode.slice(12))
|
||||||
|
: step.mode,
|
||||||
|
1,
|
||||||
|
8,
|
||||||
|
) as ScreenMode
|
||||||
|
const content = text(step.text ?? '').trim()
|
||||||
|
const label = text(step.timerLabel ?? '').trim()
|
||||||
|
const seconds = integer(step.timerDurationSeconds ?? 0, 0, 86400)
|
||||||
|
if (mode === 8 && (!content || Buffer.byteLength(content) > 10000))
|
||||||
|
throw new Error('Текст шага должен содержать от 1 до 10000 байт')
|
||||||
|
if (mode === 7 && (!seconds || Buffer.byteLength(label) > 200))
|
||||||
|
throw new Error('Укажите таймер от 1 до 86400 секунд и подпись до 200 байт')
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
text: content,
|
||||||
|
timerLabel: mode === 7 ? label || 'Перерыв' : label,
|
||||||
|
timerDurationSeconds: seconds,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { steps, currentStep: integer(plan.currentStep ?? 0, 0, Math.max(0, steps.length - 1)) }
|
||||||
|
}
|
||||||
+149
-3
@@ -16,6 +16,13 @@ module.exports = async function onlineSmoke({
|
|||||||
let offline = false
|
let offline = false
|
||||||
let polls = 0
|
let polls = 0
|
||||||
let currentCommand = 'SCREEN_COMMAND_PLAY'
|
let currentCommand = 'SCREEN_COMMAND_PLAY'
|
||||||
|
let plan = { steps: [{ mode: 2 }], currentStep: 0 }
|
||||||
|
let planSaveFailure = false
|
||||||
|
let remoteMode = 2
|
||||||
|
let remoteText = ''
|
||||||
|
let timerLabel = 'Перерыв'
|
||||||
|
let timerDurationSeconds = 600
|
||||||
|
let timerState = 1
|
||||||
const requests = []
|
const requests = []
|
||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
const chunks = []
|
const chunks = []
|
||||||
@@ -42,7 +49,36 @@ module.exports = async function onlineSmoke({
|
|||||||
scenario: { story: { introduction: { audio: 'intro.wav' } } },
|
scenario: { story: { introduction: { audio: 'intro.wav' } } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (path === '/api/games/6/screen-plan') return send({ plan: { steps: [{ mode: 2 }] } })
|
if (path === '/api/games/6/screen-plan') {
|
||||||
|
if (req.method === 'PUT') {
|
||||||
|
assert.equal(req.headers.authorization, 'Bearer TEST-ACCESS')
|
||||||
|
if (planSaveFailure) return send({}, 403)
|
||||||
|
plan = body
|
||||||
|
}
|
||||||
|
return send({ plan: plan.steps.length ? plan : null })
|
||||||
|
}
|
||||||
|
if (path === '/api/games/6/screen-plan/step') {
|
||||||
|
assert.equal(req.headers.authorization, 'Bearer TEST-ACCESS')
|
||||||
|
const step = plan.steps[body.currentStep]
|
||||||
|
if (!step) return send({}, 400)
|
||||||
|
plan.currentStep = body.currentStep
|
||||||
|
remoteMode = step.mode
|
||||||
|
remoteText = step.text || ''
|
||||||
|
timerLabel = step.timerLabel || 'Перерыв'
|
||||||
|
timerDurationSeconds = step.timerDurationSeconds || 600
|
||||||
|
timerState = 1
|
||||||
|
currentCommand = 0
|
||||||
|
version++
|
||||||
|
return send({ plan })
|
||||||
|
}
|
||||||
|
if (path === '/api/games/6/screens/command') {
|
||||||
|
currentCommand = body.command
|
||||||
|
if (body.command === 8 || body.command === 10) timerState = 2
|
||||||
|
if (body.command === 9) timerState = 3
|
||||||
|
if (body.command === 11) timerState = 1
|
||||||
|
version++
|
||||||
|
return send({})
|
||||||
|
}
|
||||||
if (path === '/api/games/6/music') return send({})
|
if (path === '/api/games/6/music') return send({})
|
||||||
if (path === '/api/files/intro.wav') {
|
if (path === '/api/files/intro.wav') {
|
||||||
assert.equal(req.headers.authorization, undefined)
|
assert.equal(req.headers.authorization, undefined)
|
||||||
@@ -59,7 +95,12 @@ module.exports = async function onlineSmoke({
|
|||||||
return send({
|
return send({
|
||||||
state: {
|
state: {
|
||||||
gameID: 6,
|
gameID: 6,
|
||||||
mode: 'SCREEN_MODE_INTRO',
|
mode: remoteMode,
|
||||||
|
text: remoteText,
|
||||||
|
timerLabel,
|
||||||
|
timerDurationSeconds,
|
||||||
|
timerRemainingSeconds: timerDurationSeconds,
|
||||||
|
timerState,
|
||||||
soundEnabled: true,
|
soundEnabled: true,
|
||||||
volume: 80,
|
volume: 80,
|
||||||
stateVersion: String(version),
|
stateVersion: String(version),
|
||||||
@@ -209,12 +250,117 @@ module.exports = async function onlineSmoke({
|
|||||||
await act({ action: 'results' })
|
await act({ action: 'results' })
|
||||||
assert.equal((await state()).results.teams[0].name, 'Детективы')
|
assert.equal((await state()).results.teams[0].name, 'Детективы')
|
||||||
assert.equal(JSON.stringify(await state()).includes('PRIVATE'), false)
|
assert.equal(JSON.stringify(await state()).includes('PRIVATE'), false)
|
||||||
for (const method of ['onlineState()', "onlineAction({action:'logout'})", 'closeOutput()']) {
|
for (const method of [
|
||||||
|
'planStatus()',
|
||||||
|
'onlineState()',
|
||||||
|
"onlineAction({action:'logout'})",
|
||||||
|
'closeOutput()',
|
||||||
|
]) {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await inspect(`window.screenBridge.${method}.then(() => false, () => true)`),
|
await inspect(`window.screenBridge.${method}.then(() => false, () => true)`),
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Настоящий редактор: черновик, ошибка сохранения, порядок и переходы.
|
||||||
|
const clickPlan = async (label) => {
|
||||||
|
await poll(
|
||||||
|
() =>
|
||||||
|
evaluate(`(() => {
|
||||||
|
const button = Array.from(document.querySelectorAll('[data-testid="screen-plan"] button'))
|
||||||
|
.find(b => b.textContent.trim() === ${JSON.stringify(label)})
|
||||||
|
if (!button || button.disabled) return false
|
||||||
|
button.click(); return true
|
||||||
|
})()`),
|
||||||
|
`plan button ${label}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const setInput = (selector, value) =>
|
||||||
|
evaluate(`(() => {
|
||||||
|
const input = document.querySelector(${JSON.stringify(selector)})
|
||||||
|
input.focus()
|
||||||
|
input.value = ${JSON.stringify(value)}
|
||||||
|
input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' }))
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||||
|
input.blur()
|
||||||
|
})()`)
|
||||||
|
await clickPlan('Редактировать')
|
||||||
|
await clickPlan('Добавить шаг')
|
||||||
|
await setInput('[data-testid="screen-plan"] textarea', 'Текст из редактора')
|
||||||
|
await clickPlan('Добавить шаг')
|
||||||
|
await evaluate(
|
||||||
|
`Array.from(document.querySelectorAll('[data-testid="screen-plan"] .n-base-selection')).at(-1).click()`,
|
||||||
|
)
|
||||||
|
await poll(
|
||||||
|
() =>
|
||||||
|
evaluate(
|
||||||
|
`Array.from(document.querySelectorAll('.n-base-select-option')).some(e => e.textContent.trim() === 'Таймер')`,
|
||||||
|
),
|
||||||
|
'timer option',
|
||||||
|
)
|
||||||
|
await evaluate(
|
||||||
|
`Array.from(document.querySelectorAll('.n-base-select-option')).find(e => e.textContent.trim() === 'Таймер').click()`,
|
||||||
|
)
|
||||||
|
await setInput('#plan-label-2', 'Отдых')
|
||||||
|
await setInput('#plan-seconds-2', '60')
|
||||||
|
assert.equal((await state()).catalog.steps.length, 1, 'draft is not published')
|
||||||
|
const beforeSaveScene = (await state()).scene.revision
|
||||||
|
planSaveFailure = true
|
||||||
|
await clickPlan('Сохранить план')
|
||||||
|
await poll(
|
||||||
|
() => evaluate(`document.body.textContent.includes('Нет доступа к действию')`),
|
||||||
|
'save failure shown',
|
||||||
|
)
|
||||||
|
assert.equal((await state()).catalog.steps.length, 1)
|
||||||
|
assert.ok(await evaluate(`!!document.querySelector('[data-testid="screen-plan"] textarea')`))
|
||||||
|
planSaveFailure = false
|
||||||
|
await clickPlan('Сохранить план')
|
||||||
|
await poll(async () => (await state()).catalog.steps.length === 3, 'plan saved')
|
||||||
|
assert.equal((await state()).scene.revision, beforeSaveScene)
|
||||||
|
assert.equal(plan.steps[1].text, 'Текст из редактора')
|
||||||
|
assert.equal(plan.steps[2].timerDurationSeconds, 60)
|
||||||
|
await evaluate(`document.querySelector('[data-step-index="1"] button').click()`)
|
||||||
|
await poll(
|
||||||
|
async () =>
|
||||||
|
(await state()).scene.text === 'Текст из редактора' &&
|
||||||
|
(await state()).scene.stepIndex === 1,
|
||||||
|
'show text step',
|
||||||
|
)
|
||||||
|
await evaluate(`document.querySelector('[data-step-index="2"] button').click()`)
|
||||||
|
await poll(async () => (await state()).scene.stepIndex === 2, 'show timer step')
|
||||||
|
assert.equal((await state()).timer.status, 'stopped')
|
||||||
|
assert.equal((await state()).channels.main.desired, 'stopped')
|
||||||
|
await clickPlan('Старт')
|
||||||
|
await poll(async () => (await state()).timer.status === 'running', 'plan timer start')
|
||||||
|
await clickPlan('Пауза')
|
||||||
|
await poll(async () => (await state()).timer.status === 'paused', 'plan timer pause')
|
||||||
|
await clickPlan('Редактировать')
|
||||||
|
await evaluate(`document.querySelector('[aria-label="Поднять шаг 3"]').click()`)
|
||||||
|
await clickPlan('Сохранить план')
|
||||||
|
await poll(async () => (await state()).catalog.steps[1].mode === 7, 'reorder plan saved')
|
||||||
|
assert.equal((await state()).scene.stepIndex, null, 'edited index is not highlighted')
|
||||||
|
await clickPlan('Редактировать')
|
||||||
|
await setInput('[data-testid="screen-plan"] textarea', 'Несохранённый черновик')
|
||||||
|
await clickPlan('Отменить')
|
||||||
|
await poll(
|
||||||
|
() =>
|
||||||
|
evaluate(
|
||||||
|
`Array.from(document.querySelectorAll('button')).some(b => b.textContent.trim() === 'Отменить изменения')`,
|
||||||
|
),
|
||||||
|
'discard dialog',
|
||||||
|
)
|
||||||
|
await evaluate(
|
||||||
|
`Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Отменить изменения').click()`,
|
||||||
|
)
|
||||||
|
await poll(
|
||||||
|
() => evaluate(`!document.querySelector('[data-testid="screen-plan"] textarea')`),
|
||||||
|
'discarded draft',
|
||||||
|
)
|
||||||
|
assert.equal((await state()).catalog.steps[2].text, 'Текст из редактора')
|
||||||
|
await clickPlan('Редактировать')
|
||||||
|
await clickPlan('Удалить план')
|
||||||
|
await clickPlan('Сохранить план')
|
||||||
|
await poll(async () => (await state()).catalog.steps.length === 0, 'plan deleted')
|
||||||
|
console.log('SCREEN_PLAN_SMOKE_PASS')
|
||||||
await act({ action: 'logout' })
|
await act({ action: 'logout' })
|
||||||
assert.equal((await evaluate('window.screenBridge.onlineState()')).authenticated, false)
|
assert.equal((await evaluate('window.screenBridge.onlineState()')).authenticated, false)
|
||||||
console.log('SCREEN_ONLINE_SMOKE_PASS', JSON.stringify({ polls, requests: requests.length }))
|
console.log('SCREEN_ONLINE_SMOKE_PASS', JSON.stringify({ polls, requests: requests.length }))
|
||||||
|
|||||||
+4
-2
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NConfigProvider, NMessageProvider, darkTheme } from 'naive-ui'
|
import { NConfigProvider, NDialogProvider, NMessageProvider, darkTheme } from 'naive-ui'
|
||||||
import ControlPage from './components/ControlPage.vue'
|
import ControlPage from './components/ControlPage.vue'
|
||||||
import OutputPage from './components/OutputPage.vue'
|
import OutputPage from './components/OutputPage.vue'
|
||||||
|
|
||||||
@@ -11,7 +11,9 @@ const isOutput = new URLSearchParams(location.search).has('output')
|
|||||||
<n-config-provider :theme="darkTheme">
|
<n-config-provider :theme="darkTheme">
|
||||||
<n-message-provider>
|
<n-message-provider>
|
||||||
<OutputPage v-if="isOutput && hasBridge" />
|
<OutputPage v-if="isOutput && hasBridge" />
|
||||||
<ControlPage v-else />
|
<n-dialog-provider v-else>
|
||||||
|
<ControlPage />
|
||||||
|
</n-dialog-provider>
|
||||||
</n-message-provider>
|
</n-message-provider>
|
||||||
</n-config-provider>
|
</n-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
NSpace,
|
NSpace,
|
||||||
NSwitch,
|
NSwitch,
|
||||||
useMessage,
|
useMessage,
|
||||||
|
useDialog,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import type {
|
import type {
|
||||||
@@ -20,13 +21,23 @@ import type {
|
|||||||
PreviewFrame,
|
PreviewFrame,
|
||||||
ScreenMode,
|
ScreenMode,
|
||||||
State,
|
State,
|
||||||
|
ScreenStep,
|
||||||
|
PlanStatus,
|
||||||
} from '../types/screen'
|
} from '../types/screen'
|
||||||
|
|
||||||
import ConnectionPanel from './ConnectionPanel.vue'
|
import ConnectionPanel from './ConnectionPanel.vue'
|
||||||
|
import ScreenPlanPanel from './ScreenPlanPanel.vue'
|
||||||
import { modeLabels } from '../utils/presentation'
|
import { modeLabels } from '../utils/presentation'
|
||||||
import { useScreenTimer } from '../composables/useScreenTimer'
|
import { useScreenTimer } from '../composables/useScreenTimer'
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const planStatus = ref<PlanStatus>({ key: null, editable: false, notice: '' })
|
||||||
|
const isPlanEditing = ref(false)
|
||||||
|
const isPlanSaving = ref(false)
|
||||||
|
const planDraft = ref<ScreenStep[]>([])
|
||||||
|
const originalPlan = ref('')
|
||||||
|
const isPlanDirty = computed(() => JSON.stringify(planDraft.value) !== originalPlan.value)
|
||||||
const bridge = window.screenBridge
|
const bridge = window.screenBridge
|
||||||
const state = ref<State | null>(null)
|
const state = ref<State | null>(null)
|
||||||
const packages = ref<PackageSummary[]>([])
|
const packages = ref<PackageSummary[]>([])
|
||||||
@@ -54,12 +65,6 @@ const previewURL = computed(() =>
|
|||||||
? preview.value?.dataURL
|
? preview.value?.dataURL
|
||||||
: null,
|
: null,
|
||||||
)
|
)
|
||||||
const stepOptions = computed(() =>
|
|
||||||
(state.value?.catalog.steps ?? []).map((step, index) => ({
|
|
||||||
label: `${index + 1}. ${modeLabels[step.mode]}${step.mode === 8 ? ` · ${step.text.slice(0, 60)}` : ''}`,
|
|
||||||
value: index,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
const sceneLabels = {
|
const sceneLabels = {
|
||||||
idle: 'Окно показа закрыто',
|
idle: 'Окно показа закрыто',
|
||||||
pending: 'Подготовка показа',
|
pending: 'Подготовка показа',
|
||||||
@@ -122,8 +127,60 @@ let timer: ReturnType<typeof setInterval> | undefined
|
|||||||
let unsubscribe: (() => void) | undefined
|
let unsubscribe: (() => void) | undefined
|
||||||
let session = ''
|
let session = ''
|
||||||
let sequence = 0
|
let sequence = 0
|
||||||
|
function editPlan() {
|
||||||
|
planDraft.value = state.value?.catalog.steps.map((step) => ({ ...step })) ?? []
|
||||||
|
originalPlan.value = JSON.stringify(planDraft.value)
|
||||||
|
isPlanEditing.value = true
|
||||||
|
if (!planDraft.value.length) addPlanStep()
|
||||||
|
}
|
||||||
|
function addPlanStep() {
|
||||||
|
planDraft.value.push({
|
||||||
|
mode: 8,
|
||||||
|
text: 'Новый экран',
|
||||||
|
timerLabel: 'Перерыв',
|
||||||
|
timerDurationSeconds: 600,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function movePlanStep(index: number, offset: number) {
|
||||||
|
const target = index + offset
|
||||||
|
if (target < 0 || target >= planDraft.value.length) return
|
||||||
|
const [step] = planDraft.value.splice(index, 1)
|
||||||
|
if (step) planDraft.value.splice(target, 0, step)
|
||||||
|
}
|
||||||
|
function discardPlan(action: () => void) {
|
||||||
|
if (!isPlanEditing.value || !isPlanDirty.value) return action()
|
||||||
|
dialog.warning({
|
||||||
|
title: 'Несохранённые изменения',
|
||||||
|
content: 'Отменить несохранённые изменения плана?',
|
||||||
|
positiveText: 'Отменить изменения',
|
||||||
|
negativeText: 'Продолжить редактирование',
|
||||||
|
onPositiveClick: action,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function savePlan() {
|
||||||
|
if (!bridge || !state.value?.catalog.key || isPlanSaving.value) return
|
||||||
|
const key = state.value.catalog.key
|
||||||
|
isPlanSaving.value = true
|
||||||
|
try {
|
||||||
|
await bridge.onlineAction({
|
||||||
|
action: 'plan-save',
|
||||||
|
key,
|
||||||
|
steps: planDraft.value.map((step) => ({ ...step })),
|
||||||
|
})
|
||||||
|
if (state.value.catalog.key === key) isPlanEditing.value = false
|
||||||
|
message.success('План экранов сохранён')
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : 'Не удалось сохранить план')
|
||||||
|
} finally {
|
||||||
|
isPlanSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
function acceptState(value: State) {
|
function acceptState(value: State) {
|
||||||
if (state.value && value.revision < state.value.revision) return
|
if (state.value && value.revision < state.value.revision) return
|
||||||
|
if (value.catalog.key !== state.value?.catalog.key) {
|
||||||
|
isPlanEditing.value = false
|
||||||
|
planDraft.value = []
|
||||||
|
}
|
||||||
if (value.session !== state.value?.session || value.scene.text !== state.value?.scene.text)
|
if (value.session !== state.value?.session || value.scene.text !== state.value?.scene.text)
|
||||||
textDraft.value = value.scene.text
|
textDraft.value = value.scene.text
|
||||||
if (
|
if (
|
||||||
@@ -164,7 +221,12 @@ async function send(intent: Intent) {
|
|||||||
message.error(error instanceof Error ? error.message : 'Команда не выполнена')
|
message.error(error instanceof Error ? error.message : 'Команда не выполнена')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function selectPackage(key: string) {
|
function selectPackage(key: string) {
|
||||||
|
discardPlan(() => {
|
||||||
|
void changePackage(key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function changePackage(key: string) {
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
if (!bridge) return
|
if (!bridge) return
|
||||||
try {
|
try {
|
||||||
@@ -186,6 +248,7 @@ async function removePackage(key: string) {
|
|||||||
async function refreshHost() {
|
async function refreshHost() {
|
||||||
if (!bridge) return
|
if (!bridge) return
|
||||||
host.value = await bridge.host()
|
host.value = await bridge.host()
|
||||||
|
planStatus.value = await bridge.planStatus()
|
||||||
if (display.value === null) display.value = host.value.preferredDisplayId
|
if (display.value === null) display.value = host.value.preferredDisplayId
|
||||||
if (!host.value.displays.some((d) => d.id === display.value)) display.value = null
|
if (!host.value.displays.some((d) => d.id === display.value)) display.value = null
|
||||||
}
|
}
|
||||||
@@ -244,7 +307,7 @@ onUnmounted(() => {
|
|||||||
<n-select
|
<n-select
|
||||||
:value="state?.catalog.key ?? 'fixtures'"
|
:value="state?.catalog.key ?? 'fixtures'"
|
||||||
:options="packageOptions"
|
:options="packageOptions"
|
||||||
:disabled="!bridge || isBusy"
|
:disabled="!bridge || isBusy || isPlanSaving"
|
||||||
:input-props="{ 'aria-label': 'Подготовленный пакет' }"
|
:input-props="{ 'aria-label': 'Подготовленный пакет' }"
|
||||||
@update:value="selectPackage"
|
@update:value="selectPackage"
|
||||||
/>
|
/>
|
||||||
@@ -298,35 +361,30 @@ onUnmounted(() => {
|
|||||||
>Откройте показ, чтобы подготовить медиа и включить воспроизведение.</n-alert
|
>Откройте показ, чтобы подготовить медиа и включить воспроизведение.</n-alert
|
||||||
>
|
>
|
||||||
<section class="presentation-grid">
|
<section class="presentation-grid">
|
||||||
<n-card title="План показа">
|
<n-card title="План экранов">
|
||||||
<n-space vertical>
|
<n-space vertical>
|
||||||
<n-select
|
<ScreenPlanPanel
|
||||||
:value="state.scene.stepIndex"
|
:steps="state.catalog.steps"
|
||||||
:options="stepOptions"
|
:current-index="state.scene.stepIndex"
|
||||||
:disabled="isBusy || !state.catalog.steps.length"
|
:draft="planDraft"
|
||||||
placeholder="Выберите шаг"
|
:is-editing="isPlanEditing"
|
||||||
:input-props="{ 'aria-label': 'Шаг плана' }"
|
:is-busy="isBusy || isPlanSaving"
|
||||||
@update:value="send({ action: 'step', index: $event })"
|
:can-edit="planStatus.key === state.catalog.key && planStatus.editable"
|
||||||
|
:notice="planStatus.key === state.catalog.key ? planStatus.notice : ''"
|
||||||
|
@edit="editPlan"
|
||||||
|
@cancel="
|
||||||
|
discardPlan(() => {
|
||||||
|
isPlanEditing = false
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@save="savePlan"
|
||||||
|
@add="addPlanStep"
|
||||||
|
@remove="planDraft.splice($event, 1)"
|
||||||
|
@move="movePlanStep"
|
||||||
|
@clear="planDraft = []"
|
||||||
|
@update-step="(index, patch) => Object.assign(planDraft[index]!, patch)"
|
||||||
|
@command="send"
|
||||||
/>
|
/>
|
||||||
<p v-if="!state.catalog.steps.length">
|
|
||||||
В пакете нет плана. Используйте переключатель режимов.
|
|
||||||
</p>
|
|
||||||
<n-space>
|
|
||||||
<n-button
|
|
||||||
:disabled="isBusy || state.scene.stepIndex === null || state.scene.stepIndex <= 0"
|
|
||||||
@click="send({ action: 'step', index: (state.scene.stepIndex ?? 0) - 1 })"
|
|
||||||
>Предыдущий шаг</n-button
|
|
||||||
>
|
|
||||||
<n-button
|
|
||||||
:disabled="
|
|
||||||
isBusy ||
|
|
||||||
!state.catalog.steps.length ||
|
|
||||||
(state.scene.stepIndex ?? -1) >= state.catalog.steps.length - 1
|
|
||||||
"
|
|
||||||
@click="send({ action: 'step', index: (state.scene.stepIndex ?? -1) + 1 })"
|
|
||||||
>Следующий шаг</n-button
|
|
||||||
>
|
|
||||||
</n-space>
|
|
||||||
<label>Режим показа</label>
|
<label>Режим показа</label>
|
||||||
<n-select
|
<n-select
|
||||||
:value="state.scene.mode"
|
:value="state.scene.mode"
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NButton, NInput, NInputNumber, NSelect, NSpace } from 'naive-ui'
|
||||||
|
import type { Intent, ScreenMode, ScreenStep } from '../types/screen'
|
||||||
|
import { modeLabels } from '../utils/presentation'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
steps: ScreenStep[]
|
||||||
|
currentIndex: number | null
|
||||||
|
draft: ScreenStep[]
|
||||||
|
isEditing: boolean
|
||||||
|
isBusy: boolean
|
||||||
|
canEdit: boolean
|
||||||
|
notice: string
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
edit: []
|
||||||
|
cancel: []
|
||||||
|
save: []
|
||||||
|
add: []
|
||||||
|
remove: [index: number]
|
||||||
|
move: [index: number, offset: number]
|
||||||
|
clear: []
|
||||||
|
updateStep: [index: number, patch: Partial<ScreenStep>]
|
||||||
|
command: [intent: Intent]
|
||||||
|
}>()
|
||||||
|
const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
|
||||||
|
value: Number(mode) as ScreenMode,
|
||||||
|
label,
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-space vertical data-testid="screen-plan">
|
||||||
|
<n-space>
|
||||||
|
<n-button v-if="!isEditing" :disabled="isBusy || !canEdit" @click="emit('edit')">
|
||||||
|
{{ steps.length ? 'Редактировать' : 'Создать план' }}
|
||||||
|
</n-button>
|
||||||
|
<template v-else>
|
||||||
|
<n-button :disabled="isBusy" @click="emit('cancel')">Отменить</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="isBusy || !canEdit"
|
||||||
|
:loading="isBusy"
|
||||||
|
@click="emit('save')"
|
||||||
|
>
|
||||||
|
Сохранить план
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
</n-space>
|
||||||
|
<n-alert v-if="notice" type="warning" role="status">{{ notice }}</n-alert>
|
||||||
|
<p v-if="!canEdit">Для редактирования войдите на сервер выбранной подготовленной игры.</p>
|
||||||
|
<template v-if="isEditing">
|
||||||
|
<n-space v-for="(step, index) in draft" :key="index" vertical class="plan-step">
|
||||||
|
<span>Шаг {{ index + 1 }}</span>
|
||||||
|
<n-select
|
||||||
|
:value="step.mode"
|
||||||
|
:options="modeOptions"
|
||||||
|
:disabled="isBusy"
|
||||||
|
:input-props="{ 'aria-label': `Режим шага ${index + 1}` }"
|
||||||
|
@update:value="emit('updateStep', index, { mode: $event })"
|
||||||
|
/>
|
||||||
|
<n-input
|
||||||
|
v-if="step.mode === 8"
|
||||||
|
:value="step.text"
|
||||||
|
type="textarea"
|
||||||
|
:disabled="isBusy"
|
||||||
|
placeholder="Текст экрана"
|
||||||
|
:input-props="{ 'aria-label': `Текст шага ${index + 1}` }"
|
||||||
|
@update:value="emit('updateStep', index, { text: $event })"
|
||||||
|
/>
|
||||||
|
<template v-if="step.mode === 7">
|
||||||
|
<label :for="`plan-label-${index}`">Надпись таймера</label>
|
||||||
|
<n-input
|
||||||
|
:value="step.timerLabel"
|
||||||
|
:disabled="isBusy"
|
||||||
|
:input-props="{ id: `plan-label-${index}` }"
|
||||||
|
@update:value="emit('updateStep', index, { timerLabel: $event })"
|
||||||
|
/>
|
||||||
|
<label :for="`plan-seconds-${index}`">Длительность в секундах</label>
|
||||||
|
<n-input-number
|
||||||
|
:value="step.timerDurationSeconds"
|
||||||
|
:min="1"
|
||||||
|
:max="86400"
|
||||||
|
:disabled="isBusy"
|
||||||
|
:input-props="{ id: `plan-seconds-${index}` }"
|
||||||
|
@update:value="emit('updateStep', index, { timerDurationSeconds: $event ?? 0 })"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<n-space>
|
||||||
|
<n-button
|
||||||
|
:disabled="isBusy || index === 0"
|
||||||
|
:aria-label="`Поднять шаг ${index + 1}`"
|
||||||
|
@click="emit('move', index, -1)"
|
||||||
|
>↑</n-button
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
:disabled="isBusy || index === draft.length - 1"
|
||||||
|
:aria-label="`Опустить шаг ${index + 1}`"
|
||||||
|
@click="emit('move', index, 1)"
|
||||||
|
>↓</n-button
|
||||||
|
>
|
||||||
|
<n-button :disabled="isBusy" type="error" ghost @click="emit('remove', index)"
|
||||||
|
>Удалить</n-button
|
||||||
|
>
|
||||||
|
</n-space>
|
||||||
|
</n-space>
|
||||||
|
<n-space>
|
||||||
|
<n-button :disabled="isBusy" @click="emit('add')">Добавить шаг</n-button>
|
||||||
|
<n-button :disabled="isBusy || !draft.length" @click="emit('clear')">Удалить план</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p v-if="!steps.length">План экранов ещё не создан. Используйте переключатель режимов.</p>
|
||||||
|
<n-space
|
||||||
|
v-for="(step, index) in steps"
|
||||||
|
:key="index"
|
||||||
|
vertical
|
||||||
|
class="plan-step"
|
||||||
|
:class="{ 'plan-step-current': currentIndex === index }"
|
||||||
|
:data-step-index="index"
|
||||||
|
:aria-current="currentIndex === index ? 'step' : undefined"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
>{{ index + 1 }}. {{ modeLabels[step.mode]
|
||||||
|
}}<template v-if="step.mode === 8">: {{ step.text }}</template
|
||||||
|
><template v-if="step.mode === 7"
|
||||||
|
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
|
||||||
|
></span
|
||||||
|
>
|
||||||
|
<n-space>
|
||||||
|
<n-button
|
||||||
|
:type="currentIndex === index ? 'primary' : 'default'"
|
||||||
|
:disabled="isBusy || currentIndex === index"
|
||||||
|
@click="emit('command', { action: 'step', index })"
|
||||||
|
>{{ currentIndex === index ? 'Показан' : 'Показать' }}</n-button
|
||||||
|
>
|
||||||
|
<template v-if="currentIndex === index && step.mode === 7">
|
||||||
|
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-start' })"
|
||||||
|
>Старт</n-button
|
||||||
|
>
|
||||||
|
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-pause' })"
|
||||||
|
>Пауза</n-button
|
||||||
|
>
|
||||||
|
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-resume' })"
|
||||||
|
>Продолжить</n-button
|
||||||
|
>
|
||||||
|
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-reset' })"
|
||||||
|
>Сбросить</n-button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<template v-if="currentIndex === index && (step.mode === 2 || step.mode === 4)">
|
||||||
|
<n-button
|
||||||
|
:disabled="isBusy"
|
||||||
|
@click="emit('command', { action: 'play', channel: 'main' })"
|
||||||
|
>Запуск</n-button
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
:disabled="isBusy"
|
||||||
|
@click="emit('command', { action: 'pause', channel: 'main' })"
|
||||||
|
>Пауза</n-button
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
:disabled="isBusy"
|
||||||
|
@click="emit('command', { action: 'resume', channel: 'main' })"
|
||||||
|
>Продолжить</n-button
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
:disabled="isBusy"
|
||||||
|
@click="emit('command', { action: 'stop', channel: 'main' })"
|
||||||
|
>Стоп</n-button
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
:disabled="isBusy"
|
||||||
|
@click="emit('command', { action: 'replay', channel: 'main' })"
|
||||||
|
>Повтор</n-button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</n-space>
|
||||||
|
</n-space>
|
||||||
|
<n-space>
|
||||||
|
<n-button
|
||||||
|
:disabled="isBusy || currentIndex === null || currentIndex <= 0"
|
||||||
|
@click="emit('command', { action: 'step', index: (currentIndex ?? 0) - 1 })"
|
||||||
|
>Предыдущий шаг</n-button
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
:disabled="isBusy || !steps.length || (currentIndex ?? -1) >= steps.length - 1"
|
||||||
|
@click="emit('command', { action: 'step', index: (currentIndex ?? -1) + 1 })"
|
||||||
|
>Следующий шаг</n-button
|
||||||
|
>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.plan-step {
|
||||||
|
border: 1px solid var(--brand-background);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-step-current {
|
||||||
|
border-color: var(--brand-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import type { ScreenStep } from './screen.js'
|
||||||
|
|
||||||
export type OnlineIntent =
|
export type OnlineIntent =
|
||||||
|
| { action: 'plan-save'; key: string; steps: ScreenStep[] }
|
||||||
|
| { action: 'plan-refresh' }
|
||||||
| { action: 'login'; server: string; email: string; password: string }
|
| { action: 'login'; server: string; email: string; password: string }
|
||||||
| { action: 'logout' | 'games' | 'connect' | 'disconnect' | 'sound' | 'results' | 'cancel' }
|
| { action: 'logout' | 'games' | 'connect' | 'disconnect' | 'sound' | 'results' | 'cancel' }
|
||||||
| { action: 'download' | 'check'; gameId: number }
|
| { action: 'download' | 'check'; gameId: number }
|
||||||
|
|||||||
@@ -135,7 +135,17 @@ export interface PackageSummary {
|
|||||||
version: string
|
version: string
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
export interface ScreenPlan {
|
||||||
|
steps: ScreenStep[]
|
||||||
|
currentStep: number
|
||||||
|
}
|
||||||
|
export interface PlanStatus {
|
||||||
|
key: string | null
|
||||||
|
editable: boolean
|
||||||
|
notice: string
|
||||||
|
}
|
||||||
export interface ScreenBridge {
|
export interface ScreenBridge {
|
||||||
|
planStatus: () => Promise<PlanStatus>
|
||||||
onlineState: () => Promise<OnlineState>
|
onlineState: () => Promise<OnlineState>
|
||||||
onlineAction: (intent: OnlineIntent) => Promise<OnlineState>
|
onlineAction: (intent: OnlineIntent) => Promise<OnlineState>
|
||||||
closeOutput: () => Promise<void>
|
closeOutput: () => Promise<void>
|
||||||
|
|||||||
Reference in New Issue
Block a user