This commit is contained in:
2026-10-17 19:52:24 +07:00
parent a386f3d9e5
commit f5dc9c1e5b
17 changed files with 1652 additions and 59 deletions
+41
View File
@@ -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 — существующая авторизация, выбор игры/скачивание
через интерфейс, снимок результатов и получение команд существующего сервера.
+54 -7
View File
@@ -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 и не сохраняются в состоянии приложения.
+7 -21
View File
@@ -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 — Устойчивость на площадке
+46
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+284
View File
@@ -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)
})
+58
View File
@@ -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
View File
@@ -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,
+261 -2
View File
@@ -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
View File
@@ -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>
+113
View File
@@ -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>
+25
View File
@@ -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),
)
}
+59
View File
@@ -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 -1
View File
@@ -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,
+19
View File
@@ -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')}`
}