diff --git a/README.md b/README.md index 653703a..ac2ca82 100644 --- a/README.md +++ b/README.md @@ -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//` выдаёт только ресурсы готового пакета, с правильным 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. diff --git a/TASKS.md b/TASKS.md index 1895e98..777a9f4 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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//. - Цель: применять команды пульта независимо от сервера. @@ -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 — Пульт ведущего и все режимы показа diff --git a/docs/SCREEN-004_ACCEPTANCE.md b/docs/SCREEN-004_ACCEPTANCE.md new file mode 100644 index 0000000..f3fc0b4 --- /dev/null +++ b/docs/SCREEN-004_ACCEPTANCE.md @@ -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. diff --git a/electron/controller.test.ts b/electron/controller.test.ts index dae5cb2..b6d07f1 100644 --- a/electron/controller.test.ts +++ b/electron/controller.test.ts @@ -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) diff --git a/electron/controller.ts b/electron/controller.ts index ae1f7fb..cf06b32 100644 --- a/electron/controller.ts +++ b/electron/controller.ts @@ -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 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() - 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() + 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) { + 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 = { 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 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() } } diff --git a/electron/main.ts b/electron/main.ts index 19b465d..178cd65 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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 ( diff --git a/electron/media-player.test.ts b/electron/media-player.test.ts new file mode 100644 index 0000000..f69f944 --- /dev/null +++ b/electron/media-player.test.ts @@ -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((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') +}) diff --git a/electron/preload.ts b/electron/preload.ts index 4993c1e..a155c71 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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), }) diff --git a/package.json b/package.json index 7f1b179..93afd9e 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index 36a2710..03533e4 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -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) diff --git a/src/components/ControlPage.vue b/src/components/ControlPage.vue index 9ff2140..c40c373 100644 --- a/src/components/ControlPage.vue +++ b/src/components/ControlPage.vue @@ -1,38 +1,54 @@ diff --git a/src/types/screen.ts b/src/types/screen.ts index 4579ad5..4f07025 100644 --- a/src/types/screen.ts +++ b/src/types/screen.ts @@ -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 + 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 - report: (report: { id: string; status: State['status']; error?: string }) => void + report: (report: Report) => void state: () => Promise subscribe: (listener: (state: State) => void) => () => void host: () => Promise show: (displayId: number) => Promise + packages: () => Promise + selectPackage: (key: string | null) => Promise } diff --git a/src/utils/media-player.ts b/src/utils/media-player.ts new file mode 100644 index 0000000..d3f6f54 --- /dev/null +++ b/src/utils/media-player.ts @@ -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((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 = { 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((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' + } +}