up
This commit is contained in:
@@ -66,3 +66,44 @@
|
||||
UI загрузки/вход остаётся в SCREEN-006; хранилище предоставляет программный API.
|
||||
- Следующий шаг: SCREEN-004 — подключить подготовленные локальные ресурсы к
|
||||
проигрывателю и игровым командам без ожидания сети.
|
||||
|
||||
## SCREEN-005 — Пульт ведущего и все режимы показа
|
||||
|
||||
- Статус: `DONE`.
|
||||
- Дата: 2026-10-17.
|
||||
- Репозиторий: только screen; бэкенд, frontend сайта и protobuf не изменены,
|
||||
новых зависимостей нет. Разработка продолжена по запросу владельца;
|
||||
аппаратная приёмка SCREEN-004 остаётся открытой.
|
||||
- Результат: все восемь ScreenMode, локальные обложка/фон, сообщения завершения,
|
||||
текст без HTML, список шагов с предыдущим/следующим, редакторы черновиков текста
|
||||
и таймера. Выбор шага не запускает звук/таймер, уход из основного медиарежима
|
||||
останавливает основной канал; музыка остаётся независимой. Ошибки и кнопки
|
||||
остаются на пульте. Готовность подтверждается окном после подготовки сцены.
|
||||
- Таймер: монотонное время в main, точный остаток, пауза/продолжение/сброс,
|
||||
отсчёт за другой сценой/закрытым окном, сброс при смене пакета. Интервалы и
|
||||
медиаснимки не накапливают дрейф; окончание не запускает звук или другой режим.
|
||||
- Предпросмотр: выбранный владельцем вариант реального окна, capturePage до 2 FPS,
|
||||
ширина до 960 px, один текущий кадр, проверка прав и сессии/ревизии. Нет второго
|
||||
проигрывателя; при закрытии окна и скрытии/сворачивании пульта захват прекращается.
|
||||
- Результаты: локальный контракт ResultsSnapshot, whitelist публичных полей,
|
||||
проверка ключа игры/времени/чисел/повторов, время снимка на пульте, таблица с
|
||||
прокруткой. Реальное получение/сохранение остаётся в SCREEN-006; без снимка
|
||||
честное пустое состояние. Смена пакета очищает снимок.
|
||||
- Проверки: `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm test`
|
||||
(46 тестов, 9 новых), сборка и GUI smoke, `git diff --check` — PASS.
|
||||
Проверены все восемь шагов, локальные изображения, кириллица/системный шрифт,
|
||||
черновик текста/экранирование HTML, таймер за скрытой сценой, права IPC,
|
||||
предпросмотр (10 захватов, ограничение частоты, без аудио/видео в пульте),
|
||||
повреждение/восстановление картинки, таблица на 50 синтетических командах и
|
||||
сохранение её прокрутки при обновлениях. Фикстура результатов существует только
|
||||
в тестовом renderer; после пересоздания окна в main результатов по-прежнему нет.
|
||||
- Исправление по GUI: Chromium повторно использовал декодированную картинку после
|
||||
повреждения файла. Новый адрес изображения на каждую подготовку сцены устранил
|
||||
ложную готовность; повторный прогон проверил ошибку и восстановление.
|
||||
- Ограничения: текущий Linux x64/Electron 42.11.15, синтетический внешний дисплей,
|
||||
тестовые --no-sandbox --mute-audio --disable-gpu. Рабочий сервер, реальные игровые
|
||||
материалы и самостоятельный аппаратный/ручной проход владельца не проверялись.
|
||||
macOS/Windows и физический p95 на M1 не подтверждены; архивы не пересобирались.
|
||||
Инструкция ручной проверки — docs/SCREEN-005_ACCEPTANCE.md, запуск npm run acceptance.
|
||||
- Следующий шаг: SCREEN-006 — существующая авторизация, выбор игры/скачивание
|
||||
через интерфейс, снимок результатов и получение команд существующего сервера.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Вечерний детектив — приложение площадки
|
||||
|
||||
Статус: программная часть SCREEN-004 реализована; SCREEN-001 и SCREEN-004
|
||||
остаются IN_PROGRESS до аппаратной приёмки.
|
||||
Статус: SCREEN-005 реализована; SCREEN-001 и SCREEN-004 остаются IN_PROGRESS
|
||||
до аппаратной приёмки. Режимы показа и пульт работают с локальными пакетами.
|
||||
SCREEN-002 и SCREEN-003 завершены: описание пакета через существующий API,
|
||||
загрузка, проверка, восстановление и локальная выдача файлов.
|
||||
Управление с телефона идёт через существующий сервер сайта; desktop получает
|
||||
@@ -22,7 +22,8 @@ macOS Intel/Apple Silicon, Windows x64/ARM64 и Linux x64/ARM64.
|
||||
общего правила хранения задач в `../docs/agent`. Не дублировать их там.
|
||||
Идентификаторы `SCREEN-*` относятся только к этому приложению.
|
||||
|
||||
Текущий этап: SCREEN-004; программная часть готова, аппаратная проверка открыта.
|
||||
Следующий этап: SCREEN-006 — авторизация, загрузка через интерфейс и серверная
|
||||
синхронизация; аппаратная проверка SCREEN-004 остаётся открытой.
|
||||
Новые задачи: `Новая задача: <описание>`.
|
||||
Commit и push выполняет только пользователь. Вложенные `.git` не изменять.
|
||||
|
||||
@@ -44,8 +45,8 @@ npm run dev
|
||||
его и откройте показ. Аудио и видео — синтетические локальные файлы длительностью
|
||||
60 секунд; бинарники находятся в игнорируемом fixtures/ и не входят в Git.
|
||||
Тестовый режим сохраняет отдельный выбор WAV/видео; игровые пакеты выбираются
|
||||
в разделе «Материалы показа». Полные игровые
|
||||
режимы, интерфейс загрузки игр и авторизация пока не реализованы.
|
||||
в разделе «Материалы показа». Интерфейс загрузки игр и авторизация пока
|
||||
не реализованы; они относятся к SCREEN-006.
|
||||
|
||||
Управление телефоном через сервер, подключение игры и авторизация относятся к
|
||||
SCREEN-006 и в техническом стенде ещё не реализованы. Интернет нужен для получения
|
||||
@@ -179,5 +180,51 @@ URL, независимо от исходного имени файла. Лок
|
||||
`dist-electron`, из которого Electron загружает preload при открытии окна.
|
||||
Программные замеры в пульте разделены по каналу и текущему виду медиа; они
|
||||
не измеряют физический звук/первый кадр. Аппаратная приёмка, остальные ОС,
|
||||
реальные игровые материалы и p95 на M1 пока не подтверждены. Полные визуальные
|
||||
режимы остаются в SCREEN-005, авторизация и получение серверных команд — в SCREEN-006.
|
||||
реальные игровые материалы и p95 на M1 пока не подтверждены. Все восемь визуальных режимов доступны в SCREEN-005; авторизация и получение
|
||||
серверных команд остаются в SCREEN-006.
|
||||
|
||||
## Пульт и режимы SCREEN-005
|
||||
|
||||
Пульт включает готовый пакет, план шагов, переключатель восьми режимов, текст,
|
||||
таймер, предпросмотр и прежние независимые каналы медиа/музыки. План из пакета
|
||||
доступен только для чтения. Выбор шага применяет его параметры без автоматического
|
||||
запуска звука/таймера; окончание медиа не переключает шаг. Пуск основного ресурса
|
||||
показывает соответствующий режим вступления или видео. Уход в другую сцену
|
||||
останавливает основной канал, сохраняя независимую музыку.
|
||||
|
||||
Текст и параметры таймера редактируются как черновик; только «Показать текст» /
|
||||
«Показать таймер» публикуют их. Текст отображается как обычный текст, без HTML.
|
||||
Таймер вычисляется в основном процессе по монотонному deadline; интервалы только
|
||||
обновляют снимок. Пауза сохраняет точный остаток, сброс возвращает полную длительность.
|
||||
Таймер продолжает идти за другой сценой и при закрытом окне; смена пакета сбрасывает
|
||||
его. По окончании остаётся 00:00, без сигнала и смены режима. Восстановление после
|
||||
сна/перезапуска относится к SCREEN-007.
|
||||
|
||||
Обложка и фон читаются через локальный протокол. Новая подготовка использует
|
||||
адрес с идентификатором сцены, исключая устаревший декодированный кеш изображения.
|
||||
Окно подтверждает готовность по загрузке изображений/медиа; запоздалые подтверждения
|
||||
прошлой сцены/сессии игнорируются. Сообщения об ошибках доступны только пульту.
|
||||
На проекторе остаются материалы игры и предусмотренные состояния отсутствия
|
||||
необязательного вступления/видео/результатов, без кнопок и диагностических текстов.
|
||||
|
||||
Предпросмотр — кадры настоящего окна через capturePage, максимум два в секунду,
|
||||
шириной не более 960 px. В пульте нет второго audio/video. Захват недоступен окну
|
||||
показа, при закрытом проекторе/скрытом или свёрнутом пульте прекращается; старые
|
||||
кадры другой сцены/сессии не принимаются. Хранится только последний кадр.
|
||||
Системные шрифты с кириллицей не требуют сети.
|
||||
|
||||
`Controller.setResults(snapshot)` принимает снимок текущей игры: packageKey,
|
||||
capturedAt, вопросы и публичные строки таблицы (ID/название команды, место,
|
||||
сумма, ходы и баллы ответов). Лишние поля отбрасываются, другой ключ игры,
|
||||
повторные команды/вопросы и некорректные значения отклоняются. На пульте указан
|
||||
момент снимка. При его отсутствии показывается «Результаты пока не загружены»;
|
||||
фиктивные результаты в приложении не создаются. Длинная таблица прокручивается
|
||||
снизу вверх, обновления позиций медиа её не перезапускают. Сетевое получение и
|
||||
сохранение снимка реализуются в SCREEN-006; публичного IPC редактирования результатов нет.
|
||||
|
||||
`npm run acceptance` теперь создаёт локальный пакет с обложкой, фоном и восемью
|
||||
шагами; [инструкция SCREEN-005](docs/SCREEN-005_ACCEPTANCE.md) описывает полный
|
||||
офлайн-проход. Автоматический smoke проверяет также повреждение/восстановление
|
||||
изображения, отсутствие второго звука, предел частоты кадров предпросмотра,
|
||||
кириллицу и отрисовку/прокрутку 50 синтетических строк результатов. Эти строки
|
||||
подаются только в тестовый renderer и не сохраняются в состоянии приложения.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Задачи приложения площадки
|
||||
|
||||
SCREEN-001 и SCREEN-004 имеют статус IN_PROGRESS; SCREEN-002 и SCREEN-003 завершены
|
||||
и перенесены в архив; остальные активные задачи — TODO.
|
||||
SCREEN-001 и SCREEN-004 имеют статус IN_PROGRESS; SCREEN-002, SCREEN-003 и SCREEN-005
|
||||
завершены и перенесены в архив; остальные активные задачи — TODO.
|
||||
SCREEN-009 (LAN-пульт) отменена владельцем; ID не переиспользовать.
|
||||
Управление телефоном через сервер входит в SCREEN-006. План и общие ограничения:
|
||||
[PLAN.md](PLAN.md). Идентификаторы независимы от ED-* в корневом списке.
|
||||
@@ -109,27 +109,10 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе
|
||||
docs/SCREEN-004_ACCEPTANCE.md на всей матрице, получить результаты владельца.
|
||||
До их получения статус остаётся IN_PROGRESS; перенос в архив не выполняется.
|
||||
|
||||
## SCREEN-005 — Пульт ведущего и все режимы показа
|
||||
|
||||
- Статус: `TODO`
|
||||
- Зависимость: SCREEN-004.
|
||||
- Цель: провести показ выбранной игры с ноутбука без телефона.
|
||||
- Объём: выбор подготовленного пакета, план шагов, предпросмотр без второго звука,
|
||||
обложка, фон, вступление, закрывающее видео, текст, таймер, результаты и музыка;
|
||||
фактические статусы загрузки, подготовки и воспроизведения на пульте.
|
||||
- Готовность: все текущие режимы ScreenMode доступны; таймер работает локально
|
||||
без накопления дрейфа от интервалов; пульт и технические сообщения не попадают
|
||||
на проектор; неготовый файл нельзя ошибочно запустить как готовый.
|
||||
- Проверка: проверки приложения и ручной проход полного плана без интернета;
|
||||
предпросмотр не дублирует звук; кириллица, шрифты и изображения доступны офлайн.
|
||||
- Не входит: редактор сценариев и полное управление командами/регистрациями игры.
|
||||
- Следующий шаг: сопоставить текущие компоненты и callers с экраном пульта;
|
||||
переиспользовать подходящее без преждевременного общего пакета.
|
||||
|
||||
## SCREEN-006 — Подключение игры и синхронизация с сервером
|
||||
|
||||
- Статус: `TODO`
|
||||
- Зависимости: SCREEN-002, SCREEN-005.
|
||||
- Зависимости: SCREEN-002, SCREEN-005 (DONE, см. COMPLETED_TASKS.md).
|
||||
- Цель: выбирать доступную организатору игру онлайн и сохранять автономность показа.
|
||||
- Объём: существующая авторизация, список игр, загрузка пакета, снимок результатов,
|
||||
индикатор связи, проверка новой версии материалов, синхронизация статуса показа.
|
||||
@@ -148,7 +131,10 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе
|
||||
затронутых репозиториев; существующий браузерный экран продолжает работать.
|
||||
- Не входит: полноценная офлайн-игра, LAN-сервер и отдельный мобильный пульт desktop.
|
||||
- Следующий шаг: сверить auth flow и модель сессий; зафиксировать правила
|
||||
локального владения показом и отображения статуса на сервере.
|
||||
локального владения показом и отображения статуса на сервере. Для результатов
|
||||
использовать Controller.setResults с ResultsSnapshot (только поля таблицы,
|
||||
packageKey и capturedAt); получение и сохранение снимка реализовать здесь.
|
||||
Не менять бэкенд/protobuf без нового прямого запроса владельца.
|
||||
|
||||
## SCREEN-007 — Устойчивость на площадке
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# SCREEN-005 — полный офлайн-проход показа
|
||||
|
||||
Программная часть и GUI smoke проверены на Linux x64. Аппаратная приёмка
|
||||
SCREEN-001/SCREEN-004 остаётся открытой; эта инструкция не отмечает её пройденной.
|
||||
|
||||
## Подготовка
|
||||
|
||||
1. Установите зависимости и создайте fixtures командой `npm run media:generate`.
|
||||
2. Подключите настоящий внешний дисплей/проектор в режиме расширения, запустите
|
||||
`npm run acceptance`. Команда создаёт отдельный временный userData и локальный
|
||||
пакет «Offline smoke» с изображениями, аудио, видео, музыкой и восемью шагами.
|
||||
3. Выберите этот пакет в пульте, внешний дисплей и «Открыть показ». Затем отключите
|
||||
сеть. Пульт должен оставаться на основном экране; на проекторе — только материалы.
|
||||
4. Для проверки физического звука не используйте --mute-audio. Установочные архивы
|
||||
не пересобирались; команда проверяет текущий исходный код.
|
||||
|
||||
## Полный план
|
||||
|
||||
- Пройдите восемь шагов по порядку и назад: обложка, вступление, «Успехов»,
|
||||
заключительное видео, «Спасибо за игру!», результаты, таймер и текст.
|
||||
- Убедитесь, что шаги не запускают звук или таймер сами. Запустите вступление/видео
|
||||
явной кнопкой, переключитесь в другой режим: основное медиа останавливается.
|
||||
- Включите музыку и переключайте сцены: музыка остаётся независимой. Предпросмотр
|
||||
повторяет реальное окно с небольшой задержкой, второго звука с пульта нет.
|
||||
При закрытом окне предпросмотр недоступен; скрытый/свёрнутый пульт не захватывает кадры.
|
||||
- Проверьте обложку, затемнённый фон и кириллицу без интернета. На проекторе нет
|
||||
кнопок, сообщений пульта, диагностических статусов и исходных путей файлов.
|
||||
- Введите текст с переносами строк, кириллицей и `<script>текст</script>`.
|
||||
До «Показать текст» сцена не меняется; после применения это обычный текст,
|
||||
без выполнения HTML/JavaScript.
|
||||
- Настройте таймер на 10 секунд. «Показать таймер» применяет параметры без старта.
|
||||
Проверьте старт, паузу, ожидание на паузе, продолжение и сброс. При переходе
|
||||
к обложке отсчёт продолжается; при возвращении виден актуальный остаток.
|
||||
В конце остаётся 00:00, без сигнала и автоматической смены режима.
|
||||
- Закройте/откройте окно показа при работающем таймере: отсчёт продолжается,
|
||||
медиа и музыка самостоятельно не запускаются. Смена пакета сбрасывает таймер.
|
||||
- В режиме результатов без снимка видно «Результаты пока не загружены».
|
||||
Настоящее получение снимков относится к SCREEN-006. Автоматический smoke отдельно
|
||||
проверяет таблицу и прокрутку на тестовых данных, не сохраняя их в приложении.
|
||||
|
||||
## Результат проверки
|
||||
|
||||
Запишите ОС/архитектуру, версию Electron, дисплей/переходник/аудиовыход, успешность
|
||||
каждого сценария, ошибки и скриншоты при необходимости. Для физической задержки
|
||||
и всей аппаратной матрицы используйте [SCREEN-004_ACCEPTANCE.md](SCREEN-004_ACCEPTANCE.md).
|
||||
Проверки сборки/test/smoke запускайте последовательно: они используют один dist-electron.
|
||||
+217
-3
@@ -8,7 +8,10 @@ import type {
|
||||
Intent,
|
||||
Report,
|
||||
State,
|
||||
ScreenMode,
|
||||
TimerState,
|
||||
} from '../src/types/screen.js'
|
||||
import { parseResults } from './results.js'
|
||||
import type { LocalPackage } from './package-store.js'
|
||||
|
||||
function number(value: unknown, min: number, max = Number.MAX_SAFE_INTEGER): value is number {
|
||||
@@ -27,6 +30,34 @@ export function parseCommand(value: unknown): Command {
|
||||
let intent: Intent
|
||||
if (c.action === 'mute' && typeof c.enabled === 'boolean')
|
||||
intent = { action: 'mute', enabled: c.enabled }
|
||||
else if (c.action === 'mode' && Number.isInteger(c.mode) && number(c.mode, 1, 8))
|
||||
intent = { action: 'mode', mode: c.mode as ScreenMode }
|
||||
else if (c.action === 'step' && Number.isSafeInteger(c.index) && number(c.index, 0))
|
||||
intent = { action: 'step', index: c.index }
|
||||
else if (
|
||||
c.action === 'text' &&
|
||||
typeof c.text === 'string' &&
|
||||
c.text.trim() &&
|
||||
c.text.length <= 10000
|
||||
)
|
||||
intent = { action: 'text', text: c.text.trim() }
|
||||
else if (
|
||||
c.action === 'timer-set' &&
|
||||
typeof c.label === 'string' &&
|
||||
c.label.length <= 200 &&
|
||||
Number.isInteger(c.durationSeconds) &&
|
||||
number(c.durationSeconds, 1, 86400)
|
||||
)
|
||||
intent = {
|
||||
action: 'timer-set',
|
||||
label: c.label.trim() || 'Перерыв',
|
||||
durationSeconds: c.durationSeconds,
|
||||
}
|
||||
else if (
|
||||
typeof c.action === 'string' &&
|
||||
['timer-start', 'timer-pause', 'timer-resume', 'timer-reset'].includes(c.action)
|
||||
)
|
||||
intent = { action: c.action as 'timer-start' | 'timer-pause' | 'timer-resume' | 'timer-reset' }
|
||||
else {
|
||||
if (c.channel !== 'main' && c.channel !== 'music') throw new Error('Некорректный канал')
|
||||
const channel = c.channel
|
||||
@@ -84,6 +115,9 @@ const fixtureCatalog = (): Catalog => ({
|
||||
key: null,
|
||||
name: 'Тестовый режим SCREEN-001',
|
||||
playlists: [],
|
||||
cover: null,
|
||||
background: null,
|
||||
steps: [],
|
||||
media: [
|
||||
{
|
||||
id: 'fixture-audio',
|
||||
@@ -115,6 +149,15 @@ function initialChannel(): ChannelState {
|
||||
loop: false,
|
||||
}
|
||||
}
|
||||
function initialTimer(): TimerState {
|
||||
return {
|
||||
revision: 0,
|
||||
label: 'Перерыв',
|
||||
durationMs: 300000,
|
||||
remainingMs: 300000,
|
||||
status: 'stopped',
|
||||
}
|
||||
}
|
||||
export class Controller {
|
||||
state: State = {
|
||||
session: randomUUID(),
|
||||
@@ -123,11 +166,18 @@ export class Controller {
|
||||
outputOpen: false,
|
||||
catalog: fixtureCatalog(),
|
||||
muted: false,
|
||||
scene: { revision: 0, mode: 1, stepIndex: null, text: '', status: 'idle', error: '' },
|
||||
timer: initialTimer(),
|
||||
results: null,
|
||||
channels: { main: initialChannel(), music: initialChannel() },
|
||||
samples: [],
|
||||
}
|
||||
private measured = new Map<Channel, { operation: number; startedAt: number }>()
|
||||
constructor(private publish: (state: State) => void) {
|
||||
private timerDeadline: number | null = null
|
||||
constructor(
|
||||
private publish: (state: State) => void,
|
||||
private now: () => number = () => performance.now(),
|
||||
) {
|
||||
this.state.channels.main.source = this.state.catalog.media[0]!
|
||||
}
|
||||
private broadcast() {
|
||||
@@ -146,8 +196,10 @@ export class Controller {
|
||||
this.measured.delete(channel)
|
||||
}
|
||||
private newSession() {
|
||||
this.updateTimer()
|
||||
this.state.session = randomUUID()
|
||||
this.state.sequence = 0
|
||||
this.invalidateScene()
|
||||
for (const channel of ['main', 'music'] as const)
|
||||
this.change(channel, {
|
||||
desired: 'stopped',
|
||||
@@ -158,6 +210,7 @@ export class Controller {
|
||||
output(open: boolean) {
|
||||
this.newSession()
|
||||
this.state.outputOpen = open
|
||||
this.state.scene.status = open ? 'pending' : 'idle'
|
||||
if (!open)
|
||||
for (const c of Object.values(this.state.channels)) c.status = c.source ? 'stopped' : 'idle'
|
||||
this.broadcast()
|
||||
@@ -182,6 +235,13 @@ export class Controller {
|
||||
key: pkg.key,
|
||||
name: pkg.package.game.name,
|
||||
media,
|
||||
cover: pkg.package.media.cover
|
||||
? `screen-app://app/packages/${pkg.key}/${pkg.package.media.cover}`
|
||||
: null,
|
||||
background: pkg.package.media.background
|
||||
? `screen-app://app/packages/${pkg.key}/${pkg.package.media.background}`
|
||||
: null,
|
||||
steps: pkg.package.steps.map((step) => ({ ...step, mode: step.mode as ScreenMode })),
|
||||
playlists: pkg.package.playlists.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
@@ -194,6 +254,17 @@ export class Controller {
|
||||
}
|
||||
}
|
||||
this.state.catalog = catalog
|
||||
this.state.scene = {
|
||||
revision: this.state.scene.revision,
|
||||
mode: 1,
|
||||
stepIndex: null,
|
||||
text: '',
|
||||
status: 'idle',
|
||||
error: '',
|
||||
}
|
||||
this.state.timer = initialTimer()
|
||||
this.timerDeadline = null
|
||||
this.state.results = null
|
||||
this.state.channels = {
|
||||
main: { ...initialChannel(), source: catalog.media[0] ?? null },
|
||||
music: initialChannel(),
|
||||
@@ -214,6 +285,134 @@ export class Controller {
|
||||
this.newSession()
|
||||
this.broadcast()
|
||||
}
|
||||
private invalidateScene() {
|
||||
this.state.scene.revision++
|
||||
this.state.scene.status = this.state.outputOpen ? 'pending' : 'idle'
|
||||
this.state.scene.error = ''
|
||||
}
|
||||
private mode(mode: ScreenMode, stepIndex: number | null = null, text = this.state.scene.text) {
|
||||
this.state.scene = { ...this.state.scene, mode, stepIndex, text }
|
||||
const c = this.state.channels.main
|
||||
const kind = mode === 2 ? 'audio' : mode === 4 ? 'video' : null
|
||||
const source = kind ? (this.state.catalog.media.find((s) => s.kind === kind) ?? null) : c.source
|
||||
this.change('main', {
|
||||
source,
|
||||
desired: 'stopped',
|
||||
position: 0,
|
||||
duration: source?.id === c.source?.id ? c.duration : null,
|
||||
status: source ? 'pending' : 'idle',
|
||||
})
|
||||
this.invalidateScene()
|
||||
}
|
||||
private configureTimer(label: string, seconds: number) {
|
||||
this.timerDeadline = null
|
||||
this.state.timer = {
|
||||
revision: this.state.timer.revision + 1,
|
||||
label,
|
||||
durationMs: seconds * 1000,
|
||||
remainingMs: seconds * 1000,
|
||||
status: 'stopped',
|
||||
}
|
||||
}
|
||||
private updateTimer() {
|
||||
if (this.timerDeadline === null) return false
|
||||
const t = this.state.timer
|
||||
const remaining = Math.max(0, this.timerDeadline - this.now())
|
||||
if (remaining === t.remainingMs) return false
|
||||
t.remainingMs = remaining
|
||||
t.revision++
|
||||
if (!remaining) {
|
||||
t.status = 'finished'
|
||||
this.timerDeadline = null
|
||||
}
|
||||
return true
|
||||
}
|
||||
tick() {
|
||||
if (this.updateTimer()) this.broadcast()
|
||||
}
|
||||
setResults(value: unknown) {
|
||||
const key = this.state.catalog.key
|
||||
if (value !== null && !key) throw new Error('Сначала выберите пакет игры')
|
||||
const snapshot = value === null ? null : parseResults(value, key!)
|
||||
this.state.results = snapshot
|
||||
if (this.state.scene.mode === 6) this.invalidateScene()
|
||||
this.broadcast()
|
||||
}
|
||||
reportScene(value: unknown) {
|
||||
if (!value || typeof value !== 'object') throw new Error('Некорректное подтверждение сцены')
|
||||
const r = value as Record<string, unknown>
|
||||
if (
|
||||
typeof r.session !== 'string' ||
|
||||
!Number.isSafeInteger(r.revision) ||
|
||||
(r.status !== 'ready' && r.status !== 'error') ||
|
||||
(r.error !== undefined && typeof r.error !== 'string')
|
||||
)
|
||||
throw new Error('Некорректное подтверждение сцены')
|
||||
if (
|
||||
!this.state.outputOpen ||
|
||||
r.session !== this.state.session ||
|
||||
r.revision !== this.state.scene.revision
|
||||
)
|
||||
return
|
||||
const mediaError = [2, 4].includes(this.state.scene.mode) ? this.state.channels.main.error : ''
|
||||
const status = mediaError ? 'error' : r.status
|
||||
const error = String(mediaError || r.error || '').slice(0, 500)
|
||||
if (this.state.scene.status === status && this.state.scene.error === error) return
|
||||
this.state.scene.status = status
|
||||
this.state.scene.error = error
|
||||
this.broadcast()
|
||||
}
|
||||
private presentation(command: Command): boolean {
|
||||
switch (command.action) {
|
||||
case 'mode':
|
||||
this.mode(command.mode)
|
||||
return true
|
||||
case 'text':
|
||||
this.mode(8, null, command.text)
|
||||
return true
|
||||
case 'step': {
|
||||
const step = this.state.catalog.steps[command.index]
|
||||
if (!step) throw new Error('Шаг плана не найден')
|
||||
if (step.mode === 7)
|
||||
this.configureTimer(step.timerLabel || 'Перерыв', step.timerDurationSeconds)
|
||||
this.mode(step.mode, command.index, step.text)
|
||||
return true
|
||||
}
|
||||
case 'timer-set':
|
||||
this.configureTimer(command.label, command.durationSeconds)
|
||||
this.mode(7)
|
||||
return true
|
||||
case 'timer-start':
|
||||
case 'timer-pause':
|
||||
case 'timer-resume':
|
||||
case 'timer-reset': {
|
||||
this.updateTimer()
|
||||
const t = this.state.timer
|
||||
if (command.action === 'timer-reset') {
|
||||
this.configureTimer(t.label, t.durationMs / 1000)
|
||||
return true
|
||||
}
|
||||
if (command.action === 'timer-pause') {
|
||||
if (t.status === 'running') {
|
||||
this.timerDeadline = null
|
||||
t.status = 'paused'
|
||||
t.revision++
|
||||
}
|
||||
} else if (
|
||||
t.status !== 'running' &&
|
||||
(command.action === 'timer-start' || t.status === 'paused')
|
||||
) {
|
||||
if (t.remainingMs <= 0) t.remainingMs = t.durationMs
|
||||
t.status = 'running'
|
||||
t.revision++
|
||||
this.timerDeadline = this.now() + t.remainingMs
|
||||
}
|
||||
return true
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
private track(index: number) {
|
||||
const c = this.state.channels.music
|
||||
const track = this.state.catalog.playlists.find((p) => p.id === c.playlistId)?.tracks[index]
|
||||
@@ -243,13 +442,16 @@ export class Controller {
|
||||
const command = parseCommand(input)
|
||||
if (command.session !== this.state.session || command.sequence <= this.state.sequence) return
|
||||
if (command.action === 'mute') this.state.muted = command.enabled
|
||||
else {
|
||||
else if (this.presentation(command)) {
|
||||
/* Режимы и таймер не зависят от транспортных каналов. */
|
||||
} else if ('channel' in command) {
|
||||
const channel = command.channel
|
||||
const c = this.state.channels[channel]
|
||||
let c = this.state.channels[channel]
|
||||
switch (command.action) {
|
||||
case 'select': {
|
||||
const source = this.state.catalog.media.find((s) => s.id === command.sourceId)
|
||||
if (!source) throw new Error('Ресурс не найден в выбранном пакете')
|
||||
this.mode(source.kind === 'audio' ? 2 : 4)
|
||||
this.change(channel, {
|
||||
source,
|
||||
desired: 'stopped',
|
||||
@@ -287,6 +489,14 @@ export class Controller {
|
||||
break
|
||||
default: {
|
||||
if (!c.source) throw new Error('Для канала не выбран ресурс')
|
||||
if (channel === 'main' && ['play', 'resume', 'replay'].includes(command.action)) {
|
||||
const mode = c.source.kind === 'audio' ? 2 : 4
|
||||
if (this.state.scene.status === 'error') this.invalidateScene()
|
||||
if (this.state.scene.mode !== mode) {
|
||||
this.mode(mode)
|
||||
c = this.state.channels[channel]
|
||||
}
|
||||
}
|
||||
const patch: Partial<ChannelState> = { status: 'pending' }
|
||||
if (command.action === 'play' || command.action === 'replay') {
|
||||
patch.position = 0
|
||||
@@ -361,6 +571,10 @@ export class Controller {
|
||||
}
|
||||
if (report.status === 'error') {
|
||||
c.desired = 'paused'
|
||||
if (report.channel === 'main' && [2, 4].includes(this.state.scene.mode)) {
|
||||
this.state.scene.status = 'error'
|
||||
this.state.scene.error = c.error
|
||||
}
|
||||
this.measured.delete(report.channel)
|
||||
}
|
||||
if (report.status === 'ended') {
|
||||
|
||||
+61
-2
@@ -98,7 +98,11 @@ app
|
||||
.then(async () => {
|
||||
const packages = new PackageStore(join(app.getPath('userData'), 'packages'))
|
||||
await packages.initialize()
|
||||
app.on('before-quit', () => packages.cancel())
|
||||
const timerTick = setInterval(() => controller.tick(), 250)
|
||||
app.on('before-quit', () => {
|
||||
clearInterval(timerTick)
|
||||
packages.cancel()
|
||||
})
|
||||
session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) =>
|
||||
callback(false),
|
||||
)
|
||||
@@ -107,7 +111,7 @@ app
|
||||
const url = new URL(request.url)
|
||||
const headers = {
|
||||
'Content-Security-Policy':
|
||||
"default-src 'self' screen-app:; script-src 'self'; style-src 'self' 'unsafe-inline'; media-src screen-app:; connect-src 'self'; frame-ancestors 'none'",
|
||||
"default-src 'self' screen-app:; script-src 'self'; style-src 'self' 'unsafe-inline'; media-src screen-app:; img-src 'self' screen-app: data:; connect-src 'self'; frame-ancestors 'none'",
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
}
|
||||
if (url.host !== 'app' || request.method !== 'GET') return new Response(null, { status: 404 })
|
||||
@@ -220,6 +224,61 @@ app
|
||||
throw new Error('Сначала откройте окно показа')
|
||||
controller.command(parsed)
|
||||
})
|
||||
let capturing = false
|
||||
let lastCapture = -Infinity
|
||||
ipcMain.handle('preview', async (event) => {
|
||||
requireControl(event)
|
||||
const window = output
|
||||
const state = controller.state
|
||||
if (
|
||||
!window ||
|
||||
window.isDestroyed() ||
|
||||
window.webContents.isLoading() ||
|
||||
!control?.isVisible() ||
|
||||
control.isMinimized() ||
|
||||
state.scene.status !== 'ready' ||
|
||||
capturing ||
|
||||
performance.now() - lastCapture < 500
|
||||
)
|
||||
return null
|
||||
const session = state.session
|
||||
const revision = state.scene.revision
|
||||
capturing = true
|
||||
lastCapture = performance.now()
|
||||
try {
|
||||
const image = await window.webContents.capturePage()
|
||||
if (
|
||||
window !== output ||
|
||||
window.isDestroyed() ||
|
||||
session !== controller.state.session ||
|
||||
revision !== controller.state.scene.revision ||
|
||||
controller.state.scene.status !== 'ready' ||
|
||||
image.isEmpty()
|
||||
)
|
||||
return null
|
||||
return {
|
||||
session,
|
||||
revision,
|
||||
dataURL: image.resize({ width: Math.min(960, image.getSize().width) }).toDataURL(),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
capturing = false
|
||||
}
|
||||
})
|
||||
ipcMain.on('scene-report', (event, report: unknown) => {
|
||||
if (
|
||||
event.sender !== output?.webContents ||
|
||||
event.senderFrame !== output.webContents.mainFrame
|
||||
)
|
||||
return
|
||||
try {
|
||||
controller.reportScene(report)
|
||||
} catch (error) {
|
||||
console.error('Ошибка подтверждения сцены', error)
|
||||
}
|
||||
})
|
||||
ipcMain.on('report', (event, report: unknown) => {
|
||||
if (
|
||||
event.sender !== output?.webContents ||
|
||||
|
||||
+3
-1
@@ -1,9 +1,11 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { Command, Report, State } from '../src/types/screen.js'
|
||||
import type { Command, Report, SceneReport, State } from '../src/types/screen.js'
|
||||
|
||||
contextBridge.exposeInMainWorld('screenBridge', {
|
||||
command: (command: Command) => ipcRenderer.invoke('command', command),
|
||||
report: (report: Report) => ipcRenderer.send('report', report),
|
||||
reportScene: (report: SceneReport) => ipcRenderer.send('scene-report', report),
|
||||
preview: () => ipcRenderer.invoke('preview'),
|
||||
state: () => ipcRenderer.invoke('state'),
|
||||
subscribe: (listener: (state: State) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, state: State) => listener(state)
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { Controller, parseCommand } from './controller.js'
|
||||
import { buildScreenPackage } from './screen-package.js'
|
||||
import { packageKey, type LocalPackage } from './package-store.js'
|
||||
import { parseResults } from './results.js'
|
||||
import { formatTimer, remainingTime } from '../src/utils/presentation.js'
|
||||
import type { Intent, ScreenMode } from '../src/types/screen.js'
|
||||
|
||||
function fixture() {
|
||||
let now = 0
|
||||
const c = new Controller(
|
||||
() => {},
|
||||
() => now,
|
||||
)
|
||||
const pkg = buildScreenPackage('https://example.test', 7, {
|
||||
game: {
|
||||
game: {
|
||||
ID: 7,
|
||||
name: 'Дело №7',
|
||||
scenario: {
|
||||
image: 'cover.png',
|
||||
story: {
|
||||
background: 'background.png',
|
||||
introduction: { audio: 'intro.wav' },
|
||||
conclusion: { video: 'ending.mp4' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plan: {
|
||||
plan: {
|
||||
steps: Array.from({ length: 8 }, (_, i) => ({
|
||||
mode: i + 1,
|
||||
text: i === 7 ? 'Текст шага\nКириллица' : '',
|
||||
timerLabel: 'Перерыв',
|
||||
timerDurationSeconds: i === 6 ? '10' : '0',
|
||||
})),
|
||||
},
|
||||
},
|
||||
music: {
|
||||
gamePlaylistId: 1,
|
||||
playlists: [
|
||||
{
|
||||
ID: 1,
|
||||
name: 'Музыка',
|
||||
tracks: [{ ID: 1, name: 'Трек', filename: 'one.wav', position: 0 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const local: LocalPackage = {
|
||||
formatVersion: 1,
|
||||
key: packageKey(pkg.server, 7),
|
||||
version: 'a'.repeat(64),
|
||||
package: pkg,
|
||||
files: [],
|
||||
}
|
||||
c.selectPackage(local)
|
||||
c.output(true)
|
||||
const send = (intent: Intent) =>
|
||||
c.command({ session: c.state.session, sequence: c.state.sequence + 1, ...intent })
|
||||
return {
|
||||
c,
|
||||
send,
|
||||
key: local.key,
|
||||
advance: (ms: number) => {
|
||||
now += ms
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('проходит восемь шагов, подставляет локальные изображения, не запускает звук/таймер', () => {
|
||||
const f = fixture()
|
||||
assert.match(
|
||||
f.c.state.catalog.cover!,
|
||||
/^screen-app:\/\/app\/packages\/[a-f0-9]{64}\/[a-f0-9]{64}$/,
|
||||
)
|
||||
assert.notEqual(f.c.state.catalog.cover, f.c.state.catalog.background)
|
||||
for (let i = 0; i < 8; i++) {
|
||||
f.send({ action: 'step', index: i })
|
||||
assert.equal(f.c.state.scene.mode, i + 1)
|
||||
assert.equal(f.c.state.scene.stepIndex, i)
|
||||
assert.equal(f.c.state.channels.main.desired, 'stopped')
|
||||
assert.equal(f.c.state.timer.status, 'stopped')
|
||||
}
|
||||
assert.equal(f.c.state.scene.text, 'Текст шага\nКириллица')
|
||||
assert.equal(f.c.state.timer.remainingMs, 10000)
|
||||
assert.throws(() => f.send({ action: 'step', index: 8 }), /Шаг/)
|
||||
})
|
||||
test('смена сцены останавливает основное медиа, музыка независима, автоматического шага нет', () => {
|
||||
const f = fixture()
|
||||
f.send({ action: 'play', channel: 'music' })
|
||||
f.send({ action: 'step', index: 1 })
|
||||
f.send({ action: 'play', channel: 'main' })
|
||||
const c = f.c.state.channels.main
|
||||
f.c.report({
|
||||
session: f.c.state.session,
|
||||
channel: 'main',
|
||||
operation: c.operation,
|
||||
status: 'ended',
|
||||
position: 60,
|
||||
duration: 60,
|
||||
})
|
||||
assert.equal(f.c.state.scene.stepIndex, 1)
|
||||
assert.equal(f.c.state.scene.mode, 2)
|
||||
f.send({ action: 'mode', mode: 3 })
|
||||
assert.equal(f.c.state.channels.main.desired, 'stopped')
|
||||
assert.equal(f.c.state.channels.music.desired, 'playing')
|
||||
assert.equal(f.c.state.scene.stepIndex, null)
|
||||
})
|
||||
test('таймер не накапливает дрейф при редких tick, переживает смену сцены и окна', () => {
|
||||
const f = fixture()
|
||||
f.send({ action: 'timer-set', label: '', durationSeconds: 10 })
|
||||
assert.equal(f.c.state.timer.label, 'Перерыв')
|
||||
assert.equal(f.c.state.timer.status, 'stopped')
|
||||
f.send({ action: 'timer-start' })
|
||||
f.advance(3250)
|
||||
f.c.tick()
|
||||
assert.equal(f.c.state.timer.remainingMs, 6750)
|
||||
f.send({ action: 'mode', mode: 8 })
|
||||
f.advance(1500)
|
||||
f.c.output(false)
|
||||
f.c.output(true)
|
||||
assert.equal(f.c.state.timer.remainingMs, 5250)
|
||||
f.advance(5250)
|
||||
f.c.tick()
|
||||
assert.equal(f.c.state.timer.status, 'finished')
|
||||
assert.equal(f.c.state.timer.remainingMs, 0)
|
||||
assert.equal(f.c.state.scene.mode, 8)
|
||||
assert.equal(f.c.state.channels.main.desired, 'stopped')
|
||||
f.advance(99999)
|
||||
f.c.tick()
|
||||
assert.equal(f.c.state.timer.remainingMs, 0)
|
||||
})
|
||||
test('пауза/продолжение/сброс таймера используют точный остаток, шаг таймера сбрасывает его', () => {
|
||||
const f = fixture()
|
||||
f.send({ action: 'step', index: 6 })
|
||||
f.send({ action: 'timer-start' })
|
||||
f.advance(1234)
|
||||
f.send({ action: 'timer-pause' })
|
||||
assert.equal(f.c.state.timer.remainingMs, 8766)
|
||||
f.advance(5000)
|
||||
f.c.tick()
|
||||
assert.equal(f.c.state.timer.remainingMs, 8766)
|
||||
f.send({ action: 'timer-resume' })
|
||||
f.advance(1000)
|
||||
f.c.tick()
|
||||
assert.equal(f.c.state.timer.remainingMs, 7766)
|
||||
f.send({ action: 'timer-reset' })
|
||||
assert.equal(f.c.state.timer.remainingMs, 10000)
|
||||
assert.equal(f.c.state.timer.status, 'stopped')
|
||||
f.send({ action: 'timer-start' })
|
||||
f.advance(100)
|
||||
f.send({ action: 'step', index: 6 })
|
||||
assert.equal(f.c.state.timer.remainingMs, 10000)
|
||||
assert.equal(f.c.state.timer.status, 'stopped')
|
||||
f.c.selectPackage(null)
|
||||
assert.equal(f.c.state.timer.status, 'stopped')
|
||||
assert.equal(f.c.state.timer.remainingMs, 300000)
|
||||
})
|
||||
test('формат таймера и интерполяция не зависят от числа обновлений', () => {
|
||||
assert.equal(formatTimer(60000), '01:00')
|
||||
assert.equal(formatTimer(1), '00:01')
|
||||
assert.equal(formatTimer(-5), '00:00')
|
||||
const f = fixture()
|
||||
f.send({ action: 'timer-set', label: 'Перерыв', durationSeconds: 10 })
|
||||
f.send({ action: 'timer-start' })
|
||||
assert.equal(remainingTime(f.c.state.timer, 11000), 0)
|
||||
assert.equal(remainingTime(f.c.state.timer, 1234), 8766)
|
||||
f.send({ action: 'timer-pause' })
|
||||
assert.equal(remainingTime(f.c.state.timer, 1234), 10000)
|
||||
})
|
||||
test('подтверждение сцены отклоняет старую ревизию и сессию, ошибка не попадает в текст показа', () => {
|
||||
const f = fixture()
|
||||
const old = { session: f.c.state.session, revision: f.c.state.scene.revision, status: 'ready' }
|
||||
f.send({ action: 'text', text: ' Привет\n<script>не HTML</script> ' })
|
||||
f.c.reportScene(old)
|
||||
assert.equal(f.c.state.scene.status, 'pending')
|
||||
assert.equal(f.c.state.scene.text, 'Привет\n<script>не HTML</script>')
|
||||
const report = {
|
||||
session: f.c.state.session,
|
||||
revision: f.c.state.scene.revision,
|
||||
status: 'error',
|
||||
error: 'Нет изображения',
|
||||
}
|
||||
f.c.reportScene(report)
|
||||
assert.equal(f.c.state.scene.error, 'Нет изображения')
|
||||
assert.ok(!f.c.state.scene.text.includes('Нет изображения'))
|
||||
f.c.output(false)
|
||||
f.c.output(true)
|
||||
f.c.reportScene(report)
|
||||
assert.equal(f.c.state.scene.status, 'pending')
|
||||
f.c.reportScene({
|
||||
session: f.c.state.session,
|
||||
revision: f.c.state.scene.revision,
|
||||
status: 'ready',
|
||||
})
|
||||
assert.equal(f.c.state.scene.status, 'ready')
|
||||
assert.throws(() => f.c.reportScene({ ...old, status: 'fake' }))
|
||||
})
|
||||
test('не допускает пустой текст и некорректные параметры таймера/режима на IPC', () => {
|
||||
const f = fixture()
|
||||
const base = { session: f.c.state.session, sequence: 1 }
|
||||
for (const intent of [
|
||||
{ action: 'text', text: '' },
|
||||
{ action: 'text', text: ' '.repeat(3) },
|
||||
{ action: 'text', text: 'x'.repeat(10001) },
|
||||
{ action: 'timer-set', label: '', durationSeconds: 0 },
|
||||
{ action: 'timer-set', label: '', durationSeconds: 1.5 },
|
||||
{ action: 'timer-set', label: 'x'.repeat(201), durationSeconds: 10 },
|
||||
{ action: 'mode', mode: 9 },
|
||||
{ action: 'mode', mode: NaN },
|
||||
{ action: 'step', index: -1 },
|
||||
{ action: ['timer-start'] },
|
||||
])
|
||||
assert.throws(() => parseCommand({ ...base, ...intent }))
|
||||
for (const mode of [1, 2, 3, 4, 5, 6, 7, 8] as ScreenMode[])
|
||||
assert.equal(parseCommand({ ...base, action: 'mode', mode }).action, 'mode')
|
||||
})
|
||||
function snapshot(key: string) {
|
||||
return {
|
||||
packageKey: key,
|
||||
capturedAt: '2026-10-17T10:00:00Z',
|
||||
questions: [{ code: 'К1', text: 'Вопрос' }],
|
||||
teams: [
|
||||
{
|
||||
id: 'team',
|
||||
name: 'Детективы',
|
||||
place: 1,
|
||||
totalScore: -2,
|
||||
actionsCount: 4,
|
||||
answers: [{ questionCode: 'К1', score: -2 }],
|
||||
password: 'SECRET',
|
||||
participants: ['SECRET'],
|
||||
},
|
||||
],
|
||||
token: 'SECRET',
|
||||
}
|
||||
}
|
||||
test('снимок результатов ограничен текущей игрой, содержит только публичные поля и время', () => {
|
||||
const f = fixture()
|
||||
const data = snapshot(f.key)
|
||||
f.send({ action: 'mode', mode: 6 })
|
||||
const revision = f.c.state.scene.revision
|
||||
f.c.setResults(data)
|
||||
assert.equal(f.c.state.scene.revision, revision + 1)
|
||||
assert.equal(f.c.state.results!.teams[0]!.totalScore, -2)
|
||||
assert.equal(f.c.state.results!.capturedAt, '2026-10-17T10:00:00.000Z')
|
||||
assert.ok(!JSON.stringify(f.c.state.results).includes('SECRET'))
|
||||
assert.throws(() => f.c.setResults(snapshot('a'.repeat(64))), /другой игре/)
|
||||
f.c.selectPackage(null)
|
||||
assert.equal(f.c.state.results, null)
|
||||
})
|
||||
test('не принимает повреждённые снимки, повторные команды и неизвестные коды ответов', () => {
|
||||
const f = fixture()
|
||||
for (const mutate of [
|
||||
(d: ReturnType<typeof snapshot>) => {
|
||||
d.capturedAt = 'invalid'
|
||||
},
|
||||
(d: ReturnType<typeof snapshot>) => {
|
||||
d.teams[0]!.totalScore = NaN
|
||||
},
|
||||
(d: ReturnType<typeof snapshot>) => {
|
||||
d.teams[0]!.answers[0]!.questionCode = 'unknown'
|
||||
},
|
||||
(d: ReturnType<typeof snapshot>) => {
|
||||
d.teams.push(d.teams[0]!)
|
||||
},
|
||||
(d: ReturnType<typeof snapshot>) => {
|
||||
d.questions.push(d.questions[0]!)
|
||||
},
|
||||
]) {
|
||||
const data = snapshot(f.key)
|
||||
mutate(data)
|
||||
assert.throws(() => parseResults(data, f.key))
|
||||
}
|
||||
const command = { session: f.c.state.session, sequence: 1, action: 'timer-start' }
|
||||
f.c.command(command)
|
||||
f.advance(500)
|
||||
f.c.command(command)
|
||||
f.c.tick()
|
||||
assert.equal(f.c.state.timer.remainingMs, 299500)
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ResultsSnapshot } from '../src/types/screen.js'
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new Error('Некорректный снимок результатов')
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
function list(value: unknown): unknown[] {
|
||||
if (!Array.isArray(value)) throw new Error('Некорректный список результатов')
|
||||
return value
|
||||
}
|
||||
function text(value: unknown): string {
|
||||
if (typeof value !== 'string') throw new Error('Некорректный текст результатов')
|
||||
return value
|
||||
}
|
||||
function integer(value: unknown, minimum = -2147483648): number {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum)
|
||||
throw new Error('Некорректное число результатов')
|
||||
return value
|
||||
}
|
||||
// Сохраняем только поля публичной таблицы, без участников, паролей и токенов.
|
||||
export function parseResults(value: unknown, packageKey: string): ResultsSnapshot {
|
||||
const data = object(value)
|
||||
if (data.packageKey !== packageKey || !/^[a-f0-9]{64}$/.test(packageKey))
|
||||
throw new Error('Результаты относятся к другой игре')
|
||||
const captured = Date.parse(text(data.capturedAt))
|
||||
if (!Number.isFinite(captured)) throw new Error('Некорректное время снимка результатов')
|
||||
const questions = list(data.questions).map((value) => {
|
||||
const q = object(value)
|
||||
return { code: text(q.code), text: text(q.text) }
|
||||
})
|
||||
const codes = new Set(questions.map((q) => q.code))
|
||||
if (codes.size !== questions.length || codes.has(''))
|
||||
throw new Error('Повтор или отсутствие кода вопроса')
|
||||
const teams = list(data.teams).map((value) => {
|
||||
const team = object(value)
|
||||
const answers = list(team.answers).map((value) => {
|
||||
const a = object(value)
|
||||
return { questionCode: text(a.questionCode), score: integer(a.score) }
|
||||
})
|
||||
if (
|
||||
new Set(answers.map((a) => a.questionCode)).size !== answers.length ||
|
||||
answers.some((a) => !codes.has(a.questionCode))
|
||||
)
|
||||
throw new Error('Некорректные ответы результатов')
|
||||
return {
|
||||
id: text(team.id),
|
||||
name: text(team.name),
|
||||
place: integer(team.place, 1),
|
||||
totalScore: integer(team.totalScore),
|
||||
actionsCount: integer(team.actionsCount, 0),
|
||||
answers,
|
||||
}
|
||||
})
|
||||
if (teams.some((team) => !team.id) || new Set(teams.map((t) => t.id)).size !== teams.length)
|
||||
throw new Error('Повтор или отсутствие команды')
|
||||
return { packageKey, capturedAt: new Date(captured).toISOString(), questions, teams }
|
||||
}
|
||||
+216
-10
@@ -1,7 +1,7 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { app, BrowserWindow, screen } = require('electron')
|
||||
const { app, BrowserWindow, screen, nativeImage } = require('electron')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { mkdtempSync, readFileSync } = require('node:fs')
|
||||
const { mkdtempSync, readFileSync, writeFileSync } = require('node:fs')
|
||||
const root = resolve(__dirname, '..')
|
||||
app.setAppPath(root)
|
||||
app.setPath('userData', mkdtempSync(join(require('node:os').tmpdir(), 'screen-smoke-')))
|
||||
@@ -11,7 +11,13 @@ let targetDisplay
|
||||
let isSyntheticDisplay = false
|
||||
const boundsBeforeFullscreen = new Map()
|
||||
const fullscreenEntered = new Set()
|
||||
const captures = []
|
||||
app.on('browser-window-created', (_event, window) => {
|
||||
const capturePage = window.webContents.capturePage.bind(window.webContents)
|
||||
window.webContents.capturePage = (...args) => {
|
||||
captures.push({ id: window.id, time: performance.now() })
|
||||
return capturePage(...args)
|
||||
}
|
||||
window.on('enter-full-screen', () => fullscreenEntered.add(window.id))
|
||||
let requestedBounds
|
||||
const setBounds = window.setBounds.bind(window)
|
||||
@@ -74,17 +80,40 @@ const seeded = (async () => {
|
||||
const { buildScreenPackage } = require(root + '/dist-electron/electron/screen-package.js')
|
||||
const audio = readFileSync(root + '/fixtures/audio.wav')
|
||||
const video = readFileSync(root + '/fixtures/video.mp4')
|
||||
const image = (color) =>
|
||||
nativeImage
|
||||
.createFromBitmap(Buffer.alloc(320 * 180 * 4).fill(Buffer.from(color)), {
|
||||
width: 320,
|
||||
height: 180,
|
||||
})
|
||||
.toPNG()
|
||||
const cover = image([30, 60, 80, 255])
|
||||
const background = image([70, 40, 20, 255])
|
||||
const pkg = buildScreenPackage('https://offline.invalid', 7, {
|
||||
game: {
|
||||
game: {
|
||||
ID: 7,
|
||||
name: 'Offline smoke',
|
||||
scenario: {
|
||||
story: { introduction: { audio: 'intro.wav' }, conclusion: { video: 'ending.mp4' } },
|
||||
image: 'cover.png',
|
||||
story: {
|
||||
background: 'background.png',
|
||||
introduction: { audio: 'intro.wav' },
|
||||
conclusion: { video: 'ending.mp4' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plan: {},
|
||||
plan: {
|
||||
plan: {
|
||||
steps: Array.from({ length: 8 }, (_, index) => ({
|
||||
mode: index + 1,
|
||||
text: index === 7 ? 'Привет, детективы!\nКириллица: Ёжик и улика №7' : '',
|
||||
timerLabel: 'Перерыв',
|
||||
timerDurationSeconds: index === 6 ? '2' : '0',
|
||||
})),
|
||||
},
|
||||
},
|
||||
music: {
|
||||
gamePlaylistId: 1,
|
||||
playlists: [
|
||||
@@ -102,14 +131,22 @@ const seeded = (async () => {
|
||||
const store = new PackageStore(join(app.getPath('userData'), 'packages'), {
|
||||
fetch: async (input) => {
|
||||
const url = String(input)
|
||||
const bytes = url.endsWith('intro.wav')
|
||||
? audio
|
||||
: url.endsWith('.mp4')
|
||||
? video
|
||||
: musicWav(url.endsWith('two.wav') ? 660 : 330)
|
||||
const bytes = url.endsWith('cover.png')
|
||||
? cover
|
||||
: url.endsWith('background.png')
|
||||
? background
|
||||
: url.endsWith('intro.wav')
|
||||
? audio
|
||||
: url.endsWith('.mp4')
|
||||
? video
|
||||
: musicWav(url.endsWith('two.wav') ? 660 : 330)
|
||||
return new Response(bytes, {
|
||||
headers: {
|
||||
'Content-Type': url.endsWith('.mp4') ? 'video/mp4' : 'audio/wav',
|
||||
'Content-Type': url.endsWith('.png')
|
||||
? 'image/png'
|
||||
: url.endsWith('.mp4')
|
||||
? 'video/mp4'
|
||||
: 'audio/wav',
|
||||
'Content-Length': String(bytes.length),
|
||||
},
|
||||
})
|
||||
@@ -321,6 +358,172 @@ app.whenReady().then(async () => {
|
||||
"Array.from(document.querySelectorAll('audio,video')).every(m => !m.getAttribute('src') || m.src.startsWith('screen-app://app/packages/'))",
|
||||
),
|
||||
)
|
||||
await command({ action: 'play', channel: 'music' })
|
||||
const modes = []
|
||||
const sceneReady = (mode) =>
|
||||
poll(async () => {
|
||||
const s = await state()
|
||||
if (s.scene.status === 'error') throw new Error(s.scene.error)
|
||||
return s.scene.mode === mode && s.scene.status === 'ready' && s
|
||||
}, `scene ${mode} ready`)
|
||||
for (let index = 0; index < 8; index++) {
|
||||
await command({ action: 'step', index })
|
||||
const s = await sceneReady(index + 1)
|
||||
modes.push(s.scene.mode)
|
||||
assert.equal(s.scene.stepIndex, index)
|
||||
assert.equal(s.channels.main.desired, 'stopped')
|
||||
assert.equal(s.channels.music.desired, 'playing')
|
||||
assert.equal(
|
||||
await inspect('document.querySelectorAll("button,input,textarea,select").length'),
|
||||
0,
|
||||
)
|
||||
if (index === 0)
|
||||
assert.equal(await inspect('document.querySelector("img.cover").naturalWidth'), 320)
|
||||
else assert.equal(await inspect('document.querySelector("img.background").naturalWidth'), 320)
|
||||
if (index === 2) assert.ok(await inspect('document.body.textContent.includes("Успехов")'))
|
||||
if (index === 4)
|
||||
assert.ok(await inspect('document.body.textContent.includes("Спасибо за игру!")'))
|
||||
if (index === 5)
|
||||
assert.ok(
|
||||
await inspect('document.body.textContent.includes("Результаты пока не загружены")'),
|
||||
)
|
||||
if (index === 6) {
|
||||
assert.equal(s.timer.status, 'stopped')
|
||||
assert.equal(await inspect('document.querySelector(".timer strong").textContent'), '00:02')
|
||||
}
|
||||
if (index === 7)
|
||||
assert.ok(await inspect('document.body.textContent.includes("Кириллица: Ёжик и улика №7")'))
|
||||
}
|
||||
const publishedText = (await state()).scene.text
|
||||
await evaluate(
|
||||
`(() => { const input = document.querySelector('#scene-text'); input.value = 'Черновик ещё не показан'; input.dispatchEvent(new Event('input', {bubbles:true})) })()`,
|
||||
)
|
||||
await delay(100)
|
||||
assert.equal((await state()).scene.text, publishedText)
|
||||
const safeText = 'Привет\n<script>window.__unsafe = true</script>'
|
||||
await command({ action: 'text', text: safeText })
|
||||
await sceneReady(8)
|
||||
assert.ok(await inspect(`document.querySelector('.text').textContent.includes('<script>')`))
|
||||
assert.equal(await inspect('typeof window.__unsafe'), 'undefined')
|
||||
assert.equal(await inspect('document.fonts.check("16px system-ui", "Кириллица Ёжик №7")'), true)
|
||||
await command({ action: 'timer-set', label: 'Перерыв', durationSeconds: 2 })
|
||||
await sceneReady(7)
|
||||
await command({ action: 'timer-start' })
|
||||
await delay(300)
|
||||
await command({ action: 'timer-pause' })
|
||||
const remaining = (await state()).timer.remainingMs
|
||||
await delay(300)
|
||||
assert.equal((await state()).timer.remainingMs, remaining)
|
||||
await command({ action: 'timer-resume' })
|
||||
await command({ action: 'mode', mode: 1 })
|
||||
await sceneReady(1)
|
||||
await poll(
|
||||
async () => (await state()).timer.status === 'finished',
|
||||
'timer finishes while hidden',
|
||||
)
|
||||
await command({ action: 'mode', mode: 7 })
|
||||
await sceneReady(7)
|
||||
assert.equal(await inspect('document.querySelector(".timer strong").textContent'), '00:00')
|
||||
await command({ action: 'timer-reset' })
|
||||
await poll(
|
||||
async () =>
|
||||
(await inspect('document.querySelector(".timer strong").textContent')) === '00:02',
|
||||
'timer reset DOM',
|
||||
)
|
||||
control.focus()
|
||||
await poll(
|
||||
() =>
|
||||
evaluate(
|
||||
`(() => { const image = document.querySelector('[data-testid=preview]'); return !!image && image.complete && image.naturalWidth > 0 && image.naturalWidth <= 960 && image.src.startsWith('data:image/png') })()`,
|
||||
),
|
||||
'native preview image without network',
|
||||
)
|
||||
assert.equal(await evaluate('document.querySelectorAll("audio,video").length'), 0)
|
||||
await assert.rejects(inspect('window.screenBridge.preview()'))
|
||||
const sceneBefore = (await state()).scene
|
||||
await evaluate(
|
||||
`(async () => { const s = await window.screenBridge.state(); window.screenBridge.reportScene({session:s.session, revision:s.scene.revision, status:'error',error:'forged'}) })()`,
|
||||
)
|
||||
await delay(50)
|
||||
assert.equal((await state()).scene.status, sceneBefore.status)
|
||||
const backgroundId = offlinePackage.package.media.background
|
||||
const backgroundPath = join(
|
||||
app.getPath('userData'),
|
||||
'packages',
|
||||
offlinePackage.key,
|
||||
`v-${offlinePackage.version}`,
|
||||
'assets',
|
||||
backgroundId,
|
||||
)
|
||||
const originalImage = readFileSync(backgroundPath)
|
||||
writeFileSync(backgroundPath, Buffer.alloc(originalImage.length))
|
||||
await command({ action: 'text', text: 'Проверка изображения' })
|
||||
await poll(
|
||||
async () => (await state()).scene.status === 'error',
|
||||
'broken image error on control',
|
||||
)
|
||||
assert.equal(await inspect('document.body.textContent.includes("Не удалось")'), false)
|
||||
assert.equal((await state()).channels.music.desired, 'playing')
|
||||
writeFileSync(backgroundPath, originalImage)
|
||||
await command({ action: 'mode', mode: 8 })
|
||||
await sceneReady(8)
|
||||
await command({ action: 'stop', channel: 'music' })
|
||||
const nativeCaptures = captures.filter((capture) => capture.id === output.id)
|
||||
assert.ok(nativeCaptures.length > 0)
|
||||
for (let index = 1; index < nativeCaptures.length; index++)
|
||||
assert.ok(
|
||||
nativeCaptures[index].time - nativeCaptures[index - 1].time >= 490,
|
||||
'preview limited to 2 FPS',
|
||||
)
|
||||
// Фикстура проверяет таблицу и прокрутку в renderer, не создавая результаты в main.
|
||||
await command({ action: 'mode', mode: 6 })
|
||||
await sceneReady(6)
|
||||
const realState = await state()
|
||||
const resultFixture = {
|
||||
...realState,
|
||||
revision: realState.revision + 1000000,
|
||||
scene: { ...realState.scene, revision: realState.scene.revision + 1 },
|
||||
results: {
|
||||
packageKey: offlinePackage.key,
|
||||
capturedAt: '2026-10-17T10:00:00.000Z',
|
||||
questions: [
|
||||
{ code: 'К1', text: 'Первый вопрос' },
|
||||
{ code: 'К2', text: 'Второй вопрос' },
|
||||
],
|
||||
teams: Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `fixture-${i}`,
|
||||
name: `Детективы №${i + 1}`,
|
||||
place: i + 1,
|
||||
totalScore: 10,
|
||||
actionsCount: 3,
|
||||
answers: [{ questionCode: 'К1', score: 10 }],
|
||||
})),
|
||||
},
|
||||
}
|
||||
output.webContents.send('state', resultFixture)
|
||||
await poll(
|
||||
() => inspect('document.querySelectorAll("tbody tr").length === 50'),
|
||||
'results table fixture',
|
||||
)
|
||||
assert.equal(
|
||||
await inspect('document.querySelector("tbody tr td:nth-child(4)").textContent'),
|
||||
'0',
|
||||
)
|
||||
const scroll = await inspect('document.querySelector(".results-scroll").scrollTop')
|
||||
assert.ok(scroll > 0)
|
||||
await delay(250)
|
||||
const afterScroll = await inspect('document.querySelector(".results-scroll").scrollTop')
|
||||
assert.ok(afterScroll < scroll)
|
||||
output.webContents.send('state', {
|
||||
...resultFixture,
|
||||
revision: resultFixture.revision + 1,
|
||||
results: structuredClone(resultFixture.results),
|
||||
})
|
||||
await delay(250)
|
||||
assert.ok(
|
||||
await inspect(`document.querySelector('.results-scroll').scrollTop < ${afterScroll}`),
|
||||
'media state updates do not restart result scrolling',
|
||||
)
|
||||
const samples = (await state()).samples
|
||||
const oldSession = (await state()).session
|
||||
output.close()
|
||||
@@ -346,6 +549,7 @@ app.whenReady().then(async () => {
|
||||
)
|
||||
assert.equal((await state()).channels.main.desired, 'stopped')
|
||||
assert.equal(await evaluate('typeof window.screenBridge.lan'), 'undefined')
|
||||
assert.equal((await state()).results, null)
|
||||
console.log(
|
||||
'SCREEN_SMOKE_PASS',
|
||||
JSON.stringify({
|
||||
@@ -355,6 +559,8 @@ app.whenReady().then(async () => {
|
||||
files: offlinePackage.files.length,
|
||||
},
|
||||
display: { id: targetDisplay.id, synthetic: isSyntheticDisplay },
|
||||
modes,
|
||||
previewCaptures: nativeCaptures.length,
|
||||
fixturePosition,
|
||||
pausedAt,
|
||||
samples,
|
||||
|
||||
@@ -4,13 +4,26 @@ import {
|
||||
NButton,
|
||||
NCard,
|
||||
NInputNumber,
|
||||
NInput,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NSwitch,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { Channel, HostInfo, Intent, PackageSummary, Playback, State } from '../types/screen'
|
||||
import type {
|
||||
Channel,
|
||||
HostInfo,
|
||||
Intent,
|
||||
PackageSummary,
|
||||
Playback,
|
||||
PreviewFrame,
|
||||
ScreenMode,
|
||||
State,
|
||||
} from '../types/screen'
|
||||
|
||||
import { modeLabels } from '../utils/presentation'
|
||||
import { useScreenTimer } from '../composables/useScreenTimer'
|
||||
|
||||
const message = useMessage()
|
||||
const bridge = window.screenBridge
|
||||
@@ -20,6 +33,68 @@ const host = ref<HostInfo>({ displays: [] })
|
||||
const display = ref<number | null>(null)
|
||||
const positions = ref<Record<Channel, number | null>>({ main: 10, music: 10 })
|
||||
const isBusy = ref(false)
|
||||
const textDraft = ref('')
|
||||
const timerLabel = ref('Перерыв')
|
||||
const timerSeconds = ref<number | null>(300)
|
||||
const timerDisplay = useScreenTimer(() => state.value)
|
||||
const preview = ref<PreviewFrame | null>(null)
|
||||
const modeOptions = Object.entries(modeLabels).map(([key, label]) => ({
|
||||
label,
|
||||
value: Number(key) as ScreenMode,
|
||||
}))
|
||||
const previewURL = computed(() =>
|
||||
preview.value?.session === state.value?.session &&
|
||||
preview.value?.revision === state.value?.scene.revision
|
||||
? preview.value?.dataURL
|
||||
: 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 = {
|
||||
idle: 'Окно показа закрыто',
|
||||
pending: 'Подготовка показа',
|
||||
ready: 'Показ готов',
|
||||
error: 'Не удалось подготовить показ',
|
||||
}
|
||||
const timerLabels = {
|
||||
stopped: 'Не запущен',
|
||||
running: 'Идёт',
|
||||
paused: 'Пауза',
|
||||
finished: 'Завершён',
|
||||
}
|
||||
let previewTimer: ReturnType<typeof setInterval> | undefined
|
||||
let previewBusy = false
|
||||
async function refreshPreview() {
|
||||
if (
|
||||
!bridge ||
|
||||
previewBusy ||
|
||||
!state.value?.outputOpen ||
|
||||
document.visibilityState !== 'visible' ||
|
||||
state.value.scene.status !== 'ready'
|
||||
)
|
||||
return
|
||||
previewBusy = true
|
||||
try {
|
||||
const frame = await bridge.preview()
|
||||
if (
|
||||
frame &&
|
||||
state.value.outputOpen &&
|
||||
frame.session === state.value.session &&
|
||||
frame.revision === state.value.scene.revision &&
|
||||
document.visibilityState === 'visible'
|
||||
)
|
||||
preview.value = frame
|
||||
} catch {
|
||||
/* Показ продолжает работать и при недоступности кадра предпросмотра. */
|
||||
} finally {
|
||||
previewBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
const channels = ['main', 'music'] as const
|
||||
const playlist = computed(() =>
|
||||
state.value?.catalog.playlists.find((p) => p.id === state.value?.channels.music.playlistId),
|
||||
@@ -43,6 +118,22 @@ let session = ''
|
||||
let sequence = 0
|
||||
function acceptState(value: State) {
|
||||
if (state.value && value.revision < state.value.revision) return
|
||||
if (value.session !== state.value?.session || value.scene.text !== state.value?.scene.text)
|
||||
textDraft.value = value.scene.text
|
||||
if (
|
||||
value.session !== state.value?.session ||
|
||||
value.timer.label !== state.value?.timer.label ||
|
||||
value.timer.durationMs !== state.value?.timer.durationMs
|
||||
) {
|
||||
timerLabel.value = value.timer.label
|
||||
timerSeconds.value = value.timer.durationMs / 1000
|
||||
}
|
||||
if (
|
||||
!value.outputOpen ||
|
||||
value.session !== state.value?.session ||
|
||||
value.scene.revision !== state.value?.scene.revision
|
||||
)
|
||||
preview.value = null
|
||||
if (session !== value.session) {
|
||||
session = value.session
|
||||
sequence = value.sequence
|
||||
@@ -100,6 +191,9 @@ onMounted(async () => {
|
||||
packages.value = await bridge.packages()
|
||||
await refreshHost()
|
||||
})
|
||||
previewTimer = setInterval(() => {
|
||||
void refreshPreview()
|
||||
}, 500)
|
||||
let refreshing = false
|
||||
timer = setInterval(() => {
|
||||
if (refreshing) return
|
||||
@@ -113,6 +207,7 @@ onMounted(async () => {
|
||||
})
|
||||
onUnmounted(() => {
|
||||
clearInterval(timer)
|
||||
clearInterval(previewTimer)
|
||||
unsubscribe?.()
|
||||
})
|
||||
</script>
|
||||
@@ -154,6 +249,144 @@ onUnmounted(() => {
|
||||
<n-alert v-if="!state.outputOpen" type="info"
|
||||
>Откройте показ, чтобы подготовить медиа и включить воспроизведение.</n-alert
|
||||
>
|
||||
<section class="presentation-grid">
|
||||
<n-card title="План показа">
|
||||
<n-space vertical>
|
||||
<n-select
|
||||
:value="state.scene.stepIndex"
|
||||
:options="stepOptions"
|
||||
:disabled="isBusy || !state.catalog.steps.length"
|
||||
placeholder="Выберите шаг"
|
||||
:input-props="{ 'aria-label': 'Шаг плана' }"
|
||||
@update:value="send({ action: 'step', index: $event })"
|
||||
/>
|
||||
<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>
|
||||
<n-select
|
||||
:value="state.scene.mode"
|
||||
:options="modeOptions"
|
||||
:disabled="isBusy"
|
||||
:input-props="{ 'aria-label': 'Режим показа' }"
|
||||
@update:value="send({ action: 'mode', mode: $event })"
|
||||
/>
|
||||
<p role="status">{{ sceneLabels[state.scene.status] }}</p>
|
||||
<n-alert v-if="state.scene.error" type="error">{{ state.scene.error }}</n-alert>
|
||||
<n-button
|
||||
v-if="state.scene.status === 'error'"
|
||||
@click="send({ action: 'mode', mode: state.scene.mode })"
|
||||
>Повторить подготовку</n-button
|
||||
>
|
||||
<p v-if="state.scene.mode === 6">
|
||||
{{
|
||||
state.results
|
||||
? `Снимок результатов: ${new Date(state.results.capturedAt).toLocaleString('ru-RU')}`
|
||||
: 'Результаты пока не загружены'
|
||||
}}
|
||||
</p>
|
||||
</n-space>
|
||||
</n-card>
|
||||
<n-card title="Текущий показ">
|
||||
<img
|
||||
v-if="previewURL"
|
||||
class="preview"
|
||||
data-testid="preview"
|
||||
:src="previewURL"
|
||||
alt="Предпросмотр текущего показа"
|
||||
/>
|
||||
<div v-else class="preview-empty">
|
||||
{{
|
||||
!state.outputOpen
|
||||
? 'Окно показа закрыто'
|
||||
: state.scene.status === 'pending'
|
||||
? 'Подготовка показа…'
|
||||
: 'Предпросмотр недоступен'
|
||||
}}
|
||||
</div>
|
||||
<p>Кадр реального окна показа, без второго звука.</p>
|
||||
</n-card>
|
||||
</section>
|
||||
<n-card title="Текст и таймер">
|
||||
<n-space vertical>
|
||||
<label for="scene-text">Текст для показа</label>
|
||||
<n-input
|
||||
v-model:value="textDraft"
|
||||
type="textarea"
|
||||
:maxlength="10000"
|
||||
:autosize="{ minRows: 2, maxRows: 6 }"
|
||||
:input-props="{ id: 'scene-text' }"
|
||||
/>
|
||||
<n-button
|
||||
:disabled="isBusy || !textDraft.trim()"
|
||||
@click="send({ action: 'text', text: textDraft })"
|
||||
>Показать текст</n-button
|
||||
>
|
||||
<label for="timer-label">Подпись таймера</label>
|
||||
<n-input
|
||||
v-model:value="timerLabel"
|
||||
:maxlength="200"
|
||||
:input-props="{ id: 'timer-label' }"
|
||||
/>
|
||||
<label for="timer-seconds">Длительность, секунды</label>
|
||||
<n-input-number
|
||||
v-model:value="timerSeconds"
|
||||
:min="1"
|
||||
:max="86400"
|
||||
:precision="0"
|
||||
:input-props="{ id: 'timer-seconds' }"
|
||||
/>
|
||||
<n-button
|
||||
:disabled="isBusy || !timerSeconds"
|
||||
@click="
|
||||
send({ action: 'timer-set', label: timerLabel, durationSeconds: timerSeconds ?? 0 })
|
||||
"
|
||||
>Показать таймер</n-button
|
||||
>
|
||||
<p role="status" class="timer-status">
|
||||
{{ state.timer.label }} · {{ timerDisplay }} · {{ timerLabels[state.timer.status] }}
|
||||
</p>
|
||||
<p v-if="state.scene.mode !== 7 && state.timer.status === 'running'">
|
||||
Таймер продолжает отсчёт в фоне.
|
||||
</p>
|
||||
<n-space>
|
||||
<n-button
|
||||
:disabled="isBusy || state.timer.status === 'running'"
|
||||
@click="send({ action: 'timer-start' })"
|
||||
>Запустить таймер</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="isBusy || state.timer.status !== 'running'"
|
||||
@click="send({ action: 'timer-pause' })"
|
||||
>Пауза таймера</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="isBusy || state.timer.status !== 'paused'"
|
||||
@click="send({ action: 'timer-resume' })"
|
||||
>Продолжить таймер</n-button
|
||||
>
|
||||
<n-button :disabled="isBusy" @click="send({ action: 'timer-reset' })"
|
||||
>Сбросить таймер</n-button
|
||||
>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
<label class="mute"
|
||||
>Выключить весь звук
|
||||
<n-switch
|
||||
@@ -286,10 +519,36 @@ onUnmounted(() => {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin: auto;
|
||||
max-width: 900px;
|
||||
max-width: 1280px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.presentation-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
|
||||
}
|
||||
|
||||
.preview {
|
||||
background: var(--brand-background);
|
||||
display: block;
|
||||
max-height: 50vh;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
align-items: center;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--brand-background);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.timer-status {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
+187
-12
@@ -1,22 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
||||
import type { State } from '../types/screen'
|
||||
import { MediaPlayer } from '../utils/media-player'
|
||||
import { MediaPlayer, waitUntil } from '../utils/media-player'
|
||||
import { useScreenTimer } from '../composables/useScreenTimer'
|
||||
import ResultsTable from './ResultsTable.vue'
|
||||
|
||||
const audio = ref<HTMLAudioElement | null>(null)
|
||||
const video = ref<HTMLVideoElement | null>(null)
|
||||
const musicA = ref<HTMLAudioElement | null>(null)
|
||||
const musicB = ref<HTMLAudioElement | null>(null)
|
||||
const showVideo = ref(false)
|
||||
const surface = ref<HTMLElement | null>(null)
|
||||
const state = shallowRef<State | null>(null)
|
||||
const imageError = ref(false)
|
||||
const timerDisplay = useScreenTimer(() => state.value)
|
||||
const bridge = window.screenBridge!
|
||||
let player: MediaPlayer | undefined
|
||||
let unsubscribe: (() => void) | undefined
|
||||
let revision = -1
|
||||
function apply(state: State) {
|
||||
if (state.revision <= revision) return
|
||||
revision = state.revision
|
||||
showVideo.value = state.channels.main.source?.kind === 'video'
|
||||
player?.apply(state)
|
||||
let sceneToken = ''
|
||||
let sceneAbort: AbortController | null = null
|
||||
// Новый адрес исключает повторное использование уже декодированной картинки Chromium.
|
||||
function imageURL(url: string) {
|
||||
const result = new URL(url)
|
||||
result.searchParams.set('scene', `${state.value?.session}:${state.value?.scene.revision}`)
|
||||
return result.href
|
||||
}
|
||||
function waitImage(image: HTMLImageElement, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const finish = (error?: string) => {
|
||||
clearTimeout(timeout)
|
||||
image.removeEventListener('load', loaded)
|
||||
image.removeEventListener('error', failed)
|
||||
signal.removeEventListener('abort', aborted)
|
||||
if (error) reject(new Error(error))
|
||||
else resolve()
|
||||
}
|
||||
const loaded = () =>
|
||||
finish(image.naturalWidth ? undefined : 'Не удалось подготовить изображение')
|
||||
const failed = () => finish('Не удалось подготовить изображение')
|
||||
const aborted = () => finish('Подготовка отменена')
|
||||
const timeout = setTimeout(() => finish('Изображение не подготовлено за 10 секунд'), 10000)
|
||||
image.addEventListener('load', loaded)
|
||||
image.addEventListener('error', failed)
|
||||
signal.addEventListener('abort', aborted, { once: true })
|
||||
if (signal.aborted) aborted()
|
||||
else if (image.complete) loaded()
|
||||
})
|
||||
}
|
||||
async function prepareScene(snapshot: State) {
|
||||
sceneAbort?.abort()
|
||||
const abort = new AbortController()
|
||||
sceneAbort = abort
|
||||
const current = () =>
|
||||
!abort.signal.aborted &&
|
||||
state.value?.session === snapshot.session &&
|
||||
state.value.scene.revision === snapshot.scene.revision
|
||||
imageError.value = false
|
||||
try {
|
||||
await nextTick()
|
||||
if (!current()) return
|
||||
await Promise.all(
|
||||
Array.from(surface.value?.querySelectorAll('img') ?? []).map((image) =>
|
||||
waitImage(image, abort.signal),
|
||||
),
|
||||
)
|
||||
const media =
|
||||
snapshot.scene.mode === 2 ? audio.value : snapshot.scene.mode === 4 ? video.value : null
|
||||
if (media && snapshot.channels.main.source)
|
||||
await waitUntil(media, 'canplay', () => media.readyState >= 3, abort.signal)
|
||||
if (current())
|
||||
bridge.reportScene({
|
||||
session: snapshot.session,
|
||||
revision: snapshot.scene.revision,
|
||||
status: 'ready',
|
||||
})
|
||||
} catch (error) {
|
||||
if (!current()) return
|
||||
imageError.value = true
|
||||
bridge.reportScene({
|
||||
session: snapshot.session,
|
||||
revision: snapshot.scene.revision,
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Не удалось подготовить сцену',
|
||||
})
|
||||
}
|
||||
}
|
||||
function apply(value: State) {
|
||||
if (value.revision <= revision) return
|
||||
revision = value.revision
|
||||
state.value = value
|
||||
player?.apply(value)
|
||||
const token = `${value.session}:${value.scene.revision}`
|
||||
if (token !== sceneToken) {
|
||||
sceneToken = token
|
||||
void prepareScene(value)
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
player = new MediaPlayer(
|
||||
@@ -27,15 +105,61 @@ onMounted(async () => {
|
||||
apply(await bridge.state())
|
||||
})
|
||||
onUnmounted(() => {
|
||||
sceneAbort?.abort()
|
||||
unsubscribe?.()
|
||||
player?.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="output">
|
||||
<main ref="surface" class="output" :data-mode="state?.scene.mode">
|
||||
<img
|
||||
v-if="state?.catalog.background && state.scene.mode !== 1"
|
||||
:key="`${state.session}:${state.scene.revision}:background`"
|
||||
class="background"
|
||||
:class="{ failed: imageError }"
|
||||
:src="imageURL(state.catalog.background)"
|
||||
alt=""
|
||||
/>
|
||||
<div v-if="state" class="scene">
|
||||
<template v-if="state.scene.mode === 1">
|
||||
<img
|
||||
v-if="state.catalog.cover"
|
||||
:key="`${state.session}:${state.scene.revision}:cover`"
|
||||
class="cover"
|
||||
:class="{ failed: imageError }"
|
||||
:src="imageURL(state.catalog.cover)"
|
||||
alt="Обложка дела"
|
||||
/>
|
||||
<h1 v-else class="message">{{ state.catalog.name }}</h1>
|
||||
</template>
|
||||
<p v-else-if="state.scene.mode === 2 && !state.channels.main.source" class="message">
|
||||
Вступление не задано
|
||||
</p>
|
||||
<p v-else-if="state.scene.mode === 3" class="message">Успехов</p>
|
||||
<p v-else-if="state.scene.mode === 4 && !state.channels.main.source" class="message">
|
||||
Видео закрывашки не задано
|
||||
</p>
|
||||
<p v-else-if="state.scene.mode === 5" class="message">Спасибо за игру!</p>
|
||||
<ResultsTable
|
||||
v-else-if="state.scene.mode === 6"
|
||||
:snapshot="state.results"
|
||||
:revision="state.scene.revision"
|
||||
/>
|
||||
<section v-else-if="state.scene.mode === 7" class="timer">
|
||||
<h1>{{ state.timer.label }}</h1>
|
||||
<strong>{{ timerDisplay }}</strong>
|
||||
</section>
|
||||
<p v-else-if="state.scene.mode === 8" class="message text">{{ state.scene.text }}</p>
|
||||
</div>
|
||||
<audio ref="audio" data-channel="main-audio" preload="auto" />
|
||||
<video v-show="showVideo" ref="video" data-channel="main-video" preload="auto" playsinline />
|
||||
<video
|
||||
v-show="state?.scene.mode === 4 && state.channels.main.source?.kind === 'video'"
|
||||
ref="video"
|
||||
data-channel="main-video"
|
||||
preload="auto"
|
||||
playsinline
|
||||
/>
|
||||
<audio ref="musicA" data-channel="music-a" preload="auto" />
|
||||
<audio ref="musicB" data-channel="music-b" preload="auto" />
|
||||
</main>
|
||||
@@ -46,12 +170,63 @@ onUnmounted(() => {
|
||||
background: var(--brand-background);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
video {
|
||||
.scene {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.background,
|
||||
.cover,
|
||||
video {
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.background {
|
||||
filter: brightness(45%);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover,
|
||||
video {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.failed {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: clamp(32px, 7vw, 140px);
|
||||
margin: 0;
|
||||
max-height: 90vh;
|
||||
max-width: 92vw;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.timer h1 {
|
||||
font-size: clamp(28px, 5vw, 80px);
|
||||
}
|
||||
|
||||
.timer strong {
|
||||
font-size: clamp(80px, 20vw, 300px);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import type { ResultsSnapshot } from '../types/screen'
|
||||
|
||||
const props = defineProps<{ snapshot: ResultsSnapshot | null; revision: number }>()
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
let frame = 0
|
||||
let generation = 0
|
||||
watch(
|
||||
() => props.revision,
|
||||
async () => {
|
||||
const current = ++generation
|
||||
cancelAnimationFrame(frame)
|
||||
await nextTick()
|
||||
if (current !== generation || !container.value || !props.snapshot) return
|
||||
const element = container.value
|
||||
const distance = Math.max(0, element.scrollHeight - element.clientHeight)
|
||||
const start = performance.now()
|
||||
element.scrollTop = distance
|
||||
const scroll = (now: number) => {
|
||||
if (current !== generation) return
|
||||
element.scrollTop = Math.max(0, distance - (now - start) * 0.032)
|
||||
if (element.scrollTop > 0) frame = requestAnimationFrame(scroll)
|
||||
}
|
||||
frame = requestAnimationFrame(scroll)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
onUnmounted(() => {
|
||||
generation++
|
||||
cancelAnimationFrame(frame)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="results">
|
||||
<h1>Результаты</h1>
|
||||
<p v-if="!snapshot" class="empty">Результаты пока не загружены</p>
|
||||
<p v-else-if="!snapshot.teams.length" class="empty">Нет результатов команд</p>
|
||||
<div v-else ref="container" class="results-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Место</th>
|
||||
<th>Команда</th>
|
||||
<th v-for="question in snapshot.questions" :key="question.code" :title="question.text">
|
||||
{{ question.code }}
|
||||
</th>
|
||||
<th>Сумма</th>
|
||||
<th>Ходы</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="team in snapshot.teams" :key="team.id">
|
||||
<td>{{ team.place }}</td>
|
||||
<td class="team-name">{{ team.name }}</td>
|
||||
<td v-for="question in snapshot.questions" :key="question.code">
|
||||
{{ team.answers.find((a) => a.questionCode === question.code)?.score ?? 0 }}
|
||||
</td>
|
||||
<td>{{ team.totalScore }}</td>
|
||||
<td>{{ team.actionsCount }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 90vh;
|
||||
max-width: 96vw;
|
||||
width: 96vw;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(28px, 4vw, 72px);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: clamp(24px, 3vw, 48px);
|
||||
}
|
||||
|
||||
.results-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
font-size: clamp(18px, 2.4vw, 48px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
border-bottom: 1px solid rgb(255 255 255 / 30%);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
th {
|
||||
background: rgb(0 0 0 / 70%);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.team-name {
|
||||
overflow-wrap: anywhere;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
import type { State, TimerState } from '../types/screen'
|
||||
import { formatTimer, remainingTime } from '../utils/presentation'
|
||||
|
||||
export function useScreenTimer(state: () => State | null) {
|
||||
const timer = shallowRef<TimerState | null>(null)
|
||||
const now = ref(performance.now())
|
||||
let syncedAt = now.value
|
||||
watch(
|
||||
[() => state()?.session, () => state()?.timer.revision],
|
||||
() => {
|
||||
syncedAt = performance.now()
|
||||
now.value = syncedAt
|
||||
timer.value = state()?.timer ?? null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
const tick = setInterval(() => {
|
||||
if (timer.value?.status === 'running') now.value = performance.now()
|
||||
}, 100)
|
||||
onUnmounted(() => clearInterval(tick))
|
||||
return computed(() =>
|
||||
formatTimer(timer.value ? remainingTime(timer.value, now.value - syncedAt) : 0),
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export type Intent =
|
||||
| { action: 'track'; channel: 'music'; trackIndex: number }
|
||||
| { action: 'next'; channel: 'music' }
|
||||
| { action: 'loop'; channel: 'music'; enabled: boolean }
|
||||
| { action: 'mode'; mode: ScreenMode }
|
||||
| { action: 'step'; index: number }
|
||||
| { action: 'text'; text: string }
|
||||
| { action: 'timer-set'; label: string; durationSeconds: number }
|
||||
| { action: 'timer-start' | 'timer-pause' | 'timer-resume' | 'timer-reset' }
|
||||
export type Command = Intent & { session: string; sequence: number }
|
||||
export interface MediaSource {
|
||||
id: string
|
||||
@@ -23,11 +28,60 @@ export interface Playlist {
|
||||
name: string
|
||||
tracks: { id: number; name: string; source: MediaSource }[]
|
||||
}
|
||||
export type ScreenMode = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
|
||||
export interface ScreenStep {
|
||||
mode: ScreenMode
|
||||
text: string
|
||||
timerLabel: string
|
||||
timerDurationSeconds: number
|
||||
}
|
||||
export interface SceneState {
|
||||
revision: number
|
||||
mode: ScreenMode
|
||||
stepIndex: number | null
|
||||
text: string
|
||||
status: 'idle' | 'pending' | 'ready' | 'error'
|
||||
error: string
|
||||
}
|
||||
export interface TimerState {
|
||||
revision: number
|
||||
label: string
|
||||
durationMs: number
|
||||
remainingMs: number
|
||||
status: 'stopped' | 'running' | 'paused' | 'finished'
|
||||
}
|
||||
export interface SceneReport {
|
||||
session: string
|
||||
revision: number
|
||||
status: 'ready' | 'error'
|
||||
error?: string
|
||||
}
|
||||
export interface PreviewFrame {
|
||||
session: string
|
||||
revision: number
|
||||
dataURL: string
|
||||
}
|
||||
export interface ResultsSnapshot {
|
||||
packageKey: string
|
||||
capturedAt: string
|
||||
questions: { code: string; text: string }[]
|
||||
teams: {
|
||||
id: string
|
||||
name: string
|
||||
place: number
|
||||
totalScore: number
|
||||
actionsCount: number
|
||||
answers: { questionCode: string; score: number }[]
|
||||
}[]
|
||||
}
|
||||
export interface Catalog {
|
||||
key: string | null
|
||||
name: string
|
||||
media: MediaSource[]
|
||||
playlists: Playlist[]
|
||||
cover: string | null
|
||||
background: string | null
|
||||
steps: ScreenStep[]
|
||||
}
|
||||
export interface ChannelState {
|
||||
operation: number
|
||||
@@ -50,6 +104,9 @@ export interface State {
|
||||
outputOpen: boolean
|
||||
catalog: Catalog
|
||||
muted: boolean
|
||||
scene: SceneState
|
||||
timer: TimerState
|
||||
results: ResultsSnapshot | null
|
||||
channels: Record<Channel, ChannelState>
|
||||
samples: { channel: Channel; media: MediaKind; milliseconds: number }[]
|
||||
}
|
||||
@@ -77,6 +134,8 @@ export interface PackageSummary {
|
||||
export interface ScreenBridge {
|
||||
command: (command: Command) => Promise<void>
|
||||
report: (report: Report) => void
|
||||
reportScene: (report: SceneReport) => void
|
||||
preview: () => Promise<PreviewFrame | null>
|
||||
state: () => Promise<State>
|
||||
subscribe: (listener: (state: State) => void) => () => void
|
||||
host: () => Promise<HostInfo>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Channel, ChannelState, Report, State } from '../types/screen.js'
|
||||
|
||||
function waitUntil(
|
||||
export function waitUntil(
|
||||
media: HTMLMediaElement,
|
||||
event: string,
|
||||
ready: () => boolean,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ScreenMode, TimerState } from '../types/screen.js'
|
||||
|
||||
export const modeLabels: Record<ScreenMode, string> = {
|
||||
1: 'Обложка',
|
||||
2: 'Вступление',
|
||||
3: 'Завершение вступления',
|
||||
4: 'Заключительное видео',
|
||||
5: 'Завершение показа',
|
||||
6: 'Результаты',
|
||||
7: 'Таймер',
|
||||
8: 'Текст',
|
||||
}
|
||||
export function remainingTime(timer: TimerState, elapsedMs: number): number {
|
||||
return Math.max(0, timer.remainingMs - (timer.status === 'running' ? Math.max(0, elapsedMs) : 0))
|
||||
}
|
||||
export function formatTimer(milliseconds: number): string {
|
||||
const seconds = Math.ceil(Math.max(0, milliseconds) / 1000)
|
||||
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
Reference in New Issue
Block a user