up
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 — Пульт ведущего и все режимы показа
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user