This commit is contained in:
2026-10-17 19:29:49 +07:00
parent dc0d82d88a
commit a386f3d9e5
14 changed files with 1852 additions and 421 deletions
+56 -8
View File
@@ -1,6 +1,7 @@
# Вечерний детектив — приложение площадки
Статус: SCREEN-001 в работе; технический стенд реализован, аппаратная приёмка открыта.
Статус: программная часть SCREEN-004 реализована; SCREEN-001 и SCREEN-004
остаются IN_PROGRESS до аппаратной приёмки.
SCREEN-002 и SCREEN-003 завершены: описание пакета через существующий API,
загрузка, проверка, восстановление и локальная выдача файлов.
Управление с телефона идёт через существующий сервер сайта; desktop получает
@@ -21,11 +22,11 @@ macOS Intel/Apple Silicon, Windows x64/ARM64 и Linux x64/ARM64.
общего правила хранения задач в `../docs/agent`. Не дублировать их там.
Идентификаторы `SCREEN-*` относятся только к этому приложению.
Для начала: `Выполнить задачу: SCREEN-001`.
Текущий этап: SCREEN-004; программная часть готова, аппаратная проверка открыта.
Новые задачи: `Новая задача: <описание>`.
Commit и push выполняет только пользователь. Вложенные `.git` не изменять.
## Запуск стенда SCREEN-001
## Запуск проигрывателя
Нужны Node.js 24.15+ и ffmpeg с libx264 в PATH.
В Linux приложение использует X11; для сессии Wayland нужен XWayland, чтобы
@@ -42,7 +43,8 @@ npm run dev
и назначьте экран компьютера основным. В пульте выберите
его и откройте показ. Аудио и видео — синтетические локальные файлы длительностью
60 секунд; бинарники находятся в игнорируемом fixtures/ и не входят в Git.
Стенд поддерживает пуск с начала, паузу, остановку и перемотку. Полные игровые
Тестовый режим сохраняет отдельный выбор WAV/видео; игровые пакеты выбираются
в разделе «Материалы показа». Полные игровые
режимы, интерфейс загрузки игр и авторизация пока не реализованы.
Управление телефоном через сервер, подключение игры и авторизация относятся к
@@ -123,13 +125,59 @@ URL, независимо от исходного имени файла. Лок
`screen-app://app/packages/<key>/<assetId>` выдаёт только ресурсы готового пакета,
с правильным MIME, запретом кеширования и поддержкой Range. Произвольные пути
и сетевые URL в протокол не передаются. Воспроизведение игровых пакетов подключается
в SCREEN-004; синтетический стенд пока использует прежние fixture-файлы.
и сетевые URL в протокол не передаются. Воспроизведение игровых пакетов подключено
в SCREEN-004; fixture-файлы доступны отдельно в тестовом режиме.
`npm test` включает настоящий временный HTTP-сервер: после скачивания сервер
отключается, новое хранилище проверяет пакет, локальная выдача читает все ресурсы.
`npm run smoke -- --no-sandbox --mute-audio --disable-gpu` дополнительно готовит
WAV-пакет в отдельном временном userData, открывает его повторно через основной
процесс и проверяет Range и воспроизведение. Эти флаги используются только в тесте.
пакет с WAV, H.264 и двумя музыкальными треками в отдельном временном userData,
открывает его повторно через основной процесс и проверяет пульт, Range, команды
и переходы плейлиста. Эти флаги используются только в тесте.
Серверных эталонных хешей нет: локальная проверка подтверждает целостность
скачанной копии, но не неизменность источников во время подготовки.
## Локальный проигрыватель SCREEN-004
На пульте выберите готовый пакет или «Тестовый режим SCREEN-001», внешний дисплей
и откройте показ. В основном канале доступны вступление и заключительное видео;
их выбор останавливает прежнее основное медиа. Музыка воспроизводится отдельным
каналом без автоматического приглушения. Звук выводится только окном показа.
Начальная громкость каждого канала — 35%; общий mute не меняет сохранённые уровни.
«Пуск с начала» и «Повторить с начала» запускают выбранный ресурс с нуля,
«Пауза» сохраняет фактическую позицию, «Продолжить» возобновляет её, «Стоп»
сбрасывает позицию. Перемотка сохраняет паузу/воспроизведение и ограничена реальной
длительностью файла. Громкость не вызывает перемотку или повторный пуск.
Следующий трек сохраняет состояние воспроизведения или паузы; по окончании трека
переход выполняется локально. Повтор относится ко всему плейлисту; без повтора
последний трек завершает воспроизведение. Смена плейлиста останавливает музыку.
Основной процесс хранит каталог разрешённых локальных ресурсов и состояния
`main`/`music`; renderer получает только адреса screen-app. Команды IPC содержат
сессию и возрастающий sequence; повторные/устаревшие номера игнорируются.
Подтверждения содержат сессию, канал и operation, чтобы запоздавшие события
старого ресурса не меняли новый. Позиция обновляется по событиям медиа; подготовка,
успешный play и ошибки подтверждаются проигрывателем. Подготовлены выбранное
основное медиа и один следующий трек, без чтения всех видео в JavaScript.
`screenBridge.packages()` и `selectPackage(key)` доступны только пульту;
`selectPackage(null)` включает тестовый режим. Выбор повторно проверяет пакет.
Закрытие или пересоздание окна и смена пакета создают новую сессию и останавливают
каналы; повторное открытие не возобновляет звук. После перезапуска всего приложения
выбор начинается с тестового режима; сохранение выбора относится к SCREEN-007.
Для ручной проверки с настоящим внешним дисплеем выполните `npm run acceptance`.
Команда использует синтетические fixtures и отдельный временный userData, создаёт
локальный пакет с двумя тональными музыкальными треками и оставляет пульт открытым
без автоматических команд. Подготовка не требует сети после установки зависимостей.
При отсутствии внешнего дисплея команда завершится с сообщением, синтетический
монитор в ручном режиме не подставляется. Встроенная аппаратная инструкция:
[SCREEN-004_ACCEPTANCE.md](docs/SCREEN-004_ACCEPTANCE.md).
Проверки test и smoke запускайте последовательно: обе команды пересобирают
`dist-electron`, из которого Electron загружает preload при открытии окна.
Программные замеры в пульте разделены по каналу и текущему виду медиа; они
не измеряют физический звук/первый кадр. Аппаратная приёмка, остальные ОС,
реальные игровые материалы и p95 на M1 пока не подтверждены. Полные визуальные
режимы остаются в SCREEN-005, авторизация и получение серверных команд — в SCREEN-006.
+30 -6
View File
@@ -1,6 +1,6 @@
# Задачи приложения площадки
SCREEN-001 имеет статус IN_PROGRESS; SCREEN-002 и SCREEN-003 завершены
SCREEN-001 и SCREEN-004 имеют статус IN_PROGRESS; SCREEN-002 и SCREEN-003 завершены
и перенесены в архив; остальные активные задачи — TODO.
SCREEN-009 (LAN-пульт) отменена владельцем; ID не переиспользовать.
Управление телефоном через сервер входит в SCREEN-006. План и общие ограничения:
@@ -56,8 +56,8 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе
Node 26.8.1, Electron 42.11.15. Единичные программные замеры не подтверждают
аппаратную приёмку или физический p95 ≤150 мс. Headless зависал/падал; GUI smoke
проходил с тестовыми --no-sandbox --mute-audio --disable-gpu.
- Ограничения: дедупликация стенда хранит последние 4096 команд; долговременная
семантика — SCREEN-004. Привязка игры и серверное управление — SCREEN-006.
- Ограничения исходного стенда: дедупликация хранила последние 4096 команд;
в программной части SCREEN-004 заменена сессиями и возрастающими номерами. Привязка игры и серверное управление — SCREEN-006.
- Следующий шаг: получить аппаратные результаты владельца по
docs/SCREEN-001_ACCEPTANCE.md на всех шести вариантах (Linux — на обоих
дистрибутивах), включая проектор,
@@ -68,7 +68,7 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе
## SCREEN-004 — Локальное воспроизведение и быстрые команды
- Статус: `TODO`
- Статус: `IN_PROGRESS`
- Зависимость: SCREEN-003 (DONE, см. COMPLETED_TASKS.md); использовать основу
SCREEN-001. Материалы брать из PackageStore и screen-app://app/packages/<key>/<assetId>.
- Цель: применять команды пульта независимо от сервера.
@@ -82,8 +82,32 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе
ручные seek/replay/смена трека и повтор замеров на всей матрице;
порог p95 ≤150 мс — на M1.
- Не входит: стриминг, несколько независимых проигрывателей на разных компьютерах.
- Следующий шаг: сверить существующую семантику ScreenCommand/MusicState и
реализовать один последовательный путь команда → проигрыватель → подтверждение.
- Программный результат (2026-10-17): готовые пакеты подключены к пульту через
проверку PackageStore; явный тестовый режим сохранён. Вступление и видео
взаимно исключаются в основном канале, музыка независима и не приглушается
автоматически (существующая политика сайта). Начальная громкость 35%, общий
mute сохраняет уровни. Добавлены play/pause/resume/stop/replay/seek, выбор
плейлиста/трека, следующий трек и повтор плейлиста. Позиции ограничиваются
фактической длительностью, пауза и громкость не перематывают ресурс.
- Согласование: новая сессия при выборе пакета и пересоздании окна, возрастающие
номера команд без накопления UUID; подтверждения привязаны к операции канала.
Отменённый play не оживляет канал, быстрый seek→pause не теряет перемотку;
повторный пуск перезагружает источник после ошибки чтения. Окно открывается
с остановленными каналами, автоматического возобновления звука нет.
- Проверки: типы, ESLint, Stylelint, 37 тестов, сборка и GUI smoke Electron — PASS.
GUI проверил выбор пакета через пульт, локальные WAV/H.264, Range, pause/resume,
replay/seek, независимую музыку, mute/volume, переход/повтор плейлиста,
быстрые команды, запреты IPC и повторное открытие без автозапуска.
Сборки и тесты запускать последовательно: test и smoke пересобирают один
dist-electron. При параллельной пересборке был таймаут повторного открытия;
последовательные прогоны прошли.
- Открыто: аппаратная матрица и физический p95 ≤150 мс на M1; текущие программные
замеры этого не подтверждают. Проверено на Linux x64, дисплей smoke синтетический,
звук приглушён тестовым флагом. Реальные игровые файлы и остальные ОС не проверены;
установочные архивы не пересобирались. Бэкенд и protobuf не менялись.
- Следующий шаг: выполнить npm run acceptance и аппаратную инструкцию
docs/SCREEN-004_ACCEPTANCE.md на всей матрице, получить результаты владельца.
До их получения статус остаётся IN_PROGRESS; перенос в архив не выполняется.
## SCREEN-005 — Пульт ведущего и все режимы показа
+74
View File
@@ -0,0 +1,74 @@
# SCREEN-004 — аппаратная проверка локального проигрывателя
Статус: **открыта**. Программная часть проверена на Linux x64, 37 тестов и GUI
smoke пройдены. Программные миллисекунды не подтверждают физический p95.
SCREEN-004 остаётся IN_PROGRESS до результатов владельца на всей матрице.
## Подготовка
1. Используйте Node.js 24.15+, установленные зависимости и ffmpeg. Выполните
`npm run media:generate`, затем `npm run acceptance` из корня screen.
2. Подключите реальный монитор/проектор в режиме расширения. Пульт должен остаться
на основном дисплее компьютера. Ручная проверка не подставляет синтетический монитор.
3. Команда создаст отдельный временный каталог данных и пакет «Offline smoke»:
WAV-вступление, H.264/AAC-видео и два четырёхсекундных тональных трека.
Путь данных печатается как SCREEN004_ACCEPTANCE_READY. Откройте пакет через
«Материалы показа», выберите внешний дисплей и нажмите «Открыть показ».
4. Отключите сеть после запуска. Не используйте --mute-audio для физической проверки.
Установите подходящую громкость и проверьте нужный аудиовыход ОС. Параметры
--no-sandbox/--disable-gpu, применённые в автоматическом smoke текущей среды,
не являются обязательными параметрами приложения; запишите использованные флаги.
5. Между сборками закрывайте приложение: test и smoke изменяют общий dist-electron.
## Сценарии
- Выберите вступление, дождитесь готовности, проверьте пуск с начала, паузу,
продолжение, повтор с начала и остановку. Пауза не должна отматывать звук назад.
- Перемотайте на середину в воспроизведении и на паузе; затем на конец.
Проверьте фактическую позицию и сохранение состояния паузы/воспроизведения.
- Переключитесь на заключительное видео: вступление прекращается. Повторите
команды и перемотку, проверьте изображение и звук на внешнем дисплее.
- Запустите музыку вместе с видео/вступлением: оба канала слышны независимо,
автоматического приглушения нет. Изменение громкости одного канала не должно
перематывать его или менять другой. Общий mute отключает оба; снятие сохраняет уровни.
- Проверьте выбор трека, «Следующий трек» на паузе и в воспроизведении,
автоматический переход, завершение второго трека без повтора и переход
со второго на первый с включённым повтором. Музыка не должна удваиваться.
- Несколько раз быстро выполните пуск → перемотка → пауза, затем пуск → стоп,
переключение вступление/видео и смену пакета. Последнее действие побеждает,
поздний play не оживляет предыдущий ресурс.
- Закройте окно показа и откройте снова. Проверьте оба канала: звук не начинается
до явной команды, позиции сброшены. Пульт и технические ошибки не видны на проекторе.
- Проверьте отдельный тестовый режим SCREEN-001. Он не должен подменять выбранный
игровой пакет автоматически во время нормального воспроизведения.
## Измерение задержки
На каждом формате и проверяемом аудиовыходе выполните не менее 30 запусков уже
подготовленного медиа. Холодный запуск/первую подготовку запишите отдельно.
Для физической задержки используйте синхронную запись действия на пульте и первого
слышимого звука/видимого кадра; методику и погрешность опишите в результате.
На MacBook Air M1, 16 ГБ RAM, физический p95 должен быть ≤150 мс.
Остальные варианты также требуют проверки, но тот же порог для них не заявлен.
Раскройте «Программные замеры запуска» в пульте: эти значения показывают только
приём команды → подтверждение проигрывателя, разделены по каналу и типу медиа.
Не выдавайте их за измерение физического выхода. Для чистой серии запускайте
ручную проверку заново; счётчики не сохраняются между запусками приложения.
## Матрица и отчёт
| Платформа | Архитектура | Результат |
| --- | --- | --- |
| macOS 13+ | Intel x64 | Открыто |
| macOS 13+ | Apple Silicon ARM64 | Открыто; M1 p95 ≤150 мс |
| Windows 11 | x64 | Открыто |
| Windows 11 | ARM64 | Открыто |
| Ubuntu 24.04 LTS / Debian 13 | x64 | Открыто для обоих дистрибутивов |
| Ubuntu 24.04 LTS / Debian 13 | ARM64 | Открыто для обоих дистрибутивов |
Для каждого прогона передайте: ОС/версию, архитектуру, CPU/RAM, версию Electron,
монитор/проектор, переходник, аудиовыход, формат, число запусков, программный и
физический p95 отдельно, методику измерения, пройденные сценарии и ошибки.
Установочные архивы для SCREEN-004 пока не пересобирались; ручная команда проверяет
текущий исходный код. Проверка установочных артефактов относится к SCREEN-008.
+213 -29
View File
@@ -1,47 +1,231 @@
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 { parseRange } from './range.js'
import type { Channel, Intent, Report } from '../src/types/screen.js'
const command = { id: 'one', action: 'play', media: 'audio', position: 0 }
test('валидирует команды на границе IPC', () => {
function testPackage(): LocalPackage {
const pkg = buildScreenPackage('https://example.test', 7, {
game: {
game: {
ID: 7,
name: 'Тест',
scenario: {
story: { introduction: { audio: 'intro.wav' }, conclusion: { video: 'ending.mp4' } },
},
},
},
plan: {},
music: {
gamePlaylistId: 1,
playlists: [
{
ID: 1,
name: 'Музыка',
tracks: [
{ ID: 1, name: 'Первый', position: 0, filename: 'one.wav' },
{ ID: 2, name: 'Второй', position: 1, filename: 'two.wav' },
],
},
],
},
})
return {
formatVersion: 1,
key: packageKey(pkg.server, pkg.game.id),
version: 'a'.repeat(64),
package: pkg,
files: [],
}
}
function send(c: Controller, intent: Intent) {
c.command({ session: c.state.session, sequence: c.state.sequence + 1, ...intent })
}
function ack(
c: Controller,
channel: Channel,
status: Report['status'],
position = 0,
duration = 120,
) {
c.report({
session: c.state.session,
channel,
operation: c.state.channels[channel].operation,
status,
position,
duration,
})
}
const controller = () => {
const c = new Controller(() => {})
c.output(true)
return c
}
test('валидирует команды IPC, включая канал, номер, позицию и громкость', () => {
const c = controller()
const command = { session: c.state.session, sequence: 1, action: 'play', channel: 'main' }
assert.deepEqual(parseCommand(command), command)
for (const value of [
null,
{},
{ ...command, id: '../file' },
{ ...command, session: '../' },
{ ...command, sequence: 1.5 },
{ ...command, sequence: 0 },
{ ...command, action: ['play'] },
{ ...command, media: ['audio'] },
{ ...command, position: NaN },
{ ...command, position: -1 },
{ ...command, action: 'delete' },
{ ...command, media: '/etc/passwd' },
]) {
{ ...command, channel: '/etc/passwd' },
{ ...command, action: 'seek', position: NaN },
{ ...command, action: 'seek', position: -1 },
{ ...command, action: 'volume', volume: 101 },
{ ...command, action: 'next' },
{ ...command, action: 'mute', enabled: 'true' },
])
assert.throws(() => parseCommand(value))
assert.equal(parseCommand({ ...command, action: 'seek', position: 900 }).action, 'seek')
})
test('не повторяет команды после 4096 операций, отклоняет старую сессию', () => {
const c = controller()
const old = { session: c.state.session, sequence: 1, action: 'play', channel: 'main' }
c.command(old)
for (let i = 0; i < 4100; i++) send(c, { action: 'volume', channel: 'main', volume: 35 })
const revision = c.state.revision
c.command(old)
assert.equal(c.state.revision, revision)
c.output(false)
c.output(true)
const session = c.state.session
c.command({ ...old, sequence: 999999 })
assert.equal(c.state.session, session)
assert.equal(c.state.sequence, 0)
assert.equal(c.state.channels.main.desired, 'stopped')
})
test('устаревшие подтверждения не влияют на новую операцию или пакет', () => {
const c = controller()
send(c, { action: 'play', channel: 'main' })
const old = {
session: c.state.session,
channel: 'main',
operation: c.state.channels.main.operation,
status: 'playing',
position: 9,
duration: 120,
}
send(c, { action: 'pause', channel: 'main' })
c.report(old)
assert.equal(c.state.channels.main.status, 'pending')
ack(c, 'main', 'paused', 12)
assert.equal(c.state.channels.main.position, 12)
c.selectPackage(testPackage())
c.report(old)
assert.equal(c.state.channels.main.desired, 'stopped')
assert.equal(c.state.channels.main.position, 0)
})
test('повтор не применяется; устаревшее подтверждение не меняет новую команду', () => {
let broadcasts = 0
const c = new Controller(() => broadcasts++)
c.command(command)
c.command(command)
assert.equal(broadcasts, 1)
c.command({ ...command, id: 'two', action: 'pause' })
c.report({ id: 'one', status: 'playing' })
assert.equal(c.state.status, 'pending')
c.report({ id: 'two', status: 'paused' })
assert.equal(c.state.status, 'paused')
assert.equal(c.state.revision, 3)
test('play/replay/stop сбрасывают позицию, resume сохраняет, seek использует длительность', () => {
const c = controller()
send(c, { action: 'play', channel: 'main' })
ack(c, 'main', 'playing', 42, 180)
send(c, { action: 'pause', channel: 'main' })
ack(c, 'main', 'paused', 42.5, 180)
send(c, { action: 'resume', channel: 'main' })
assert.equal(c.state.channels.main.position, 42.5)
ack(c, 'main', 'playing', 43, 180)
send(c, { action: 'seek', channel: 'main', position: 150 })
assert.equal(c.state.channels.main.position, 150)
assert.equal(c.state.channels.main.desired, 'playing')
send(c, { action: 'seek', channel: 'main', position: 999 })
assert.equal(c.state.channels.main.position, 180)
send(c, { action: 'pause', channel: 'main' })
ack(c, 'main', 'paused', 100, 180)
send(c, { action: 'seek', channel: 'main', position: 90 })
assert.equal(c.state.channels.main.desired, 'paused')
send(c, { action: 'replay', channel: 'main' })
assert.equal(c.state.channels.main.position, 0)
assert.equal(c.state.channels.main.desired, 'playing')
send(c, { action: 'stop', channel: 'main' })
assert.equal(c.state.channels.main.position, 0)
assert.equal(c.state.channels.main.desired, 'stopped')
})
test('playing подтверждается один раз, ошибки сохраняются', () => {
const c = new Controller(() => {})
c.command(command)
c.report({ id: 'one', status: 'playing' })
c.report({ id: 'one', status: 'playing' })
test('музыка независима от основного канала, mute и громкость не перематывают', () => {
const c = controller()
c.selectPackage(testPackage())
send(c, { action: 'play', channel: 'music' })
ack(c, 'music', 'playing', 15)
send(c, { action: 'select', channel: 'main', sourceId: 'conclusion' })
send(c, { action: 'play', channel: 'main' })
ack(c, 'main', 'playing', 20)
const operation = c.state.channels.main.operation
send(c, { action: 'volume', channel: 'main', volume: 80 })
send(c, { action: 'mute', enabled: true })
assert.equal(c.state.channels.main.operation, operation)
assert.equal(c.state.channels.main.position, 20)
assert.equal(c.state.channels.music.desired, 'playing')
assert.equal(c.state.channels.music.position, 15)
assert.equal(c.state.channels.music.volume, 35)
send(c, { action: 'mute', enabled: false })
assert.equal(c.state.channels.main.volume, 80)
assert.throws(
() => send(c, { action: 'select', channel: 'main', sourceId: 'arbitrary-file' }),
/Ресурс/,
)
})
test('музыка переходит вперёд ровно один раз; повторяет плейлист и сохраняет паузу', () => {
const c = controller()
c.selectPackage(testPackage())
send(c, { action: 'play', channel: 'music' })
ack(c, 'music', 'playing')
const ended = {
session: c.state.session,
channel: 'music',
operation: c.state.channels.music.operation,
status: 'ended',
position: 120,
duration: 120,
}
c.report(ended)
c.report(ended)
assert.equal(c.state.channels.music.trackIndex, 1)
assert.equal(c.state.channels.music.desired, 'playing')
ack(c, 'music', 'playing')
ack(c, 'music', 'ended', 120)
assert.equal(c.state.channels.music.status, 'ended')
assert.equal(c.state.channels.music.desired, 'paused')
send(c, { action: 'loop', channel: 'music', enabled: true })
send(c, { action: 'next', channel: 'music' })
assert.equal(c.state.channels.music.trackIndex, 0)
assert.equal(c.state.channels.music.desired, 'paused')
send(c, { action: 'resume', channel: 'music' })
ack(c, 'music', 'playing')
send(c, { action: 'track', channel: 'music', trackIndex: 1 })
ack(c, 'music', 'playing')
ack(c, 'music', 'ended', 120)
assert.equal(c.state.channels.music.trackIndex, 0)
assert.equal(c.state.channels.music.desired, 'playing')
})
test('ошибки и закрытие окна не возобновляют звук; замер playing записывается один раз', () => {
const c = controller()
send(c, { action: 'play', channel: 'main' })
ack(c, 'main', 'playing')
ack(c, 'main', 'playing', 1)
assert.equal(c.state.samples.length, 1)
c.report({ id: 'one', status: 'error', error: 'Нет файла' })
assert.equal(c.state.error, 'Нет файла')
assert.throws(() => c.report({ id: 'one', status: 'anything' }))
c.report({
session: c.state.session,
channel: 'main',
operation: c.state.channels.main.operation,
status: 'error',
position: 1,
duration: null,
error: 'Нет файла',
})
assert.equal(c.state.channels.main.error, 'Нет файла')
assert.equal(c.state.channels.main.desired, 'paused')
c.output(false)
c.output(true)
assert.equal(c.state.channels.main.desired, 'stopped')
assert.equal(c.state.channels.music.desired, 'stopped')
assert.throws(() => c.report({ status: 'anything' }))
})
test('Range: полный файл, границы, суффикс и отказ для недопустимых диапазонов', () => {
assert.equal(parseRange(null, 0), null)
+343 -55
View File
@@ -1,84 +1,372 @@
import { randomUUID } from 'node:crypto'
import { performance } from 'node:perf_hooks'
import type { Command, State } from '../src/types/screen.js'
import type {
Catalog,
Channel,
ChannelState,
Command,
Intent,
Report,
State,
} from '../src/types/screen.js'
import type { LocalPackage } from './package-store.js'
function number(value: unknown, min: number, max = Number.MAX_SAFE_INTEGER): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max
}
export function parseCommand(value: unknown): Command {
if (!value || typeof value !== 'object') throw new Error('Некорректная команда')
const c = value as Record<string, unknown>
if (
typeof c.id !== 'string' ||
!/^[a-zA-Z0-9-]{1,80}$/.test(c.id) ||
typeof c.action !== 'string' ||
!['play', 'pause', 'stop', 'seek'].includes(c.action) ||
typeof c.media !== 'string' ||
!['audio', 'video'].includes(c.media) ||
typeof c.position !== 'number' ||
!Number.isFinite(c.position) ||
c.position < 0 ||
c.position > 60
) {
throw new Error('Некорректная команда')
typeof c.session !== 'string' ||
!/^[a-f0-9-]{36}$/.test(c.session) ||
!Number.isSafeInteger(c.sequence) ||
!number(c.sequence, 1)
)
throw new Error('Некорректная сессия команды')
let intent: Intent
if (c.action === 'mute' && typeof c.enabled === 'boolean')
intent = { action: 'mute', enabled: c.enabled }
else {
if (c.channel !== 'main' && c.channel !== 'music') throw new Error('Некорректный канал')
const channel = c.channel
switch (c.action) {
case 'play':
case 'pause':
case 'resume':
case 'stop':
case 'replay':
intent = { action: c.action, channel }
break
case 'seek':
if (!number(c.position, 0)) throw new Error('Некорректная позиция')
intent = { action: 'seek', channel, position: c.position }
break
case 'volume':
if (!number(c.volume, 0, 100)) throw new Error('Некорректная громкость')
intent = { action: 'volume', channel, volume: c.volume }
break
case 'select':
if (
channel !== 'main' ||
typeof c.sourceId !== 'string' ||
!/^[a-zA-Z0-9-]{1,80}$/.test(c.sourceId)
)
throw new Error('Некорректный ресурс')
intent = { action: 'select', channel, sourceId: c.sourceId }
break
case 'playlist':
if (channel !== 'music' || !number(c.playlistId, 1) || !Number.isSafeInteger(c.playlistId))
throw new Error('Некорректный плейлист')
intent = { action: 'playlist', channel, playlistId: c.playlistId }
break
case 'track':
if (channel !== 'music' || !number(c.trackIndex, 0) || !Number.isSafeInteger(c.trackIndex))
throw new Error('Некорректный трек')
intent = { action: 'track', channel, trackIndex: c.trackIndex }
break
case 'next':
if (channel !== 'music') throw new Error('Некорректный канал')
intent = { action: 'next', channel }
break
case 'loop':
if (channel !== 'music' || typeof c.enabled !== 'boolean')
throw new Error('Некорректный повтор')
intent = { action: 'loop', channel, enabled: c.enabled }
break
default:
throw new Error('Некорректная команда')
}
}
return { session: c.session, sequence: c.sequence as number, ...intent }
}
const fixtureCatalog = (): Catalog => ({
key: null,
name: 'Тестовый режим SCREEN-001',
playlists: [],
media: [
{
id: 'fixture-audio',
name: 'Тестовое аудио · WAV',
kind: 'audio',
url: 'screen-app://app/media/audio',
},
{
id: 'fixture-video',
name: 'Тестовое видео · H.264 + AAC',
kind: 'video',
url: 'screen-app://app/media/video',
},
],
})
function initialChannel(): ChannelState {
return {
id: c.id,
action: c.action as Command['action'],
media: c.media as Command['media'],
position: c.position,
operation: 0,
seekOperation: 0,
source: null,
desired: 'stopped',
status: 'idle',
position: 0,
duration: null,
volume: 35,
error: '',
playlistId: null,
trackIndex: 0,
loop: false,
}
}
export class Controller {
state: State = { revision: 0, command: null, status: 'idle', error: '', samples: [] }
private seen = new Set<string>()
private startedAt = 0
constructor(private publish: (state: State) => void) {}
command(input: unknown) {
const command = parseCommand(input)
if (this.seen.has(command.id)) return
this.seen.add(command.id)
// shortcut: помним 4096 команд стенда; долговременная дедупликация — SCREEN-004.
if (this.seen.size > 4096) this.seen.delete(this.seen.values().next().value!)
this.startedAt = performance.now()
this.state = {
...this.state,
revision: this.state.revision + 1,
command,
status: 'pending',
state: State = {
session: randomUUID(),
sequence: 0,
revision: 0,
outputOpen: false,
catalog: fixtureCatalog(),
muted: false,
channels: { main: initialChannel(), music: initialChannel() },
samples: [],
}
private measured = new Map<Channel, { operation: number; startedAt: number }>()
constructor(private publish: (state: State) => void) {
this.state.channels.main.source = this.state.catalog.media[0]!
}
private broadcast() {
this.state.revision++
this.publish(structuredClone(this.state))
}
private change(channel: Channel, patch: Partial<ChannelState>) {
const c = this.state.channels[channel]
this.state.channels[channel] = {
...c,
...patch,
operation: c.operation + 1,
seekOperation: patch.position === undefined ? c.seekOperation : c.operation + 1,
error: '',
}
this.publish(this.state)
this.measured.delete(channel)
}
private newSession() {
this.state.session = randomUUID()
this.state.sequence = 0
for (const channel of ['main', 'music'] as const)
this.change(channel, {
desired: 'stopped',
status: this.state.channels[channel].source ? 'pending' : 'idle',
position: 0,
})
}
output(open: boolean) {
this.newSession()
this.state.outputOpen = open
if (!open)
for (const c of Object.values(this.state.channels)) c.status = c.source ? 'stopped' : 'idle'
this.broadcast()
}
selectPackage(pkg: LocalPackage | null) {
let catalog = fixtureCatalog()
if (pkg) {
const source = (id: string, assetId: string, name: string, kind: 'audio' | 'video') => ({
id,
name,
kind,
url: `screen-app://app/packages/${pkg.key}/${assetId}`,
})
const media = []
if (pkg.package.media.intro)
media.push(source('intro', pkg.package.media.intro, 'Вступление', 'audio'))
if (pkg.package.media.conclusion)
media.push(
source('conclusion', pkg.package.media.conclusion, 'Заключительное видео', 'video'),
)
catalog = {
key: pkg.key,
name: pkg.package.game.name,
media,
playlists: pkg.package.playlists.map((p) => ({
id: p.id,
name: p.name,
tracks: p.tracks.map((t) => ({
id: t.id,
name: t.name,
source: source(`track-${p.id}-${t.id}`, t.assetId, t.name, 'audio'),
})),
})),
}
}
this.state.catalog = catalog
this.state.channels = {
main: { ...initialChannel(), source: catalog.media[0] ?? null },
music: initialChannel(),
}
const preferred = pkg?.package.music
const playlist =
catalog.playlists.find(
(p) =>
p.id ===
(preferred?.selectedPlaylistId ??
preferred?.gamePlaylistId ??
preferred?.scenarioPlaylistId),
) ?? catalog.playlists[0]
if (playlist) {
this.state.channels.music.playlistId = playlist.id
this.state.channels.music.source = playlist.tracks[0]?.source ?? null
}
this.newSession()
this.broadcast()
}
private track(index: number) {
const c = this.state.channels.music
const track = this.state.catalog.playlists.find((p) => p.id === c.playlistId)?.tracks[index]
if (!track) throw new Error('Трек не найден')
this.change('music', {
source: track.source,
trackIndex: index,
position: 0,
duration: null,
status: 'pending',
})
}
private advance() {
const c = this.state.channels.music
const tracks = this.state.catalog.playlists.find((p) => p.id === c.playlistId)?.tracks ?? []
if (!tracks.length) throw new Error('Плейлист пуст')
const next = c.trackIndex + 1
if (next < tracks.length || c.loop) this.track(next % tracks.length)
else
this.change('music', {
desired: 'paused',
status: 'ended',
position: c.duration ?? c.position,
})
}
command(input: unknown) {
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 {
const channel = command.channel
const 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.change(channel, {
source,
desired: 'stopped',
status: 'pending',
position: 0,
duration: null,
})
break
}
case 'playlist': {
const playlist = this.state.catalog.playlists.find((p) => p.id === command.playlistId)
if (!playlist) throw new Error('Плейлист не найден в выбранном пакете')
this.change(channel, {
playlistId: playlist.id,
trackIndex: 0,
source: playlist.tracks[0]?.source ?? null,
position: 0,
duration: null,
desired: 'stopped',
status: playlist.tracks.length ? 'pending' : 'idle',
})
break
}
case 'track':
this.track(command.trackIndex)
break
case 'next':
this.advance()
break
case 'volume':
c.volume = command.volume
break
case 'loop':
c.loop = command.enabled
break
default: {
if (!c.source) throw new Error('Для канала не выбран ресурс')
const patch: Partial<ChannelState> = { status: 'pending' }
if (command.action === 'play' || command.action === 'replay') {
patch.position = 0
patch.desired = 'playing'
}
if (command.action === 'resume') {
if (c.status === 'ended') patch.position = 0
patch.desired = 'playing'
}
if (command.action === 'pause') patch.desired = 'paused'
if (command.action === 'stop') {
patch.position = 0
patch.desired = 'stopped'
}
if (command.action === 'seek')
patch.position =
c.duration === null ? command.position : Math.min(command.position, c.duration)
this.change(channel, patch)
if (['play', 'resume', 'replay'].includes(command.action))
this.measured.set(channel, {
operation: this.state.channels[channel].operation,
startedAt: performance.now(),
})
}
}
}
this.state.sequence = command.sequence
this.broadcast()
}
report(input: unknown) {
if (!input || typeof input !== 'object') throw new Error('Некорректный отчёт')
const r = input as Record<string, unknown>
if (
typeof r.session !== 'string' ||
(r.channel !== 'main' && r.channel !== 'music') ||
!Number.isSafeInteger(r.operation) ||
!number(r.operation, 0) ||
typeof r.status !== 'string' ||
!['playing', 'paused', 'stopped', 'ended', 'error'].includes(r.status) ||
typeof r.id !== 'string' ||
!number(r.position, 0) ||
(r.duration !== null && !number(r.duration, 0)) ||
(r.error !== undefined && typeof r.error !== 'string')
)
throw new Error('Некорректный отчёт')
const report = r as unknown as Report
const c = this.state.channels[report.channel]
if (
r.id !== this.state.command?.id ||
(this.state.status !== 'pending' && r.status !== 'ended' && r.status !== 'error')
!this.state.outputOpen ||
report.session !== this.state.session ||
report.operation !== c.operation ||
!c.source
)
return
const samples =
r.status === 'playing' && this.state.command.action === 'play'
? [
...this.state.samples.slice(-399),
{
media: this.state.command.media,
milliseconds: performance.now() - this.startedAt,
},
]
: this.state.samples
this.state = {
...this.state,
revision: this.state.revision + 1,
status: r.status as State['status'],
error: String(r.error ?? '').slice(0, 500),
samples,
if (report.status === 'playing' && c.desired !== 'playing') return
if (report.status === 'ended' && (c.desired !== 'playing' || c.status === 'ended')) return
c.position =
report.duration === null ? report.position : Math.min(report.position, report.duration)
c.duration = report.duration
c.status = report.status
c.error = (report.error ?? '').slice(0, 500)
const measured = this.measured.get(report.channel)
if (report.status === 'playing' && measured?.operation === c.operation) {
this.state.samples = [
...this.state.samples.slice(-399),
{
channel: report.channel,
media: c.source.kind,
milliseconds: performance.now() - measured.startedAt,
},
]
this.measured.delete(report.channel)
}
this.publish(this.state)
if (report.status === 'error') {
c.desired = 'paused'
this.measured.delete(report.channel)
}
if (report.status === 'ended') {
if (report.channel === 'music') this.advance()
else c.desired = 'paused'
}
this.broadcast()
}
}
+33 -8
View File
@@ -3,7 +3,7 @@ import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { join } from 'node:path'
import { Readable } from 'node:stream'
import { Controller } from './controller.js'
import { Controller, parseCommand } from './controller.js'
import { parseRange } from './range.js'
import { PackageStore } from './package-store.js'
import { packageMediaResponse } from './package-media.js'
@@ -69,16 +69,13 @@ function show(displayId: number) {
selectedDisplay = display.id
output = new BrowserWindow({ ...windowOptions(), ...display.bounds, show: false })
secure(output)
controller.output(true)
const window = output
output.on('closed', () => {
if (output !== window) return
output = null
selectedDisplay = null
controller.report({
id: controller.state.command?.id ?? '',
status: 'error',
error: 'Окно показа закрыто',
})
controller.output(false)
})
output.once('ready-to-show', () => {
if (!window.isDestroyed()) {
@@ -190,10 +187,38 @@ app
if (typeof id !== 'number') throw new Error('Некорректный дисплей')
show(id)
})
let selectingPackage = false
ipcMain.handle('packages', (event) => {
requireControl(event)
return packages
.list()
.map((pkg) => ({ key: pkg.key, version: pkg.version, name: pkg.package.game.name }))
})
ipcMain.handle('select-package', async (event, key: unknown) => {
requireControl(event)
if (selectingPackage) throw new Error('Подождите проверки пакета')
if (key !== null && typeof key !== 'string') throw new Error('Некорректный пакет')
selectingPackage = true
try {
controller.selectPackage(key === null ? null : await packages.verify(key))
return controller.state
} catch (error) {
if (key === controller.state.catalog.key) controller.selectPackage(null)
throw error
} finally {
selectingPackage = false
}
})
ipcMain.handle('command', (event, command: unknown) => {
requireControl(event)
if (!output) throw new Error('Сначала откройте окно показа')
controller.command(command)
if (selectingPackage) throw new Error('Подождите проверки пакета')
const parsed = parseCommand(command)
if (
!output &&
['play', 'pause', 'resume', 'stop', 'replay', 'seek', 'next'].includes(parsed.action)
)
throw new Error('Сначала откройте окно показа')
controller.command(parsed)
})
ipcMain.on('report', (event, report: unknown) => {
if (
+266
View File
@@ -0,0 +1,266 @@
import assert from 'node:assert/strict'
import { setImmediate } from 'node:timers/promises'
import { test, type TestContext } from 'node:test'
import { Controller } from './controller.js'
import { MediaPlayer } from '../src/utils/media-player.js'
import { buildScreenPackage } from './screen-package.js'
import { packageKey, type LocalPackage } from './package-store.js'
import type { Intent, Report } from '../src/types/screen.js'
function send(c: Controller, intent: Intent) {
c.command({ session: c.state.session, sequence: c.state.sequence + 1, ...intent })
}
function testPackage(): LocalPackage {
const pkg = buildScreenPackage('https://example.test', 7, {
game: {
game: {
ID: 7,
name: 'Тест',
scenario: {
story: { introduction: { audio: 'intro.wav' }, conclusion: { video: 'ending.mp4' } },
},
},
},
plan: {},
music: {
gamePlaylistId: 1,
playlists: [
{
ID: 1,
name: 'Музыка',
tracks: [
{ ID: 1, name: 'Первый', filename: 'one.wav', position: 0 },
{ ID: 2, name: 'Второй', filename: 'two.wav', position: 1 },
],
},
],
},
})
return {
formatVersion: 1,
key: packageKey(pkg.server, 7),
version: 'a'.repeat(64),
package: pkg,
files: [],
}
}
class FakeMedia extends EventTarget {
src = ''
readyState = 3
duration = 180
paused = true
muted = false
volume = 1
preload = 'auto'
seeking = false
ended = false
error: MediaError | null = null
playCalls = 0
delayPlay = false
pending: (() => void)[] = []
private position = 0
get currentTime() {
return this.position
}
set currentTime(value: number) {
this.position = value
this.ended = false
queueMicrotask(() => this.dispatchEvent(new Event('seeked')))
}
getAttribute(name: string) {
return name === 'src' ? this.src || null : null
}
removeAttribute(name: string) {
if (name === 'src') this.src = ''
}
load() {
this.error = null
this.readyState = this.src ? 3 : 0
this.position = 0
this.ended = false
}
pause() {
this.paused = true
}
play() {
this.playCalls++
if (this.delayPlay)
return new Promise<void>((resolve) =>
this.pending.push(() => {
this.paused = false
resolve()
}),
)
this.paused = false
return Promise.resolve()
}
finish() {
this.ended = true
this.paused = true
this.dispatchEvent(new Event('ended'))
}
}
async function fixture(t: TestContext) {
const audio = new FakeMedia(),
video = new FakeMedia(),
a = new FakeMedia(),
b = new FakeMedia()
const reports: Report[] = []
const c = new Controller(() => {})
c.selectPackage(testPackage())
c.output(true)
const player = new MediaPlayer(
{
audio: audio as unknown as HTMLAudioElement,
video: video as unknown as HTMLVideoElement,
music: [a as unknown as HTMLAudioElement, b as unknown as HTMLAudioElement],
},
(report) => reports.push(report),
)
t.after(() => player.destroy())
const apply = () => player.apply(structuredClone(c.state))
apply()
await setImmediate()
return { c, player, audio, video, a, b, reports, apply }
}
test('DOM: pause не возвращает позицию к последнему отчёту, seek сохраняет паузу', async (t) => {
const f = await fixture(t)
send(f.c, { action: 'play', channel: 'main' })
f.apply()
await setImmediate()
f.audio.currentTime = 44.5
send(f.c, { action: 'pause', channel: 'main' })
f.apply()
await setImmediate()
assert.equal(f.audio.paused, true)
assert.equal(f.audio.currentTime, 44.5)
f.c.report(f.reports.at(-1)!)
send(f.c, { action: 'seek', channel: 'main', position: 90 })
f.apply()
await setImmediate()
assert.equal(f.audio.currentTime, 90)
assert.equal(f.audio.paused, true)
send(f.c, { action: 'resume', channel: 'main' })
f.apply()
await setImmediate()
assert.equal(f.audio.currentTime, 90)
assert.equal(f.audio.paused, false)
send(f.c, { action: 'replay', channel: 'main' })
f.apply()
await setImmediate()
assert.equal(f.audio.currentTime, 0)
})
test('DOM: поздний play не оживляет приостановленный или сменившийся канал', async (t) => {
const f = await fixture(t)
f.audio.delayPlay = true
send(f.c, { action: 'play', channel: 'main' })
f.apply()
await setImmediate()
send(f.c, { action: 'pause', channel: 'main' })
f.apply()
await setImmediate()
f.audio.pending.shift()!()
await setImmediate()
assert.equal(f.audio.paused, true)
send(f.c, { action: 'play', channel: 'main' })
f.apply()
await setImmediate()
send(f.c, { action: 'select', channel: 'main', sourceId: 'conclusion' })
f.apply()
await setImmediate()
send(f.c, { action: 'play', channel: 'main' })
f.apply()
await setImmediate()
f.audio.pending.shift()!()
await setImmediate()
assert.equal(f.audio.paused, true)
assert.equal(f.video.paused, false)
assert.equal(f.reports.filter((r) => r.status === 'playing' && r.channel === 'main').length, 1)
})
test('DOM: музыка независима, только следующий трек подготовлен без воспроизведения', async (t) => {
const f = await fixture(t)
send(f.c, { action: 'play', channel: 'music' })
f.apply()
await setImmediate()
const playing = [f.a, f.b].find((media) => !media.paused)!
const spare = [f.a, f.b].find((media) => media !== playing)!
assert.ok(spare.src.includes(f.c.state.catalog.playlists[0]!.tracks[1]!.source.url))
assert.equal(spare.playCalls, 0)
assert.equal(spare.muted, true)
send(f.c, { action: 'play', channel: 'main' })
f.apply()
await setImmediate()
assert.equal(playing.paused, false)
assert.equal(f.audio.paused, false)
f.audio.currentTime = 27
send(f.c, { action: 'volume', channel: 'main', volume: 80 })
send(f.c, { action: 'mute', enabled: true })
f.apply()
assert.equal(f.audio.volume, 0.8)
assert.equal(f.audio.currentTime, 27)
assert.equal(f.audio.muted, true)
assert.equal(playing.muted, true)
send(f.c, { action: 'next', channel: 'music' })
f.apply()
await setImmediate()
assert.equal(playing.paused, true)
assert.equal(spare.paused, false)
assert.equal(f.audio.paused, false)
})
test('DOM: повторный snapshot не повторяет play, новая сессия не запускает звук', async (t) => {
const f = await fixture(t)
send(f.c, { action: 'play', channel: 'main' })
f.apply()
await setImmediate()
const old = structuredClone(f.c.state)
f.apply()
await setImmediate()
assert.equal(f.audio.playCalls, 1)
f.c.output(false)
f.c.output(true)
f.apply()
await setImmediate()
f.player.apply(old)
await setImmediate()
assert.equal(f.audio.playCalls, 1)
assert.equal(f.audio.paused, true)
assert.equal(f.audio.currentTime, 0)
})
test('DOM: ошибка файла подтверждается и не мешает другому каналу', async (t) => {
const f = await fixture(t)
send(f.c, { action: 'play', channel: 'music' })
f.apply()
await setImmediate()
f.audio.dispatchEvent(new Event('error'))
assert.ok(f.reports.some((r) => r.channel === 'main' && r.status === 'error'))
assert.equal([f.a, f.b].filter((media) => !media.paused).length, 1)
})
test('DOM: быстрые play → seek → pause не теряют ещё не применённую перемотку', async (t) => {
const f = await fixture(t)
send(f.c, { action: 'play', channel: 'main' })
f.apply()
send(f.c, { action: 'seek', channel: 'main', position: 90 })
f.apply()
send(f.c, { action: 'pause', channel: 'main' })
f.apply()
await setImmediate()
assert.equal(f.audio.paused, true)
assert.equal(f.audio.currentTime, 90)
assert.equal(f.reports.at(-1)!.status, 'paused')
})
test('DOM: повторный play перезагружает источник после ошибки чтения', async (t) => {
const f = await fixture(t)
f.audio.error = { code: 2, message: 'Read error' } as MediaError
f.audio.dispatchEvent(new Event('error'))
f.c.report(f.reports.at(-1)!)
send(f.c, { action: 'play', channel: 'main' })
f.apply()
await setImmediate()
assert.equal(f.audio.error, null)
assert.equal(f.audio.paused, false)
assert.equal(f.reports.at(-1)!.status, 'playing')
})
+4 -2
View File
@@ -1,9 +1,9 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { Command, State } from '../src/types/screen.js'
import type { Command, Report, State } from '../src/types/screen.js'
contextBridge.exposeInMainWorld('screenBridge', {
command: (command: Command) => ipcRenderer.invoke('command', command),
report: (report: unknown) => ipcRenderer.send('report', report),
report: (report: Report) => ipcRenderer.send('report', report),
state: () => ipcRenderer.invoke('state'),
subscribe: (listener: (state: State) => void) => {
const handler = (_event: Electron.IpcRendererEvent, state: State) => listener(state)
@@ -12,4 +12,6 @@ contextBridge.exposeInMainWorld('screenBridge', {
},
host: () => ipcRenderer.invoke('host'),
show: (id: number) => ipcRenderer.invoke('show', id),
packages: () => ipcRenderer.invoke('packages'),
selectPackage: (key: string | null) => ipcRenderer.invoke('select-package', key),
})
+1
View File
@@ -16,6 +16,7 @@
"pack": "npm run build && electron-builder --dir",
"dist": "npm run build && electron-builder",
"smoke": "npm run build && electron scripts/smoke.cjs",
"acceptance": "npm run build && electron scripts/smoke.cjs --manual",
"build:electron": "node -e \"require('node:fs').rmSync('dist-electron', { recursive: true, force: true })\" && tsc -p tsconfig.electron.json"
},
"dependencies": {
+268 -130
View File
@@ -1,13 +1,11 @@
const assert = require('node:assert/strict')
const { app, BrowserWindow, screen } = require('electron')
const root = require('node:path').resolve(__dirname, '..')
const { join, resolve } = require('node:path')
const { mkdtempSync, readFileSync } = require('node:fs')
const root = resolve(__dirname, '..')
app.setAppPath(root)
app.setPath(
'userData',
require('node:fs').mkdtempSync(
require('node:path').join(require('node:os').tmpdir(), 'screen-smoke-'),
),
)
app.setPath('userData', mkdtempSync(join(require('node:os').tmpdir(), 'screen-smoke-')))
const manual = process.argv.includes('--manual')
const delay = (ms) => new Promise((r) => setTimeout(r, ms))
let targetDisplay
let isSyntheticDisplay = false
@@ -31,6 +29,11 @@ app.whenReady().then(() => {
const original = screen.getAllDisplays.bind(screen)
const displays = original()
targetDisplay = displays.find((display) => display.id !== screen.getPrimaryDisplay().id)
if (!targetDisplay && manual) {
console.error('Подключите внешний дисплей в режиме расширенного рабочего стола')
app.exit(1)
return
}
if (!targetDisplay) {
isSyntheticDisplay = true
targetDisplay = {
@@ -43,27 +46,74 @@ app.whenReady().then(() => {
screen.getAllDisplays = () => [...original(), targetDisplay]
}
})
function musicWav(frequency) {
const size = 48000 * 2 * 4
const bytes = Buffer.alloc(44 + size)
bytes.write('RIFF', 0)
bytes.writeUInt32LE(36 + size, 4)
bytes.write('WAVEfmt ', 8)
bytes.writeUInt32LE(16, 16)
bytes.writeUInt16LE(1, 20)
bytes.writeUInt16LE(1, 22)
bytes.writeUInt32LE(48000, 24)
bytes.writeUInt32LE(96000, 28)
bytes.writeUInt16LE(2, 32)
bytes.writeUInt16LE(16, 34)
bytes.write('data', 36)
bytes.writeUInt32LE(size, 40)
for (let sample = 0; sample < size / 2; sample++)
bytes.writeInt16LE(
Math.round(Math.sin((2 * Math.PI * frequency * sample) / 48000) * 2000),
44 + sample * 2,
)
return bytes
}
let offlinePackage
const seeded = (async () => {
const { PackageStore } = require(root + '/dist-electron/electron/package-store.js')
const { buildScreenPackage } = require(root + '/dist-electron/electron/screen-package.js')
const bytes = require('node:fs').readFileSync(root + '/fixtures/audio.wav')
const audio = readFileSync(root + '/fixtures/audio.wav')
const video = readFileSync(root + '/fixtures/video.mp4')
const pkg = buildScreenPackage('https://offline.invalid', 7, {
game: {
game: {
ID: 7,
name: 'Offline smoke',
scenario: { story: { introduction: { audio: 'audio.wav' } } },
scenario: {
story: { introduction: { audio: 'intro.wav' }, conclusion: { video: 'ending.mp4' } },
},
},
},
plan: {},
music: {},
music: {
gamePlaylistId: 1,
playlists: [
{
ID: 1,
name: 'Offline playlist',
tracks: [
{ ID: 1, name: 'One', filename: 'one.wav', position: 0 },
{ ID: 2, name: 'Two', filename: 'two.wav', position: 1 },
],
},
],
},
})
const store = new PackageStore(require('node:path').join(app.getPath('userData'), 'packages'), {
fetch: async () =>
new Response(bytes, {
headers: { 'Content-Type': 'audio/wav', 'Content-Length': String(bytes.length) },
}),
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)
return new Response(bytes, {
headers: {
'Content-Type': url.endsWith('.mp4') ? 'video/mp4' : 'audio/wav',
'Content-Length': String(bytes.length),
},
})
},
})
await store.initialize()
offlinePackage = await store.download(pkg)
@@ -72,7 +122,7 @@ seeded.catch((error) => {
console.error('PACKAGE_SEED_FAIL', error)
app.exit(1)
})
// Регистрация протокола выполняется до ready; открытие хранилища ждёт seed.
// Регистрация протокола до ready; новый экземпляр хранилища открывается после seed.
const whenReady = app.whenReady.bind(app)
app.whenReady = () => whenReady().then(() => seeded)
require(root + '/dist-electron/electron/main.js')
@@ -87,161 +137,249 @@ async function poll(check, label) {
}
app.whenReady().then(async () => {
try {
await seeded
const control = await poll(() => BrowserWindow.getAllWindows()[0], 'control')
control.webContents.on('console-message', (_event, details) =>
console.log('renderer:', details.message),
)
const evaluate = (source) => control.webContents.executeJavaScript(source)
await poll(
async () =>
!control.webContents.isLoading() &&
(await control.webContents.executeJavaScript(
'!!window.screenBridge && !!document.querySelector("h1")',
)),
(await evaluate('!!window.screenBridge && !!document.querySelector("h1")')),
'control UI',
)
const evaluate = (s) => control.webContents.executeJavaScript(s)
await seeded
const asset = offlinePackage.files[0]
const localURL = `screen-app://app/packages/${offlinePackage.key}/${asset.id}`
const offline = await evaluate(`(async () => {
const response = await fetch(${JSON.stringify(localURL)}, { headers: { Range: 'bytes=0-43' } })
const size = (await response.arrayBuffer()).byteLength
const audio = new Audio(${JSON.stringify(localURL)})
await new Promise((resolve, reject) => { audio.onloadedmetadata = resolve; audio.onerror = () => reject(new Error('Offline audio failed')) })
await audio.play()
const playing = !audio.paused
const duration = audio.duration
audio.pause()
return { status: response.status, size, duration, playing }
})()`)
assert.equal(offline.status, 206)
assert.equal(offline.size, 44)
assert.ok(offline.duration > 0)
assert.equal(offline.playing, true)
if (manual) {
console.log(
'SCREEN004_ACCEPTANCE_READY',
JSON.stringify({ userData: app.getPath('userData'), electron: process.versions.electron }),
)
return
}
const state = () => evaluate('window.screenBridge.state()')
const command = async (intent) => {
const sequence = await evaluate(
`(async () => { const state = await window.screenBridge.state(); const sequence = state.sequence + 1; await window.screenBridge.command({ session: state.session, sequence, ...${JSON.stringify(intent)} }); return sequence })()`,
)
return poll(async () => {
const s = await state()
const c = intent.channel ? s.channels[intent.channel] : null
if (c?.status === 'error') throw new Error(c.error)
return s.sequence >= sequence && (!c || c.status !== 'pending') && s
}, JSON.stringify(intent))
}
assert.equal(await evaluate('typeof window.require'), 'undefined')
assert.equal((await evaluate('window.screenBridge.packages()')).length, 1)
await evaluate(`window.screenBridge.show(${targetDisplay.id})`)
const output = await poll(
let output = await poll(
() => BrowserWindow.getAllWindows().find((w) => w !== control),
'output',
)
output.webContents.on('console-message', (_event, details) =>
console.log('output:', details.message),
)
await poll(
async () =>
!output.webContents.isLoading() &&
(await output.webContents.executeJavaScript(
'document.querySelector("video")?.readyState >= 1',
)),
'metadata',
)
const inspect = (source) => output.webContents.executeJavaScript(source)
await poll(async () => (await state()).channels.main.status === 'stopped', 'fixture prepared')
if (process.platform === 'linux')
assert.equal(app.commandLine.getSwitchValue('ozone-platform'), 'x11')
await poll(() => boundsBeforeFullscreen.has(output.id), 'output fullscreen requested')
assert.deepEqual(boundsBeforeFullscreen.get(output.id), targetDisplay.bounds)
if (!isSyntheticDisplay) {
await poll(() => fullscreenEntered.has(output.id), 'output entered fullscreen')
assert.ok(output.isFullScreen())
assert.equal(screen.getDisplayMatching(output.getBounds()).id, targetDisplay.id)
}
const range = await output.webContents.executeJavaScript(
`fetch('screen-app://app/media/video', {headers: {Range: 'bytes=100-199'}}).then(async r => ({status:r.status, size:(await r.arrayBuffer()).byteLength, range:r.headers.get('content-range')}))`,
)
assert.equal(range.status, 206)
assert.equal(range.size, 100)
const command = async (id, action, media = 'audio', position = 0) => {
await evaluate(
`window.screenBridge.command(${JSON.stringify({ id, action, media, position })})`,
)
return poll(async () => {
const s = await evaluate('window.screenBridge.state()')
if (s.status === 'error') throw new Error(s.error)
return s.status !== 'pending' && s.command.id === id && s
}, id)
async function checkDisplay(window) {
await poll(() => boundsBeforeFullscreen.has(window.id), 'fullscreen requested')
assert.deepEqual(boundsBeforeFullscreen.get(window.id), targetDisplay.bounds)
if (!isSyntheticDisplay) {
await poll(() => fullscreenEntered.has(window.id), 'fullscreen entered')
assert.ok(window.isFullScreen())
assert.equal(screen.getDisplayMatching(window.getBounds()).id, targetDisplay.id)
}
}
await checkDisplay(output)
await evaluate(
`Array.from(document.querySelectorAll('button')).find(b => b.textContent.includes('Пуск с начала')).click()`,
)
await poll(async () => (await state()).channels.main.status === 'playing', 'UI fixture play')
await command({ action: 'pause', channel: 'main' })
await command({ action: 'resume', channel: 'main' })
await command({ action: 'stop', channel: 'main' })
await command({ action: 'select', channel: 'main', sourceId: 'fixture-video' })
await command({ action: 'play', channel: 'main' })
await command({ action: 'seek', channel: 'main', position: 30 })
const fixturePosition = await inspect('document.querySelector("video").currentTime')
assert.ok(fixturePosition >= 29.9 && fixturePosition < 35)
const range = await inspect(
`fetch('screen-app://app/media/video', { headers: { Range: 'bytes=100-199' } }).then(async r => ({ status: r.status, size: (await r.arrayBuffer()).byteLength }))`,
)
assert.deepEqual(range, { status: 206, size: 100 })
await command({ action: 'pause', channel: 'main' })
await evaluate("document.querySelector('.n-base-selection').click()")
await poll(
() =>
evaluate(`(() => {
const option = Array.from(document.querySelectorAll('.n-base-select-option')).find(el => el.textContent.includes('Offline smoke'))
if (!option) return false
option.click(); return true
})()`),
'UI package choice',
)
await poll(async () => {
const state = await evaluate('window.screenBridge.state()')
if (state.status === 'error') throw new Error(state.error)
return state.status === 'playing'
}, 'computer UI play')
assert.equal((await command('audio-play', 'play')).status, 'playing')
assert.equal((await command('audio-pause', 'pause')).status, 'paused')
assert.equal((await command('audio-stop', 'stop')).status, 'stopped')
assert.equal((await command('video-play', 'play', 'video')).status, 'playing')
await command('video-seek', 'seek', 'video', 30)
const position = await output.webContents.executeJavaScript(
'document.querySelector("video").currentTime',
const s = await state()
return (
s.catalog.key === offlinePackage.key &&
s.channels.main.status === 'stopped' &&
s.channels.music.status === 'stopped'
)
}, 'package prepared')
await command({ action: 'play', channel: 'main' })
await command({ action: 'seek', channel: 'main', position: 15 })
await command({ action: 'pause', channel: 'main' })
const pausedAt = await inspect(
'document.querySelector("[data-channel=main-audio]").currentTime',
)
assert.ok(position >= 29.9 && position < 35, 'video seek actual position')
await command('video-pause', 'pause', 'video')
assert.equal(
await output.webContents.executeJavaScript('document.querySelector("audio").paused'),
true,
)
assert.equal(
await output.webContents.executeJavaScript('document.querySelector("video").paused'),
true,
)
await assert.rejects(
output.webContents.executeJavaScript(
`window.screenBridge.command({id:'forbidden', action:'play', media:'audio', position:0})`,
await command({ action: 'resume', channel: 'main' })
assert.ok(
await inspect(
`document.querySelector('[data-channel=main-audio]').currentTime >= ${pausedAt - 0.1}`,
),
)
await command({ action: 'replay', channel: 'main' })
assert.ok(await inspect("document.querySelector('[data-channel=main-audio]').currentTime < 2"))
await command({ action: 'select', channel: 'main', sourceId: 'conclusion' })
await command({ action: 'play', channel: 'main' })
await command({ action: 'seek', channel: 'main', position: 30 })
await command({ action: 'play', channel: 'music' })
assert.equal(
await evaluate("document.body.textContent.includes('Телефон по локальной сети')"),
false,
await inspect(
"Array.from(document.querySelectorAll('audio,video')).filter(m => !m.paused).length",
),
2,
)
assert.deepEqual(Object.keys(await evaluate('window.screenBridge.host()')), ['displays'])
assert.equal(await evaluate('typeof window.screenBridge.lan'), 'undefined')
assert.equal(await evaluate('typeof window.screenBridge.approve'), 'undefined')
await command({ action: 'volume', channel: 'music', volume: 70 })
const videoBeforeVolume = await inspect('document.querySelector("video").currentTime')
await command({ action: 'volume', channel: 'main', volume: 25 })
assert.ok(
await inspect(`document.querySelector('video').currentTime >= ${videoBeforeVolume - 0.1}`),
)
await command({ action: 'mute', enabled: true })
assert.equal(
await inspect(
"Array.from(document.querySelectorAll('audio,video')).filter(m => !m.paused && !m.muted).length",
),
0,
)
await command({ action: 'mute', enabled: false })
assert.equal(await inspect("document.querySelector('video').volume"), 0.25)
await command({ action: 'seek', channel: 'music', position: 3.9 })
await poll(async () => {
const c = (await state()).channels.music
return c.trackIndex === 1 && c.status === 'playing'
}, 'automatic next track')
assert.equal(
await inspect(
"Array.from(document.querySelectorAll('audio[data-channel^=music]')).filter(m => !m.paused).length",
),
1,
)
await command({ action: 'seek', channel: 'music', position: 3.9 })
await poll(
async () => (await state()).channels.music.status === 'ended',
'playlist ends without repeat',
)
await command({ action: 'loop', channel: 'music', enabled: true })
await command({ action: 'resume', channel: 'music' })
await command({ action: 'seek', channel: 'music', position: 3.9 })
await poll(async () => {
const c = (await state()).channels.music
return c.trackIndex === 0 && c.status === 'playing'
}, 'playlist repeats')
await command({ action: 'pause', channel: 'music' })
await command({ action: 'next', channel: 'music' })
assert.equal((await state()).channels.music.trackIndex, 1)
assert.equal((await state()).channels.music.status, 'paused')
await command({ action: 'stop', channel: 'main' })
await evaluate(`(async () => {
const s = await window.screenBridge.state()
await Promise.all([{action:'play'}, {action:'seek',position:20}, {action:'pause'}].map((intent,i) => window.screenBridge.command({session:s.session,sequence:s.sequence+i+1,channel:'main',...intent})))
})()`)
await poll(
async () => (await state()).channels.main.status === 'paused',
'rapid commands settle',
)
assert.ok(await inspect('Math.abs(document.querySelector("video").currentTime - 20) < 0.3'))
const beforeDuplicate = await state()
await evaluate(
`window.screenBridge.command(${JSON.stringify({ session: beforeDuplicate.session, sequence: beforeDuplicate.sequence, action: 'replay', channel: 'main' })})`,
)
assert.equal((await state()).channels.main.operation, beforeDuplicate.channels.main.operation)
await assert.rejects(
inspect(
`window.screenBridge.command({session:'${beforeDuplicate.session}',sequence:999999,action:'play',channel:'main'})`,
),
)
await assert.rejects(inspect('window.screenBridge.packages()'))
await assert.rejects(
evaluate(
`window.screenBridge.command({session:'${beforeDuplicate.session}',sequence:999999,action:'select',channel:'main',sourceId:'arbitrary-file'})`,
),
)
assert.ok(
await inspect(
"Array.from(document.querySelectorAll('audio,video')).every(m => !m.getAttribute('src') || m.src.startsWith('screen-app://app/packages/'))",
),
)
const samples = (await state()).samples
const oldSession = (await state()).session
output.close()
await evaluate(`window.screenBridge.show(${targetDisplay.id})`)
const reopened = await poll(
() => BrowserWindow.getAllWindows().find((w) => w !== control),
'reopened output',
)
await poll(
async () =>
!reopened.webContents.isLoading() &&
(await reopened.webContents.executeJavaScript('!!document.querySelector("video")')),
'reopened ready',
)
await poll(() => boundsBeforeFullscreen.has(reopened.id), 'reopened fullscreen requested')
assert.deepEqual(boundsBeforeFullscreen.get(reopened.id), targetDisplay.bounds)
if (!isSyntheticDisplay) {
await poll(() => fullscreenEntered.has(reopened.id), 'reopened entered fullscreen')
assert.ok(reopened.isFullScreen())
assert.equal(screen.getDisplayMatching(reopened.getBounds()).id, targetDisplay.id)
}
output = await poll(() => BrowserWindow.getAllWindows().find((w) => w !== control), 'reopened')
await poll(async () => {
const s = await state()
return (
s.session !== oldSession &&
s.channels.main.status === 'stopped' &&
s.channels.music.status === 'stopped'
)
}, 'reopened prepared, stopped')
await checkDisplay(output)
assert.equal(
await reopened.webContents.executeJavaScript('document.querySelector("video").paused'),
true,
await inspect(
"Array.from(document.querySelectorAll('audio,video')).filter(m => !m.paused).length",
),
0,
)
await evaluate(
`window.screenBridge.command(${JSON.stringify({ session: oldSession, sequence: 9999999, action: 'play', channel: 'main' })})`,
)
assert.equal((await state()).channels.main.desired, 'stopped')
assert.equal(await evaluate('typeof window.screenBridge.lan'), 'undefined')
console.log(
'SCREEN_SMOKE_PASS',
JSON.stringify({
range,
offlinePackage: offline,
display: {
id: targetDisplay.id,
bounds: targetDisplay.bounds,
synthetic: isSyntheticDisplay,
offlinePackage: {
name: offlinePackage.package.game.name,
files: offlinePackage.files.length,
},
seek: position,
samples: (await evaluate('window.screenBridge.state()')).samples,
display: { id: targetDisplay.id, synthetic: isSyntheticDisplay },
fixturePosition,
pausedAt,
samples,
electron: process.versions.electron,
}),
)
app.exit(0)
} catch (error) {
for (const window of BrowserWindow.getAllWindows()) {
try {
console.error(
'SMOKE_DIAGNOSTIC',
window.webContents.getURL(),
await window.webContents.executeJavaScript(
"(async () => ({ state: await window.screenBridge.state(), media: Array.from(document.querySelectorAll('audio,video')).map(m => ({channel:m.dataset.channel,src:m.src,paused:m.paused,ready:m.readyState,time:m.currentTime,duration:m.duration,error:m.error?.message})) }))()",
),
)
} catch {}
}
console.error('SCREEN_SMOKE_FAIL', error)
app.exit(1)
}
})
setTimeout(() => {
console.error('Global timeout')
app.exit(1)
}, 45000)
if (!manual)
setTimeout(() => {
console.error('Global timeout')
app.exit(1)
}, 90000)
+217 -77
View File
@@ -1,38 +1,54 @@
<script setup lang="ts">
import { NAlert, NButton, NCard, NInputNumber, NSelect, NSpace, useMessage } from 'naive-ui'
import {
NAlert,
NButton,
NCard,
NInputNumber,
NSelect,
NSpace,
NSwitch,
useMessage,
} from 'naive-ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Action, HostInfo, MediaKind, State } from '../types/screen'
import type { Channel, HostInfo, Intent, PackageSummary, Playback, State } from '../types/screen'
const message = useMessage()
const bridge = window.screenBridge
const state = ref<State | null>(null)
const packages = ref<PackageSummary[]>([])
const host = ref<HostInfo>({ displays: [] })
const display = ref<number | null>(null)
const media = ref<MediaKind>('audio')
const position = ref<number | null>(10)
const positions = ref<Record<Channel, number | null>>({ main: 10, music: 10 })
const isBusy = ref(false)
const metrics = computed(() => {
const values = (state.value?.samples ?? [])
.filter((sample) => sample.media === media.value)
.map((sample) => sample.milliseconds)
.sort((a, b) => a - b)
if (!values.length) return 'Замеров пока нет'
const middle = Math.floor(values.length / 2)
const median = values.length % 2 ? values[middle]! : (values[middle - 1]! + values[middle]!) / 2
return `n=${values.length}, медиана ${median.toFixed(1)} мс, p95 ${values[Math.ceil(values.length * 0.95) - 1]!.toFixed(1)} мс`
})
const statusLabels: Record<State['status'], string> = {
idle: 'Ожидание',
pending: 'Команда отправлена',
const channels = ['main', 'music'] as const
const playlist = computed(() =>
state.value?.catalog.playlists.find((p) => p.id === state.value?.channels.music.playlistId),
)
const packageOptions = computed(() => [
{ label: 'Тестовый режим SCREEN-001', value: 'fixtures' },
...packages.value.map((p) => ({ label: p.name, value: p.key })),
])
const statusLabels: Record<Playback, string> = {
idle: 'Ресурс не выбран',
pending: 'Подготовка или выполнение команды',
playing: 'Воспроизведение подтверждено',
paused: 'Пауза',
stopped: 'Остановлено',
stopped: 'Готово, остановлено',
ended: 'Завершено',
error: 'Ошибка',
}
let timer: ReturnType<typeof setInterval> | undefined
let unsubscribe: (() => void) | undefined
let session = ''
let sequence = 0
function acceptState(value: State) {
if (state.value && value.revision < state.value.revision) return
if (session !== value.session) {
session = value.session
sequence = value.sequence
} else sequence = Math.max(sequence, value.sequence)
state.value = value
}
async function run(action: () => Promise<unknown>) {
isBusy.value = true
try {
@@ -43,40 +59,55 @@ async function run(action: () => Promise<unknown>) {
isBusy.value = false
}
}
function acceptState(value: State) {
if (!state.value || value.revision >= state.value.revision) state.value = value
async function send(intent: Intent) {
try {
if (!bridge || !state.value) throw new Error('Откройте пульт в приложении Electron')
await bridge.command({ session, sequence: ++sequence, ...intent })
} catch (error) {
message.error(error instanceof Error ? error.message : 'Команда не выполнена')
}
}
async function selectPackage(key: string) {
await run(async () => {
if (!bridge) return
try {
acceptState(await bridge.selectPackage(key === 'fixtures' ? null : key))
} finally {
packages.value = await bridge.packages()
}
})
}
async function refreshHost() {
if (bridge) host.value = await bridge.host()
}
async function send(action: Action) {
await run(async () => {
if (!bridge) throw new Error('Откройте пульт в приложении Electron')
await bridge.command({
id: crypto.randomUUID(),
action,
media: media.value,
position: position.value ?? 0,
})
})
function metrics(channel: Channel) {
const values = (state.value?.samples ?? [])
.filter(
(sample) =>
sample.channel === channel && sample.media === state.value?.channels[channel].source?.kind,
)
.map((sample) => sample.milliseconds)
.sort((a, b) => a - b)
return values.length
? `n=${values.length}, p95 ${values[Math.ceil(values.length * 0.95) - 1]!.toFixed(1)} мс`
: 'Замеров пока нет'
}
onMounted(async () => {
await run(async () => {
if (!bridge) throw new Error('Откройте пульт в приложении Electron')
unsubscribe = bridge.subscribe(acceptState)
acceptState(await bridge.state())
packages.value = await bridge.packages()
await refreshHost()
})
let isTicking = false
let refreshing = false
timer = setInterval(() => {
if (isTicking) return
isTicking = true
if (refreshing) return
refreshing = true
void refreshHost()
.catch((error: unknown) => {
message.error(error instanceof Error ? error.message : 'Ошибка связи')
})
.catch(() => {})
.finally(() => {
isTicking = false
refreshing = false
})
}, 1000)
})
@@ -88,10 +119,21 @@ onUnmounted(() => {
<template>
<main class="control">
<h1>Вечерний детектив · SCREEN-001</h1>
<n-alert type="info"
>Технический стенд: синтетические медиа, два окна и локальное воспроизведение.</n-alert
>
<h1>Вечерний детектив · Локальный проигрыватель</h1>
<n-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert>
<n-card title="Материалы показа">
<n-select
:value="state?.catalog.key ?? 'fixtures'"
:options="packageOptions"
:disabled="!bridge || isBusy"
:input-props="{ 'aria-label': 'Подготовленный пакет' }"
@update:value="selectPackage"
/>
<n-alert v-if="state && !state.catalog.key" type="info"
>Тестовый режим: синтетические аудио и видео.</n-alert
>
<p v-if="!packages.length">Готовых игровых пакетов на диске пока нет.</p>
</n-card>
<n-card v-if="bridge" title="Дисплей показа">
<n-space vertical>
<n-select
@@ -108,42 +150,134 @@ onUnmounted(() => {
>
</n-space>
</n-card>
<n-card title="Локальное воспроизведение">
<n-space vertical>
<n-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert>
<n-select
v-model:value="media"
:options="[
{ label: 'Аудио · WAV PCM 48 кГц', value: 'audio' },
{ label: 'Видео · H.264 + AAC, 720p30', value: 'video' },
]"
:input-props="{ 'aria-label': 'Тестовое медиа' }"
/>
<n-space>
<n-button :disabled="!bridge || isBusy" @click="send('play')">Пуск с начала</n-button>
<n-button :disabled="!bridge || isBusy" @click="send('pause')">Пауза</n-button>
<n-button :disabled="!bridge || isBusy" @click="send('stop')">Стоп</n-button>
<template v-if="state">
<n-alert v-if="!state.outputOpen" type="info"
>Откройте показ, чтобы подготовить медиа и включить воспроизведение.</n-alert
>
<label class="mute"
>Выключить весь звук
<n-switch
:value="state.muted"
aria-label="Выключить весь звук"
@update:value="send({ action: 'mute', enabled: $event })"
/></label>
<n-card
v-for="channel in channels"
:key="channel"
:title="channel === 'main' ? 'Вступление и видео' : 'Музыка'"
>
<n-space vertical>
<n-select
v-if="channel === 'main'"
:value="state.channels.main.source?.id ?? null"
:options="state.catalog.media.map((s) => ({ label: s.name, value: s.id }))"
placeholder="Выберите медиа"
:disabled="isBusy"
:input-props="{ 'aria-label': 'Медиа показа' }"
@update:value="send({ action: 'select', channel: 'main', sourceId: $event })"
/>
<template v-else>
<n-select
:value="state.channels.music.playlistId"
:options="state.catalog.playlists.map((p) => ({ label: p.name, value: p.id }))"
placeholder="Выберите плейлист"
:disabled="isBusy"
:input-props="{ 'aria-label': 'Плейлист' }"
@update:value="send({ action: 'playlist', channel: 'music', playlistId: $event })"
/>
<n-select
:value="state.channels.music.source ? state.channels.music.trackIndex : null"
:options="
(playlist?.tracks ?? []).map((t, index) => ({
label: t.name || `Трек ${index + 1}`,
value: index,
}))
"
placeholder="Выберите трек"
:disabled="isBusy"
:input-props="{ 'aria-label': 'Трек' }"
@update:value="send({ action: 'track', channel: 'music', trackIndex: $event })"
/>
<label
>Повторять плейлист
<n-switch
:value="state.channels.music.loop"
aria-label="Повторять плейлист"
@update:value="send({ action: 'loop', channel: 'music', enabled: $event })"
/></label>
</template>
<n-space>
<n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@click="send({ action: 'play', channel })"
>Пуск с начала</n-button
>
<n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@click="send({ action: 'pause', channel })"
>Пауза</n-button
>
<n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@click="send({ action: 'resume', channel })"
>Продолжить</n-button
>
<n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@click="send({ action: 'stop', channel })"
>Стоп</n-button
>
<n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@click="send({ action: 'replay', channel })"
>Повторить с начала</n-button
>
<n-button
v-if="channel === 'music'"
:disabled="!state.outputOpen || !state.channels.music.source || isBusy"
@click="send({ action: 'next', channel: 'music' })"
>Следующий трек</n-button
>
</n-space>
<label :for="`position-${channel}`">Позиция, секунды</label>
<n-input-number
v-model:value="positions[channel]"
:input-props="{ id: `position-${channel}` }"
:min="0"
:max="state.channels[channel].duration ?? undefined"
/>
<n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@click="send({ action: 'seek', channel, position: positions[channel] ?? 0 })"
>Перемотать</n-button
>
<label :for="`volume-${channel}`">Громкость, %</label>
<n-input-number
:value="state.channels[channel].volume"
:input-props="{ id: `volume-${channel}` }"
:min="0"
:max="100"
@update:value="send({ action: 'volume', channel, volume: $event ?? 0 })"
/>
<p role="status">
{{ statusLabels[state.channels[channel].status] }} ·
{{ state.channels[channel].position.toFixed(1) }} /
{{ state.channels[channel].duration?.toFixed(1) ?? '—' }} с
</p>
<n-alert v-if="state.channels[channel].error" type="error">{{
state.channels[channel].error
}}</n-alert>
<details>
<summary>Программные замеры запуска</summary>
<p>{{ metrics(channel) }}</p>
<p>
Приём команды → подтверждение проигрывателя. Физический звук и первый кадр проектора
здесь не измеряются.
</p>
</details>
</n-space>
<label for="position">Позиция, секунды</label>
<n-input-number
v-model:value="position"
:input-props="{ id: 'position' }"
:min="0"
:max="60"
/>
<n-button :disabled="!bridge || isBusy" @click="send('seek')">Перемотать</n-button>
<p role="status">
{{ state ? statusLabels[state.status] : 'Нет состояния' }} · ревизия
{{ state?.revision ?? 0 }}
</p>
<n-alert v-if="state?.error" type="error">{{ state.error }}</n-alert>
<p>{{ metrics }}</p>
<p>
Замеры: приём команды контроллером → подтверждение проигрывателя. Они не измеряют
физический звук или первый кадр проектора.
</p>
</n-space>
</n-card>
</n-card>
</template>
</main>
</template>
@@ -159,4 +293,10 @@ onUnmounted(() => {
p {
overflow-wrap: anywhere;
}
.mute {
align-items: center;
display: flex;
gap: 12px;
}
</style>
+23 -97
View File
@@ -1,117 +1,43 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import type { Command, MediaKind, State } from '../types/screen'
import type { State } from '../types/screen'
import { MediaPlayer } from '../utils/media-player'
const audio = ref<HTMLAudioElement | null>(null)
const video = ref<HTMLVideoElement | null>(null)
const kind = ref<MediaKind>('audio')
const musicA = ref<HTMLAudioElement | null>(null)
const musicB = ref<HTMLAudioElement | null>(null)
const showVideo = ref(false)
const bridge = window.screenBridge!
let player: MediaPlayer | undefined
let unsubscribe: (() => void) | undefined
let generation = 0
let lastRevision = -1
let commandId = ''
let lastCommand: Command | null = null
async function metadata(media: HTMLMediaElement) {
if (media.readyState >= 1) return
await new Promise<void>((resolve, reject) => {
const finish = (error?: Error) => {
clearTimeout(timeout)
media.removeEventListener('loadedmetadata', ready)
media.removeEventListener('error', failed)
if (error) reject(error)
else resolve()
}
const ready = () => finish()
const failed = () => finish(new Error('Не удалось прочитать тестовое медиа'))
const timeout = setTimeout(() => finish(new Error('Медиа не подготовлено за 10 секунд')), 10000)
media.addEventListener('loadedmetadata', ready, { once: true })
media.addEventListener('error', failed, { once: true })
if (media.error) failed()
})
}
async function apply(state: State) {
if (state.revision <= lastRevision) return
lastRevision = state.revision
const command = state.command
if (state.status !== 'pending' || !command || command.id === commandId) return
commandId = command.id
lastCommand = command
const current = ++generation
const media = command.media === 'audio' ? audio.value : video.value
const other = command.media === 'audio' ? video.value : audio.value
other?.pause()
kind.value = command.media
if (!media) return
try {
if (command.action === 'pause' || command.action === 'stop') media.pause()
await metadata(media)
if (current !== generation) return
if (command.action === 'play' || command.action === 'stop') media.currentTime = 0
if (command.action === 'seek') media.currentTime = Math.min(command.position, media.duration)
if (command.action === 'play') {
await media.play()
if (current !== generation) return
}
const status = command.action === 'stop' ? 'stopped' : media.paused ? 'paused' : 'playing'
bridge.report({ id: command.id, status })
} catch (error) {
if (current === generation)
bridge.report({
id: command.id,
status: 'error',
error: error instanceof Error ? error.message : 'Ошибка воспроизведения',
})
}
}
function activeMedia() {
return lastCommand?.media === 'audio' ? audio.value : video.value
}
function ended(event: Event) {
const media = activeMedia()
if (lastCommand && event.target === media && media?.ended)
bridge.report({ id: lastCommand.id, status: 'ended' })
}
function failed(event: Event) {
if (lastCommand && event.target === activeMedia())
bridge.report({
id: lastCommand.id,
status: 'error',
error: 'Ошибка локального медиа; проверьте наличие fixtures',
})
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)
}
onMounted(async () => {
for (const media of [audio.value, video.value]) if (media) media.volume = 0.35
unsubscribe = bridge.subscribe((state) => {
void apply(state)
})
await apply(await bridge.state())
player = new MediaPlayer(
{ audio: audio.value!, video: video.value!, music: [musicA.value!, musicB.value!] },
(report) => bridge.report(report),
)
unsubscribe = bridge.subscribe(apply)
apply(await bridge.state())
})
onUnmounted(() => {
generation++
unsubscribe?.()
audio.value?.pause()
video.value?.pause()
player?.destroy()
})
</script>
<template>
<main class="output">
<audio
ref="audio"
:src="'screen-app://app/media/audio'"
preload="auto"
@ended="ended"
@error="failed"
/>
<video
v-show="kind === 'video'"
ref="video"
:src="'screen-app://app/media/video'"
preload="auto"
@ended="ended"
@error="failed"
/>
<audio ref="audio" data-channel="main-audio" preload="auto" />
<video v-show="showVideo" 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>
</template>
+65 -9
View File
@@ -1,17 +1,66 @@
export type MediaKind = 'audio' | 'video'
export type Action = 'play' | 'pause' | 'stop' | 'seek'
export interface Command {
export type Channel = 'main' | 'music'
export type Playback = 'idle' | 'pending' | 'playing' | 'paused' | 'stopped' | 'ended' | 'error'
export type Intent =
| { action: 'play' | 'pause' | 'resume' | 'stop' | 'replay'; channel: Channel }
| { action: 'seek'; channel: Channel; position: number }
| { action: 'volume'; channel: Channel; volume: number }
| { action: 'mute'; enabled: boolean }
| { action: 'select'; channel: 'main'; sourceId: string }
| { action: 'playlist'; channel: 'music'; playlistId: number }
| { action: 'track'; channel: 'music'; trackIndex: number }
| { action: 'next'; channel: 'music' }
| { action: 'loop'; channel: 'music'; enabled: boolean }
export type Command = Intent & { session: string; sequence: number }
export interface MediaSource {
id: string
action: Action
media: MediaKind
name: string
kind: MediaKind
url: string
}
export interface Playlist {
id: number
name: string
tracks: { id: number; name: string; source: MediaSource }[]
}
export interface Catalog {
key: string | null
name: string
media: MediaSource[]
playlists: Playlist[]
}
export interface ChannelState {
operation: number
seekOperation: number
source: MediaSource | null
desired: 'playing' | 'paused' | 'stopped'
status: Playback
position: number
duration: number | null
volume: number
error: string
playlistId: number | null
trackIndex: number
loop: boolean
}
export interface State {
session: string
sequence: number
revision: number
command: Command | null
status: 'idle' | 'pending' | 'playing' | 'paused' | 'stopped' | 'ended' | 'error'
error: string
samples: { media: MediaKind; milliseconds: number }[]
outputOpen: boolean
catalog: Catalog
muted: boolean
channels: Record<Channel, ChannelState>
samples: { channel: Channel; media: MediaKind; milliseconds: number }[]
}
export interface Report {
session: string
channel: Channel
operation: number
status: 'playing' | 'paused' | 'stopped' | 'ended' | 'error'
position: number
duration: number | null
error?: string
}
export interface DisplayInfo {
id: number
@@ -20,11 +69,18 @@ export interface DisplayInfo {
export interface HostInfo {
displays: DisplayInfo[]
}
export interface PackageSummary {
key: string
version: string
name: string
}
export interface ScreenBridge {
command: (command: Command) => Promise<void>
report: (report: { id: string; status: State['status']; error?: string }) => void
report: (report: Report) => void
state: () => Promise<State>
subscribe: (listener: (state: State) => void) => () => void
host: () => Promise<HostInfo>
show: (displayId: number) => Promise<void>
packages: () => Promise<PackageSummary[]>
selectPackage: (key: string | null) => Promise<State>
}
+259
View File
@@ -0,0 +1,259 @@
import type { Channel, ChannelState, Report, State } from '../types/screen.js'
function waitUntil(
media: HTMLMediaElement,
event: string,
ready: () => boolean,
signal: AbortSignal,
start?: () => void,
) {
return new Promise<void>((resolve, reject) => {
const finish = (error?: Error) => {
clearTimeout(timer)
media.removeEventListener(event, check)
media.removeEventListener('error', failed)
signal.removeEventListener('abort', aborted)
if (error) reject(error)
else resolve()
}
const check = () => {
if (ready()) finish()
}
const failed = () => finish(new Error('Не удалось прочитать локальное медиа'))
const aborted = () => finish(new Error('Операция отменена'))
const timer = setTimeout(() => finish(new Error('Медиа не подготовлено за 10 секунд')), 10000)
media.addEventListener(event, check)
media.addEventListener('error', failed)
signal.addEventListener('abort', aborted, { once: true })
try {
start?.()
if (signal.aborted) aborted()
else if (media.error) failed()
else check()
} catch {
failed()
}
})
}
interface Runtime {
operation: number
seekOperation: number
abort: AbortController | null
media: HTMLMediaElement | null
sourceId: string | null
}
const runtime = (): Runtime => ({
operation: -1,
seekOperation: -1,
abort: null,
media: null,
sourceId: null,
})
// DOM и асинхронные операции остаются здесь; решения о плейлисте принимает main.
export class MediaPlayer {
private state: State | null = null
private revision = -1
private channels: Record<Channel, Runtime> = { main: runtime(), music: runtime() }
constructor(
private elements: {
audio: HTMLAudioElement
video: HTMLVideoElement
music: [HTMLAudioElement, HTMLAudioElement]
},
private report: (report: Report) => void,
) {}
private all() {
return [this.elements.audio, this.elements.video, ...this.elements.music]
}
private clear(media: HTMLMediaElement) {
media.pause()
if (media.getAttribute('src')) {
media.removeAttribute('src')
media.load()
}
}
destroy() {
for (const r of Object.values(this.channels)) r.abort?.abort()
for (const media of this.all()) this.clear(media)
this.channels = { main: runtime(), music: runtime() }
this.state = null
}
apply(state: State) {
if (state.revision <= this.revision) return
if (this.state && this.state.session !== state.session) this.destroy()
this.revision = state.revision
this.state = state
for (const channel of ['main', 'music'] as const) {
const r = this.channels[channel]
if (r.media) {
r.media.volume = state.channels[channel].volume / 100
r.media.muted = state.muted
if (state.channels[channel].desired !== 'playing') r.media.pause()
}
if (r.operation !== state.channels[channel].operation) void this.applyChannel(channel, state)
}
this.warmNext()
}
private source(media: HTMLMediaElement, url: string) {
if (media.getAttribute('src') === url && !media.error) return
media.pause()
media.preload = 'auto'
media.src = url
media.load()
}
private warmNext() {
const state = this.state
const active = this.channels.music.media
if (!state || !active) return
const c = state.channels.music
const tracks = state.catalog.playlists.find((p) => p.id === c.playlistId)?.tracks ?? []
const next = tracks[c.trackIndex + 1] ?? (c.loop ? tracks[0] : undefined)
const spare = this.elements.music.find((media) => media !== active)!
spare.pause()
spare.muted = true
if (next) this.source(spare, next.source.url)
else this.clear(spare)
}
private async applyChannel(channel: Channel, snapshot: State) {
const c = snapshot.channels[channel]
const r = this.channels[channel]
r.abort?.abort()
const abort = new AbortController()
r.abort = abort
r.operation = c.operation
const session = snapshot.session
const previousId = r.sourceId
r.sourceId = c.source?.id ?? null
if (!c.source) {
if (channel === 'main') {
this.clear(this.elements.audio)
this.clear(this.elements.video)
} else for (const media of this.elements.music) this.clear(media)
r.media = null
return
}
let media: HTMLMediaElement
if (channel === 'main') {
media = c.source.kind === 'audio' ? this.elements.audio : this.elements.video
this.clear(c.source.kind === 'audio' ? this.elements.video : this.elements.audio)
} else {
media =
r.media && previousId === c.source.id
? r.media
: this.elements.music.find((element) => element !== r.media)!
if (r.media && r.media !== media) r.media.pause()
}
r.media = media
media.pause()
media.volume = c.volume / 100
media.muted = snapshot.muted
let failed = false
const current = () =>
this.state?.session === session && r.abort === abort && !abort.signal.aborted
const emit = (status: Report['status'], error?: string) => {
if (!current()) return
this.report({
session,
channel,
operation: c.operation,
status,
position: Number.isFinite(media.currentTime) ? Math.max(0, media.currentTime) : 0,
duration: Number.isFinite(media.duration) ? Math.max(0, media.duration) : null,
...(error ? { error } : {}),
})
}
const fail = (message: string) => {
if (!current() || failed) return
failed = true
media.pause()
emit('error', message)
}
media.addEventListener(
'error',
() => fail('Ошибка локального медиа; проверьте пакет и поддержку формата'),
{ signal: abort.signal },
)
media.addEventListener(
'ended',
() => {
if (current() && media.ended && this.state?.channels[channel].desired === 'playing')
emit('ended')
},
{ signal: abort.signal },
)
try {
this.source(media, c.source.url)
this.warmNext()
await waitUntil(media, 'canplay', () => media.readyState >= 3, abort.signal)
if (!current() || failed) return
if (!Number.isFinite(media.duration) || media.duration <= 0)
throw new Error('Не удалось определить длительность файла')
const position = Math.min(c.position, media.duration)
if (
(c.seekOperation > r.seekOperation || previousId !== c.source.id) &&
Math.abs(media.currentTime - position) > 0.01
) {
await waitUntil(
media,
'seeked',
() => !media.seeking && Math.abs(media.currentTime - position) < 0.25,
abort.signal,
() => {
media.currentTime = position
},
)
}
if (!current() || failed) return
r.seekOperation = c.seekOperation
if (c.desired === 'playing') {
const playing = media.play()
// Даже позднее завершение play не должно оживлять уже отменённый канал.
void playing.then(
() => {
const latest = this.state?.channels[channel]
if (this.channels[channel].media !== media || !latest || latest.desired !== 'playing')
media.pause()
},
() => {},
)
await new Promise<void>((resolve, reject) => {
const finish = (error?: unknown) => {
clearTimeout(timer)
abort.signal.removeEventListener('abort', cancelled)
if (error) reject(error)
else resolve()
}
const cancelled = () => finish(new Error('Операция отменена'))
const timer = setTimeout(
() => finish(new Error('Воспроизведение не началось за 10 секунд')),
10000,
)
abort.signal.addEventListener('abort', cancelled, { once: true })
playing.then(() => finish(), finish)
if (abort.signal.aborted) cancelled()
})
}
if (!current() || failed) return
const actual = () => this.actualStatus(media, this.state!.channels[channel])
emit(actual())
let lastTime = 0
media.addEventListener(
'timeupdate',
() => {
if (!current() || failed || performance.now() - lastTime < 250) return
lastTime = performance.now()
emit(actual())
},
{ signal: abort.signal },
)
} catch (error) {
if (current()) fail(error instanceof Error ? error.message : 'Ошибка воспроизведения')
}
}
private actualStatus(media: HTMLMediaElement, c: ChannelState): Report['status'] {
if (media.ended || c.status === 'ended') return 'ended'
if (!media.paused) return 'playing'
return c.desired === 'stopped' ? 'stopped' : 'paused'
}
}