up
This commit is contained in:
@@ -237,3 +237,31 @@
|
||||
- Проверки: повторный поиск устаревших упоминаний, локальные Markdown-ссылки,
|
||||
ссылки на переименованные разделы, ручное ревью diff и git diff --check — PASS.
|
||||
Тесты приложения не запускались: исполняемый код не менялся.
|
||||
|
||||
## SCREEN-012 — Постоянная синхронизация и автоматическая подготовка материалов
|
||||
|
||||
- Статус: `DONE`.
|
||||
- Дата: 2026-10-18.
|
||||
- Результат: опрос сервера каждую секунду, включая закрытый показ; при подключении,
|
||||
повторном открытии и восстановлении связи принимается актуальное состояние
|
||||
экрана и музыки. Окно показа автоматически не открывается.
|
||||
- Игра подключается без готового пакета. Отсутствующие материалы автоматически
|
||||
скачиваются атомарно в локальный кеш; проигрыватель читает только локальные URL.
|
||||
Текущие экран и музыка готовятся первыми, затем остальные материалы игры и
|
||||
связанных плейлистов. До готовности замены сохраняется предыдущий канал;
|
||||
ожидание музыки не задерживает смену текста или другой готовой сцены.
|
||||
- Локальные команды с готовыми файлами применяются сразу и отправляются серверу;
|
||||
офлайн-команды после восстановления связи не повторяются. Текст публикуется
|
||||
существующим API плана. Исправлены гонки смены файла, подтверждения команд,
|
||||
завершения прежнего трека и обновления описания кеша. Предзагрузка следующего
|
||||
трека начинается только после завершения его загрузки.
|
||||
- Проверки: 99 тестов, type-check, lint, lint:css, сборка и Electron GUI smoke — PASS.
|
||||
GUI проверяет подключение без пакета, дозагрузку с сохранением прежней музыки,
|
||||
независимую смену текста, локальное воспроизведение нового трека, восстановление
|
||||
связи, план и устойчивость показа. 33 локальные Markdown-ссылки и
|
||||
git diff --check — PASS.
|
||||
- Границы: изменения только evening_detective_screen; сервер, сайт, protobuf
|
||||
и зависимости не менялись. Установочные архивы не пересобирались; реальный
|
||||
сервер и физический проектор/звук не проверялись. Замена байтов по прежнему
|
||||
URL требует ручной повторной загрузки; точность позиции ограничена API сервера.
|
||||
Commit/push не выполнялись.
|
||||
|
||||
@@ -85,9 +85,10 @@ Linux проверяется на обоих указанных дистрибу
|
||||
Серверные команды проходят через локальный контроллер и используют подготовленные
|
||||
файлы. Одну версию команды нельзя применять повторно. При потере сети
|
||||
уже запущенное воспроизведение и локальный пульт продолжают работать.
|
||||
При восстановлении сети необходимо сверить версии и не повторять
|
||||
устаревшую команду поверх локальных действий; правила согласования проверяются
|
||||
в SCREEN-006 на существующих ScreenState/ScreenCommand.
|
||||
При восстановлении сети применяется текущее состояние сервера. Опрос работает
|
||||
и при закрытом показе; открытие окна остаётся ручным. Недостающие материалы
|
||||
скачиваются автоматически, воспроизведение начинается только с локального диска.
|
||||
Эти правила SCREEN-012 заменяют прежнее пропускание первого серверного состояния.
|
||||
9. Текущие результаты игры загружаются онлайн с отметкой времени; офлайн доступны
|
||||
только последние сохранённые данные с явным предупреждением оператору.
|
||||
Серверная игра, ответы команд и вычисление новых результатов офлайн не работают.
|
||||
|
||||
@@ -6,13 +6,17 @@
|
||||
офлайн без автоматического открытия показа и запуска звука.
|
||||
Приложение экрана получает обновления через существующий сервер сайта.
|
||||
|
||||
SCREEN-012: постоянная синхронизация с сервером и автоматический локальный кеш
|
||||
материалов; установочные архивы эти изменения пока не содержат.
|
||||
|
||||
SCREEN-010 реализована: в пульте доступен редактор общего серверного плана
|
||||
экранов с локальной копией для офлайн-переходов. Подробности — в руководстве.
|
||||
Установочные архивы кандидата эти изменения пока не содержат.
|
||||
|
||||
Цель: ведущий управляет показом на компьютере с macOS, Windows или Linux, а подключённый монитор или
|
||||
проектор показывает материалы игрокам. Все материалы показа заранее находятся
|
||||
на диске; локальная команда не ждёт сеть или ответ сервера.
|
||||
проектор показывает материалы игрокам. Материалы воспроизводятся с диска;
|
||||
отсутствующие файлы автоматически загружаются. Команда с готовым локальным
|
||||
файлом применяется сразу, без ожидания ответа сервера.
|
||||
|
||||
Стек: Electron + Vue + TypeScript + Vite. Первая версия включает шесть сборок:
|
||||
macOS Intel/Apple Silicon, Windows x64/ARM64 и Linux x64/ARM64.
|
||||
@@ -80,16 +84,20 @@ npm run dev
|
||||
Тестовый режим сохраняет отдельный выбор WAV/видео; игровые пакеты выбираются
|
||||
в разделе «Материалы показа». В блоке «Игра и сервер» доступны вход, список игр и загрузка материалов.
|
||||
|
||||
Для получения серверных команд войдите, подготовьте и выберите игру, подключите управление
|
||||
и откройте показ. Первое состояние не запускает накопленные команды;
|
||||
отправьте новую команду на сайте. После обрыва сети локальный пульт продолжает
|
||||
работать, первое состояние при восстановлении также пропускается.
|
||||
Для постоянной связи войдите, выберите игру в серверном списке и нажмите
|
||||
«Подключить выбранную игру». Готовый пакет заранее не требуется: сначала сохраняются
|
||||
файлы текущего показа и трека, затем вся игра и связанные плейлисты.
|
||||
Опрос продолжается при закрытом окне. Окно показа открывается вручную; при открытии
|
||||
и восстановлении сети принимается актуальное состояние сервера, включая пуск медиа.
|
||||
Новая сцена или музыка включается только после сохранения её файлов на диск;
|
||||
до этого прежний канал продолжает работать. Онлайн-команды пульта общие с сервером,
|
||||
офлайн-команды остаются локальными и не отправляются позднее.
|
||||
[Проверка связи с существующим сервером](docs/SCREEN-006_ACCEPTANCE.md).
|
||||
|
||||
Токены хранятся в основном процессе и сохраняются средствами ОС отдельно от
|
||||
пакетов. Если защищённое хранилище недоступно, вход действует только до закрытия
|
||||
приложения. Выход не удаляет подготовленные материалы. Обновление выбранной
|
||||
игры доступно при закрытом показе; проверка описания не замечает замену байтов
|
||||
игры полной повторной загрузкой доступно при закрытом показе; проверка описания не замечает замену байтов
|
||||
по прежнему URL — используйте повторную загрузку. Результаты доступны после
|
||||
завершения игры; последний успешный снимок сохраняется с датой для офлайн-показа.
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
и выбор внешнего дисплея; явно нажать «Открыть показ» после восстановления.
|
||||
- [ ] При отсутствии звука проверить системный выход, HDMI, mute и громкость обоих каналов.
|
||||
- [ ] После аварии проверить остаток таймера на паузе; медиа запускать явно.
|
||||
- [ ] После возврата сети дождаться связи и отправить новую команду через сайт.
|
||||
- [ ] После возврата сети проверить применение текущего серверного состояния.
|
||||
- [ ] При повторном сбое сохранить журналы и сведения по
|
||||
[руководству](USER_GUIDE.md#журналы-и-обращение-за-помощью).
|
||||
|
||||
|
||||
@@ -17,60 +17,41 @@
|
||||
до выхода из приложения. На Linux требуется рабочий libsecret/KWallet;
|
||||
`basic_text` не используется для сохранения секретов.
|
||||
|
||||
## Серверные команды
|
||||
## Постоянная связь и автоматическая подготовка SCREEN-012
|
||||
|
||||
1. Нажать «Подключить управление этой игрой», записать номер экрана.
|
||||
Открыть показ на внешнем дисплее. Дождаться сообщения о связи.
|
||||
2. Открыть существующую страницу управления этой игрой на сайте.
|
||||
Убедиться, что экран появился в её списке. Назначить звук этому экрану
|
||||
через сайт или одноимённую кнопку приложения; это отключает звук других
|
||||
экранов игры по существующим правилам сервера.
|
||||
3. Отправить новую команду смены режима, пуска вступления, паузы, продолжения,
|
||||
перемотки и громкости. Повторить с заключительным видео.
|
||||
4. Проверить текст, настройку/пуск/паузу/сброс таймера и музыку: выбор трека,
|
||||
громкость, повтор и автоматический переход. Все используемые ресурсы должны
|
||||
быть в скачанном пакете; новый плейлист требует подготовки материалов.
|
||||
5. Чередовать серверные команды и локальный пульт. Локальная пауза не должна отменяться повтором
|
||||
того же серверного состояния. Новая серверная команда должна применяться.
|
||||
Команды компьютера действуют только на этот показ и не рассылаются другим экранам.
|
||||
6. Проверить прежний браузерный экран отдельно: его вход, привязка и команды
|
||||
должны работать как раньше. Общие команды игры по-прежнему относятся ко всем
|
||||
её подключённым экранам.
|
||||
1. Выбрать игру, которой ещё нет на компьютере, и нажать «Подключить выбранную
|
||||
игру». Проверить связь и прогресс загрузки при закрытом показе.
|
||||
2. Назначить звук экрану. Вручную открыть показ: текущие серверные экран и музыка
|
||||
применяются после сохранения необходимых файлов. Проверить локальные адреса
|
||||
медиа; прямого воспроизведения HTTP-файлов в renderer быть не должно.
|
||||
3. Через сайт сменить экран, текст, таймер и музыку. Повторное состояние не
|
||||
перезапускает медиа. Закрыть показ, изменить серверное состояние и открыть его
|
||||
снова: оно применяется, но закрытое окно самостоятельно не открывается.
|
||||
4. Добавить новый трек или сменить плейлист на ещё не подготовленный. На медленном
|
||||
соединении прежняя музыка продолжает играть до готовности нового файла.
|
||||
Смена текста в это время не ждёт музыку. Аналогично проверить новое вступление,
|
||||
видео и изображения. Все связанные плейлисты и остальные материалы готовятся фоном.
|
||||
5. Во время загрузки несколько раз сменить серверную команду: устаревший файл
|
||||
не должен включаться после завершения загрузки. Ошибка отдельного файла или
|
||||
нехватка места не удаляет готовые материалы; проверить «Повторить подготовку».
|
||||
6. Проверить локальные режимы, шаги, основной канал, таймер и музыку: онлайн они
|
||||
меняют общий сервер. Выключение всего звука остаётся локальным. Публикация
|
||||
произвольного текста сохраняет и выбирает текстовый шаг общего плана.
|
||||
7. Отключить сеть, изменить показ локально, затем изменить состояние на сервере.
|
||||
Вернуть сеть: серверные экран и музыка принимаются; локальные офлайн-команды
|
||||
не отправляются позднее. После полного скачивания проверить час показа без сети.
|
||||
8. Перезапустить приложение без сети с тем же профилем. Сохранённые игра и снимок
|
||||
результатов доступны, окно закрыто. Открыть его и проверить локальные файлы.
|
||||
Повредить один тестовый файл, восстановить его автоматически при доступной сети
|
||||
и повторить перезапуск: остальные ресурсы и исправленный файл должны сохраниться.
|
||||
9. Проверить истечение JWT и экранной сессии, смену игры во время загрузки,
|
||||
выход без удаления файлов и сохранность предыдущего полного пакета после
|
||||
неудачной ручной перезагрузки. Не менять рабочую базу ради проверки.
|
||||
|
||||
Опрос идёт раз в секунду, только пока окно показа открыто. Первое состояние при
|
||||
подключении/повторном открытии становится исходной отметкой: старые команды не
|
||||
запускаются, назначение звука учитывается при первом подключении. Для показа
|
||||
отправьте новую команду. Сайт показывает наличие связи; точные подтверждения
|
||||
проигрывателя и ошибки доступны в пульте приложения, поскольку существующий API
|
||||
не принимает их от экрана.
|
||||
|
||||
## Обрыв связи, обновления и перезапуск
|
||||
|
||||
1. Во время воспроизведения отключить сеть компьютера. Проверить локальные
|
||||
play/pause/seek, переход музыки и продолжающийся таймер.
|
||||
2. При отключённой сети изменить показ локально, затем отправить команду
|
||||
через сайт. Вернуть сеть: накопленное состояние не должно отменить локальное
|
||||
действие. Следующая новая серверная команда должна примениться.
|
||||
3. Проверить истечение пользовательского входа и отдельно недействительную
|
||||
экранную сессию на тестовом сервере. Показ не прерывается; интерфейс предлагает
|
||||
повторный вход или подключение управления. Не менять рабочую базу ради проверки.
|
||||
4. Закрыть показ. Изменить материалы игры на сервере и проверить изменения
|
||||
в приложении. Явно загрузить новую версию. При открытом показе обновление
|
||||
выбранной игры должно отклоняться. Ошибка и отмена загрузки сохраняют старую копию.
|
||||
5. Завершить игру и обновить снимок результатов. Проверить таблицу и время снимка.
|
||||
Ошибка обновления должна сохранять последний успешный снимок.
|
||||
6. Закрыть приложение, отключить сеть и запустить снова. Выбранная игра и снимок
|
||||
результатов доступны; окно показа и звук сами не запускаются. Открыть показ
|
||||
и проверить локальное воспроизведение.
|
||||
7. Выход из аккаунта не удаляет материалы и не прерывает локальное воспроизведение,
|
||||
но отключает получение команд сервера. Удалить невыбранный пакет через интерфейс.
|
||||
|
||||
Проверка метаданных не обнаруживает новые байты файла по прежнему URL:
|
||||
для этого нужна повторная загрузка. Сервер хранит последнее состояние, поэтому
|
||||
несколько команд между опросами могут объединиться. После неопределённого исхода
|
||||
запроса перехода музыки приложение не повторяет его автоматически. Секреты
|
||||
находятся отдельно от пакетов в зашифрованном файле `connection/credentials.bin`
|
||||
в userData; пароль не сохраняется. Снимки содержат только поля публичной таблицы.
|
||||
Ручная «Загрузить материалы» остаётся для принудительного обновления файлов по
|
||||
прежнему адресу и требует закрытого показа. Существующие полные пакеты совместимы
|
||||
с автоматическим кешем. Пароль не сохраняется, токены остаются в основном процессе
|
||||
и защищённом хранилище ОС; снимки содержат только публичные поля результатов.
|
||||
|
||||
## Фиксация результата
|
||||
|
||||
|
||||
@@ -74,7 +74,8 @@
|
||||
другой экран требуется выбрать вручную. Автоматического открытия нет.
|
||||
3. Вернуть сеть и подключить управление игрой. После аварийного закрытия
|
||||
отправить через сайт команду PLAY, затем восстановить и вручную открыть показ.
|
||||
Накопленная команда не запускает звук. Следующая новая команда применяется.
|
||||
Пока окно закрыто, звука нет. После ручного открытия применяется текущая
|
||||
серверная команда; повторные ответы её не перезапускают.
|
||||
4. Закрыть приложение во время показа. Звук прекращается, блокировка сна
|
||||
освобождается, материалы остаются на диске. При повторном запуске нет автопуска.
|
||||
|
||||
|
||||
@@ -129,8 +129,8 @@ preload, локальную выдачу Range, восстановление п
|
||||
4. Выполнить SCREEN-007: переподключение проектора, смена параметров дисплея,
|
||||
fullscreen, автоматический/принудительный сон, кабельный/USB/HDMI звук.
|
||||
Восстановление требует явного открытия; медиа остановлены, таймер на паузе.
|
||||
5. Вернуть сеть. Старая серверная команда не отменяет локальное действие;
|
||||
новая команда применяется. Завершить игру и сохранить результаты с датой снимка.
|
||||
5. Вернуть сеть. Принимаются текущие серверные экран и музыка, локальные
|
||||
офлайн-команды не отправляются позднее. Завершить игру и сохранить результаты с датой снимка.
|
||||
6. Перезапустить приложение без сети: выбранная игра/снимок доступны, окно показа
|
||||
закрыто, звук не начинается. Заменить папку приложения новой сборкой с тем же
|
||||
пользовательским профилем и повторить проверку сохранности данных.
|
||||
|
||||
+41
-40
@@ -58,26 +58,27 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
||||
Нажмите «Войти». Пароль вводите только в приложении, не отправляйте в отчёты.
|
||||
2. При необходимости нажмите «Обновить список игр» и выберите игру
|
||||
в поле «Выберите игру для подготовки».
|
||||
3. Нажмите «Загрузить материалы». Дождитесь завершения; при необходимости
|
||||
используйте «Отменить загрузку». Не выезжайте с незавершённой загрузкой.
|
||||
4. В отдельном блоке **«Материалы показа» выберите подготовленную игру**.
|
||||
Загрузка и выбор игры на сервере сами по себе не выбирают пакет для показа.
|
||||
Статусы игр отображаются как «Черновик», «Идёт», «Пауза», «Завершена».
|
||||
Сообщение «Тестовый режим: синтетические аудио и видео» означает, что игровая
|
||||
копия не выбрана.
|
||||
5. Откройте показ, проверьте изображения, вступление, видео и плейлисты.
|
||||
Отключите сеть и повторите пробный показ. Затем перезапустите приложение
|
||||
без сети и убедитесь, что выбранная игра доступна.
|
||||
3. Нажмите «Подключить выбранную игру». Она станет игрой показа, даже если ещё
|
||||
не подготовлена на этом компьютере. Отдельно скачивать полный пакет не требуется.
|
||||
4. Приложение получает состояние экрана и музыки раз в секунду, сначала скачивает
|
||||
нужные сейчас файлы, затем остальные материалы игры и связанные плейлисты.
|
||||
Связь и подготовка продолжаются при закрытом показе. Прогресс виден в пульте.
|
||||
При ошибке доступна кнопка «Повторить подготовку»; уже сохранённые файлы остаются.
|
||||
5. Откройте показ вручную. Приложение применит текущую серверную сцену и музыку,
|
||||
включая воспроизведение. До готовности нового файла прежнее содержимое канала
|
||||
сохраняется. Все медиа читаются только с локального диска.
|
||||
6. Перед выездом дождитесь полной подготовки без ошибок и проверьте игру без сети.
|
||||
«Материалы показа» позволяет выбирать ранее сохранённые игры и тестовый режим.
|
||||
Офлайн доступны только фактически скачанные файлы.
|
||||
|
||||
Если ОС не предоставляет защищённое хранилище, приложение предупреждает:
|
||||
вход действует до закрытия приложения, но материалы сохраняются. После перезапуска
|
||||
для серверного управления потребуется повторный вход; локальный пакет доступен офлайн.
|
||||
|
||||
Для обновления материалов закройте показ, выберите игру на сервере и нажмите
|
||||
«Проверить изменения», затем явно «Загрузить материалы». Если файл заменили
|
||||
по прежнему адресу, проверка изменений может этого не заметить — загрузите
|
||||
материалы повторно. Новый плейлист тоже требует подготовки. Ошибка или отмена
|
||||
обновления сохраняет прежнюю готовую копию.
|
||||
Новые адреса материалов и изменения плейлистов автоматически подготавливаются
|
||||
во время показа. Если заменили содержимое файла по прежнему адресу, для
|
||||
принудительной загрузки закройте показ и нажмите «Загрузить материалы».
|
||||
Ошибка или отмена ручного обновления сохраняет прежний полный пакет.
|
||||
|
||||
## Проведение показа
|
||||
|
||||
@@ -95,7 +96,9 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
||||
режим, текст, надпись и длительность таймера. «Сохранить план» отправляет план
|
||||
на сервер; «Отменить» возвращает сохранённый вариант. Для удаления всего плана
|
||||
нажмите «Удалить план», затем «Сохранить план». При ошибке сохранения черновик
|
||||
остаётся в редакторе. Изменения черновика и сохранение сами не меняют проектор.
|
||||
остаётся в редакторе. Изменения черновика и сохранение сами не меняют проектор. Публикация текста
|
||||
кнопкой «Показать текст» использует подходящий текстовый шаг общего плана
|
||||
или добавляет новый, поскольку сервер передаёт произвольный текст через план.
|
||||
|
||||
План общий с сайтом. При параллельных правках последнее сохранение
|
||||
заменяет его целиком. Во время редактирования фоновое обновление не меняет
|
||||
@@ -106,8 +109,8 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
||||
локально сразу. При наличии связи действие отправляется на сервер для остальных
|
||||
привязанных экранов. Без сети показ продолжается по последней копии; статус
|
||||
предупреждает о неподтверждённом обновлении. Офлайн-команды не накапливаются:
|
||||
восстановление связи само не меняет проектор и не отправляет прошлые переходы.
|
||||
Следующее действие онлайн снова управляет общим планом.
|
||||
после восстановления связи принимается текущее серверное состояние. Прошлые
|
||||
локальные переходы на сервер не отправляются.
|
||||
|
||||
### Вступление, видео и музыка
|
||||
|
||||
@@ -148,39 +151,37 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
||||
|
||||
## Связь с сервером
|
||||
|
||||
1. Войдите и выберите готовую игру в «Материалы показа».
|
||||
2. Проверьте строку «Игра для управления»: это название локально выбранного
|
||||
пакета, независимо от игры в списке загрузки выше. Нажмите «Подключить управление
|
||||
этой игрой»; запишите показанный номер экрана.
|
||||
Откройте показ и дождитесь «Связь с сервером есть».
|
||||
3. Откройте существующую страницу управления этой игрой на сайте.
|
||||
При необходимости назначьте звук экрану через сайт или кнопку
|
||||
«Назначить звук этому экрану». Это отключает звук других экранов игры
|
||||
по существующим правилам сервера. Статус назначения звука показывается отдельно
|
||||
от локального переключателя «Выключить весь звук». До подтверждения сервером
|
||||
назначение неизвестно; при закрытом показе или потере связи отображается
|
||||
последнее подтверждённое состояние.
|
||||
4. Отправьте новую команду. Первое состояние после подключения, открытия окна
|
||||
или восстановления связи не повторяет старые команды.
|
||||
Подключите выбранную игру кнопкой в «Игра и сервер». Для ранее сохранённой игры
|
||||
можно использовать «Подключить управление этой игрой». Проверьте название
|
||||
в строке «Игра для управления» и номер экрана. Подготовка файлов не требует
|
||||
открытого показа; само окно автоматически не открывается.
|
||||
|
||||
Опрос сервера идёт раз в секунду при открытом показе. Быстрые команды между
|
||||
опросами могут объединяться. Локальные команды компьютера действуют только
|
||||
на его показ и не рассылаются другим экранам. Новая команда сервера может
|
||||
изменить локальный показ. «Отключить управление» прекращает управление приложением,
|
||||
но сохраняет привязку экрана на сервере. Для отвязки используйте сайт.
|
||||
При отсутствии интернета пользуйтесь локальным пультом.
|
||||
Приложение постоянно принимает изменения сцены, текста, таймера и музыки.
|
||||
Первый ответ после подключения, открытия окна или восстановления сети применяется
|
||||
как текущее состояние сервера. Одинаковая версия не запускает медиа повторно.
|
||||
Команды локального пульта при доступной сети отправляются на сервер и относятся
|
||||
ко всем привязанным экранам. Выключение всего звука остаётся локальной настройкой.
|
||||
|
||||
Назначение звука экрану меняется отдельно через сайт или «Назначить звук этому
|
||||
экрану». Оно отключает звук остальных экранов по правилам сервера. При отсутствии
|
||||
связи отображается последнее подтверждённое назначение.
|
||||
|
||||
Без сети продолжайте локально. После её возврата серверное состояние заменяет
|
||||
локальное; накопленные локальные действия не отправляются. Точная синхронизация
|
||||
позиций ограничена данными существующего API. «Отключить управление» прекращает
|
||||
получение состояний, но не удаляет привязку экрана на сервере.
|
||||
|
||||
## Сбои и восстановление
|
||||
|
||||
| Ситуация | Действия ведущего |
|
||||
| --- | --- |
|
||||
| Нет сети | Продолжайте локально. После возврата сети дождитесь связи и отправьте новую команду через сайт. |
|
||||
| Нет сети | Продолжайте локально. После возврата сети приложение примет текущие экран и музыку сервера. |
|
||||
| Проектор отключился, изменилось разрешение или произошёл сон | Прочитайте причину в пульте, восстановите дисплей/выход звука, выберите внешний экран и явно откройте показ. Медиа остановлены, работающий таймер сохранён на паузе. |
|
||||
| Нет звука | Проверьте выход и mute ОС, кабель/USB/HDMI, «Выключить весь звук», громкость канала, открытое окно и статус проигрывателя. Для серверных команд проверьте назначение звука экрану. |
|
||||
| Ошибка подготовки сцены | Проверьте сообщение и нажмите «Повторить подготовку». При повторении проверьте пакет до продолжения игры. |
|
||||
| Пакет повреждён или отсутствует файл | При доступной сети закройте показ и загрузите материалы заново. Для удаления выберите другой пакет или тестовый режим, затем «Удалить материалы» у нужной игры. Без сети восстановить отсутствующие байты нельзя. |
|
||||
| Истёк вход или сессия экрана | Повторно войдите или подключите управление по сообщению пульта. Готовые материалы остаются на диске. |
|
||||
| Приложение перезапущено | Выбранная игра и сохранённые результаты восстанавливаются. Откройте показ и запустите нужные медиа/таймер явно; их текущее выполнение не восстанавливается. |
|
||||
| Приложение перезапущено | Выбранная игра и сохранённые результаты восстанавливаются. Откройте показ вручную: онлайн применяется текущее состояние сервера, офлайн запускайте медиа локально. |
|
||||
| Пульт пуст или зависает при запуске | Запишите ОС, версию, симптомы и сообщения запуска, передайте отчёт. Не считайте такой запуск успешным. |
|
||||
|
||||
При открытом показе приложение запрашивает блокировку автоматического сна,
|
||||
|
||||
+33
-4
@@ -161,7 +161,7 @@ function initialTimer(): TimerState {
|
||||
}
|
||||
}
|
||||
export class Controller {
|
||||
onMusicEnded: ((report: Report) => void) | null = null
|
||||
onMusicEnded: ((report: Report) => boolean | void) | null = null
|
||||
state: State = {
|
||||
session: randomUUID(),
|
||||
sequence: 0,
|
||||
@@ -226,7 +226,7 @@ export class Controller {
|
||||
for (const c of Object.values(this.state.channels)) c.status = c.source ? 'stopped' : 'idle'
|
||||
this.broadcast()
|
||||
}
|
||||
selectPackage(pkg: LocalPackage | null) {
|
||||
selectPackage(pkg: LocalPackage | null, preserve = false) {
|
||||
let catalog = fixtureCatalog()
|
||||
if (pkg) {
|
||||
const source = (id: string, assetId: string, name: string, kind: 'audio' | 'video') => ({
|
||||
@@ -234,6 +234,7 @@ export class Controller {
|
||||
name,
|
||||
kind,
|
||||
url: `screen-app://app/packages/${pkg.key}/${assetId}`,
|
||||
ready: pkg.files.some((file) => file.id === assetId),
|
||||
})
|
||||
const media = []
|
||||
if (pkg.package.media.intro)
|
||||
@@ -264,6 +265,13 @@ export class Controller {
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (preserve && catalog.key === this.state.catalog.key) {
|
||||
catalog.cover = this.state.catalog.cover
|
||||
catalog.background = this.state.catalog.background
|
||||
this.state.catalog = catalog
|
||||
this.broadcast()
|
||||
return
|
||||
}
|
||||
this.state.catalog = catalog
|
||||
this.state.scene = {
|
||||
revision: this.state.scene.revision,
|
||||
@@ -346,6 +354,28 @@ export class Controller {
|
||||
this.timerDeadline = timer.status === 'running' ? this.now() + timer.remainingMs : null
|
||||
this.broadcast()
|
||||
}
|
||||
markAssetReady(assetId: string) {
|
||||
let changed = false
|
||||
for (const source of [
|
||||
...this.state.catalog.media,
|
||||
...this.state.catalog.playlists.flatMap((playlist) =>
|
||||
playlist.tracks.map((track) => track.source),
|
||||
),
|
||||
]) {
|
||||
if (source.url.endsWith(`/${assetId}`) && source.ready === false) {
|
||||
source.ready = true
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) this.broadcast()
|
||||
}
|
||||
setImages(cover: string | null, background: string | null) {
|
||||
if (cover === this.state.catalog.cover && background === this.state.catalog.background) return
|
||||
this.state.catalog.cover = cover
|
||||
this.state.catalog.background = background
|
||||
this.invalidateScene()
|
||||
this.broadcast()
|
||||
}
|
||||
setPlan(steps: ScreenStep[]) {
|
||||
if (isDeepStrictEqual(steps, this.state.catalog.steps)) return
|
||||
const index = this.state.scene.stepIndex
|
||||
@@ -620,8 +650,7 @@ export class Controller {
|
||||
}
|
||||
if (report.status === 'ended') {
|
||||
if (report.channel === 'music') {
|
||||
this.onMusicEnded?.(report)
|
||||
this.advance()
|
||||
if (!this.onMusicEnded?.(report)) this.advance()
|
||||
} else c.desired = 'paused'
|
||||
}
|
||||
this.broadcast()
|
||||
|
||||
+17
-15
@@ -480,6 +480,7 @@ app
|
||||
})
|
||||
let localSession = ''
|
||||
let localSequence = 0
|
||||
const pendingCommands = new Set<string>()
|
||||
ipcMain.handle('command', async (event, command: unknown) => {
|
||||
requireControl(event)
|
||||
if (selectingPackage) throw new Error('Подождите проверки пакета')
|
||||
@@ -493,22 +494,23 @@ app
|
||||
if (localSession !== parsed.session) {
|
||||
localSession = parsed.session
|
||||
localSequence = 0
|
||||
pendingCommands.clear()
|
||||
}
|
||||
if (parsed.sequence <= localSequence) return
|
||||
if (
|
||||
parsed.action === 'step' ||
|
||||
(controller.state.scene.stepIndex !== null &&
|
||||
((parsed.action.startsWith('timer-') && parsed.action !== 'timer-set') ||
|
||||
('channel' in parsed &&
|
||||
parsed.channel === 'main' &&
|
||||
['play', 'pause', 'resume', 'stop', 'replay', 'seek', 'volume'].includes(
|
||||
parsed.action,
|
||||
))))
|
||||
) {
|
||||
const before = controller.state.sequence
|
||||
const operation = online!.planCommand(parsed)
|
||||
if (controller.state.sequence !== before) localSequence = parsed.sequence
|
||||
await operation
|
||||
const pendingKey = `${parsed.session}:${parsed.sequence}`
|
||||
if (parsed.sequence <= localSequence || pendingCommands.has(pendingKey)) return
|
||||
if (parsed.action !== 'mute') {
|
||||
pendingCommands.add(pendingKey)
|
||||
try {
|
||||
const before = controller.state.sequence
|
||||
const operation = online!.planCommand(parsed)
|
||||
if (controller.state.sequence !== before)
|
||||
localSequence = Math.max(localSequence, parsed.sequence)
|
||||
await operation
|
||||
if (parsed.session === controller.state.session)
|
||||
localSequence = Math.max(localSequence, parsed.sequence)
|
||||
} finally {
|
||||
pendingCommands.delete(pendingKey)
|
||||
}
|
||||
} else {
|
||||
controller.command({ ...parsed, sequence: controller.state.sequence + 1 })
|
||||
localSequence = parsed.sequence
|
||||
|
||||
@@ -41,7 +41,12 @@ function testPackage(): LocalPackage {
|
||||
key: packageKey(pkg.server, 7),
|
||||
version: 'a'.repeat(64),
|
||||
package: pkg,
|
||||
files: [],
|
||||
files: pkg.assets.map((asset) => ({
|
||||
id: asset.id,
|
||||
size: 1,
|
||||
sha256: 'b'.repeat(64),
|
||||
mime: 'audio/wav',
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,3 +269,25 @@ test('DOM: повторный play перезагружает источник
|
||||
assert.equal(f.audio.paused, false)
|
||||
assert.equal(f.reports.at(-1)!.status, 'playing')
|
||||
})
|
||||
|
||||
test('DOM: следующий трек не предзагружается до завершения локального файла', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const next = f.c.state.catalog.playlists[0]!.tracks[1]!.source
|
||||
next.ready = false
|
||||
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.equal(spare.src, '')
|
||||
f.c.markAssetReady(next.url.split('/').at(-1)!)
|
||||
f.apply()
|
||||
await setImmediate()
|
||||
assert.equal(spare.src, next.url)
|
||||
assert.equal(spare.playCalls, 0)
|
||||
send(f.c, { action: 'next', channel: 'music' })
|
||||
f.apply()
|
||||
await setImmediate()
|
||||
assert.equal(spare.paused, false)
|
||||
assert.equal(playing.paused, true)
|
||||
})
|
||||
|
||||
+263
-19
@@ -12,7 +12,11 @@ import { applyRemote, parseRemote, send } from './remote-state.js'
|
||||
|
||||
async function fixture(t: TestContext, secure = true) {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'screen-online-'))
|
||||
t.after(() => rm(directory, { recursive: true, force: true }))
|
||||
let shutdown = async () => {}
|
||||
t.after(async () => {
|
||||
await shutdown()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
const key = randomBytes(32)
|
||||
const secrets: SecretStorage = {
|
||||
available: () => secure,
|
||||
@@ -31,6 +35,9 @@ async function fixture(t: TestContext, secure = true) {
|
||||
}
|
||||
const model = {
|
||||
offline: false,
|
||||
introURL: 'https://media.test/intro.wav',
|
||||
musicOverride: null as Record<string, unknown> | null,
|
||||
mediaHandler: null as ((url: string) => Promise<Response>) | null,
|
||||
failMedia: false,
|
||||
expired: false,
|
||||
refreshExpired: false,
|
||||
@@ -69,6 +76,7 @@ async function fixture(t: TestContext, secure = true) {
|
||||
assert.equal(init?.credentials, 'omit')
|
||||
if (url.origin === 'https://media.test') {
|
||||
assert.equal(auth, null)
|
||||
if (model.mediaHandler) return model.mediaHandler(url.href)
|
||||
if (model.failMedia) return new Response(null, { status: 404 })
|
||||
return new Response(Buffer.from('RIFFtest-WAVE'), {
|
||||
headers: { 'Content-Type': 'audio/wav' },
|
||||
@@ -92,7 +100,7 @@ async function fixture(t: TestContext, secure = true) {
|
||||
}
|
||||
if (path === '/api/screens/music/advance') {
|
||||
model.advanceCalls++
|
||||
return json({ state: objectMusic(2, 1) })
|
||||
return json({ state: objectMusic(3, 1) })
|
||||
}
|
||||
if (path === '/api/screens/results') {
|
||||
if (model.resultError) return json({}, 400)
|
||||
@@ -132,7 +140,7 @@ async function fixture(t: TestContext, secure = true) {
|
||||
name: model.title,
|
||||
scenario: {
|
||||
story: {
|
||||
introduction: { audio: 'https://media.test/intro.wav' },
|
||||
introduction: { audio: model.introURL },
|
||||
conclusion: { video: '' },
|
||||
},
|
||||
},
|
||||
@@ -175,7 +183,20 @@ async function fixture(t: TestContext, secure = true) {
|
||||
model.remote.stateVersion = String(BigInt(String(model.remote.stateVersion)) + 1n)
|
||||
return json({})
|
||||
}
|
||||
if (path === '/api/games/7/music')
|
||||
if (path === '/api/games/7/music') {
|
||||
if (init?.method === 'POST') {
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>
|
||||
const state = objectMusic(20, Number(body.trackIndex ?? 0))
|
||||
state.playback = Number(body.playback ?? 1)
|
||||
model.remote.music = {
|
||||
...state,
|
||||
volume: body.volume,
|
||||
loop: body.loop,
|
||||
positionMs: body.positionMs,
|
||||
}
|
||||
return json({ state: model.remote.music })
|
||||
}
|
||||
if (model.musicOverride) return json(model.musicOverride)
|
||||
return json({
|
||||
gamePlaylistId: 1,
|
||||
playlists: [
|
||||
@@ -191,6 +212,19 @@ async function fixture(t: TestContext, secure = true) {
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
if (path === '/api/games/7/screens/mode' || path === '/api/games/7/screens/timer') {
|
||||
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||
model.remote = {
|
||||
...model.remote,
|
||||
mode: body.mode ?? 7,
|
||||
command: 0,
|
||||
timerLabel: body.label,
|
||||
timerDurationSeconds: body.durationSeconds,
|
||||
stateVersion: String(BigInt(String(model.remote.stateVersion)) + 1n),
|
||||
}
|
||||
return json({})
|
||||
}
|
||||
if (path.endsWith('/bind') || path.endsWith('/sound')) return json({})
|
||||
throw new Error(`Unexpected request ${path}`)
|
||||
}
|
||||
@@ -203,6 +237,7 @@ async function fixture(t: TestContext, secure = true) {
|
||||
const controller = new Controller(() => {})
|
||||
const online = new Online(join(directory, 'connection'), store, controller, secrets, fetchAPI)
|
||||
await online.initialize()
|
||||
shutdown = () => online.close()
|
||||
const login = () =>
|
||||
online.act({
|
||||
action: 'login',
|
||||
@@ -234,7 +269,7 @@ function objectMusic(version: number, index = 0) {
|
||||
stateVersion: String(version),
|
||||
playlist: { ID: 1, tracks: [{ ID: 1 }, { ID: 2 }] },
|
||||
trackIndex: index,
|
||||
playback: 'MUSIC_PLAYBACK_PLAYING',
|
||||
playback: 2,
|
||||
volume: 40,
|
||||
loop: true,
|
||||
}
|
||||
@@ -324,13 +359,13 @@ test('обновление запрещено при показе, ошибка
|
||||
await assert.rejects(f.online.act({ action: 'remove', key: old.key }), /другой пакет/)
|
||||
})
|
||||
|
||||
test('пропускает исходную и накопленную версии; повторная версия не отменяет локальную паузу', async (t) => {
|
||||
test('принимает исходную и накопленную версии сервера; одинаковая версия не повторяется', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const c = f.controller
|
||||
c.output(true)
|
||||
await f.online.poll()
|
||||
assert.equal(c.state.channels.main.desired, 'stopped')
|
||||
assert.equal(c.state.channels.main.desired, 'playing')
|
||||
f.model.remote.stateVersion = '9007199254740994'
|
||||
await f.online.poll()
|
||||
assert.equal(c.state.channels.main.desired, 'playing')
|
||||
@@ -342,14 +377,14 @@ test('пропускает исходную и накопленную верси
|
||||
f.model.offline = false
|
||||
f.model.remote.stateVersion = '9007199254740995'
|
||||
await f.online.poll()
|
||||
assert.equal(c.state.channels.main.desired, 'paused')
|
||||
assert.equal(c.state.channels.main.desired, 'playing')
|
||||
f.model.remote.stateVersion = '9007199254740996'
|
||||
await f.online.poll()
|
||||
assert.equal(c.state.channels.main.desired, 'playing')
|
||||
c.output(false)
|
||||
c.output(true)
|
||||
await f.online.poll()
|
||||
assert.equal(c.state.channels.main.desired, 'stopped')
|
||||
assert.equal(c.state.channels.main.desired, 'playing')
|
||||
})
|
||||
|
||||
test('недействительная сессия и смена серверной привязки сохраняют локальный показ', async (t) => {
|
||||
@@ -369,7 +404,7 @@ test('недействительная сессия и смена серверн
|
||||
assert.equal(f.controller.state.channels.main.desired, 'playing')
|
||||
})
|
||||
|
||||
test('не применяет ответ от предыдущего окна и не допускает параллельный опрос', async (t) => {
|
||||
test('обновляет серверное состояние после открытия окна и не допускает параллельный опрос', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
@@ -385,7 +420,7 @@ test('не применяет ответ от предыдущего окна и
|
||||
f.controller.output(true)
|
||||
release(Response.json({ state: { ...f.model.remote, stateVersion: '9007199254740999' } }))
|
||||
await pending
|
||||
assert.equal(f.controller.state.channels.main.desired, 'stopped')
|
||||
assert.equal(f.controller.state.channels.main.desired, 'playing')
|
||||
})
|
||||
|
||||
test('результаты сохраняются при ошибке API и не содержат секретных полей', async (t) => {
|
||||
@@ -454,11 +489,11 @@ test('музыкальная громкость не возвращает ста
|
||||
next,
|
||||
parseRemote({ music: { ...objectMusic(4), playlist: { ID: 99, tracks: [{ ID: 1 }] } } }),
|
||||
),
|
||||
/Музыка изменилась/,
|
||||
/Музыка ожидает подготовки/,
|
||||
)
|
||||
})
|
||||
|
||||
test('перестановка прежних треков останавливает музыку до повторной подготовки', async (t) => {
|
||||
test('перестановка треков сохраняет старую музыку до готовности нового каталога', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
@@ -467,8 +502,8 @@ test('перестановка прежних треков останавлив
|
||||
const next = parseRemote({
|
||||
music: { ...objectMusic(2), playlist: { ID: 1, tracks: [{ ID: 2 }, { ID: 1 }] } },
|
||||
})
|
||||
assert.throws(() => applyRemote(f.controller, previous, next), /Музыка изменилась/)
|
||||
assert.equal(f.controller.state.channels.music.desired, 'stopped')
|
||||
assert.throws(() => applyRemote(f.controller, previous, next), /Музыка ожидает подготовки/)
|
||||
assert.equal(f.controller.state.channels.music.desired, 'playing')
|
||||
assert.deepEqual(
|
||||
f.controller.state.catalog.playlists[0]!.tracks.map((track) => track.id),
|
||||
[1, 2],
|
||||
@@ -496,7 +531,7 @@ test('назначение звука восстанавливается пос
|
||||
await f.online.poll()
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.muted, true)
|
||||
assert.equal(f.controller.state.channels.main.desired, 'stopped')
|
||||
assert.equal(f.controller.state.channels.main.desired, 'playing')
|
||||
|
||||
await f.online.act({ action: 'sound' })
|
||||
f.model.remote.soundEnabled = true
|
||||
@@ -507,7 +542,7 @@ test('назначение звука восстанавливается пос
|
||||
f.model.remote.soundEnabled = false
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.muted, true)
|
||||
assert.equal(f.controller.state.channels.main.desired, 'stopped')
|
||||
assert.equal(f.controller.state.channels.main.desired, 'playing')
|
||||
})
|
||||
|
||||
test('истечение JWT не обрывает показ и оставляет доступным повторный вход', async (t) => {
|
||||
@@ -601,11 +636,11 @@ test('окончание серверного трека обновляет се
|
||||
}
|
||||
f.controller.report(report)
|
||||
f.controller.report(report)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await eventually(() => f.controller.state.channels.music.trackIndex === 1, 'следующий трек готов')
|
||||
assert.equal(f.model.advanceCalls, 1)
|
||||
assert.equal(f.controller.state.channels.music.trackIndex, 1)
|
||||
send(f.controller, { action: 'seek', channel: 'music', position: 2 })
|
||||
f.model.remote.music = objectMusic(2, 1)
|
||||
f.model.remote.music = objectMusic(3, 1)
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.channels.music.position, 2)
|
||||
})
|
||||
@@ -840,6 +875,7 @@ test('план: изменения с сайта загружаются при
|
||||
})
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.outputOpen, false)
|
||||
await f.online.refreshPlan()
|
||||
assert.deepEqual(f.controller.state.catalog.steps, f.model.plan.steps)
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
@@ -945,3 +981,211 @@ test('план: первое подтверждение собственной
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.muted, true)
|
||||
})
|
||||
|
||||
async function eventually(check: () => boolean, label: string) {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
if (check()) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
}
|
||||
assert.fail(label)
|
||||
}
|
||||
|
||||
test('постоянная связь: подключение без пакета и подготовка при закрытом показе, сервер запускается после открытия', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.login()
|
||||
assert.equal(f.store.list().length, 0)
|
||||
await f.online.act({ action: 'connect', gameId: 7 })
|
||||
assert.equal(f.controller.state.catalog.name, 'Дело')
|
||||
const session = f.controller.state.session
|
||||
await f.online.poll()
|
||||
await eventually(
|
||||
() => !f.online.state.preparation.active && f.online.state.preparation.total > 0,
|
||||
'фоновая подготовка завершена',
|
||||
)
|
||||
assert.equal(f.online.state.connection, 'connected')
|
||||
assert.equal(f.controller.state.session, session)
|
||||
assert.equal(f.controller.state.outputOpen, false)
|
||||
const downloads = f.requests.filter((q) => q.path.endsWith('.wav')).length
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.scene.mode, 2)
|
||||
assert.equal(f.controller.state.channels.main.desired, 'playing')
|
||||
assert.match(f.controller.state.channels.main.source!.url, /^screen-app:/)
|
||||
const operation = f.controller.state.channels.main.operation
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.channels.main.operation, operation)
|
||||
assert.equal(f.requests.filter((q) => q.path.endsWith('.wav')).length, downloads)
|
||||
})
|
||||
|
||||
test('дозагрузка нового плейлиста сохраняет музыку; смена текста не ждёт файл; новый трек запускается только локально', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
f.model.remote.music = objectMusic(1)
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
const old = f.controller.state.channels.music.source!.url
|
||||
let release!: (response: Response) => void
|
||||
let started = false
|
||||
f.model.mediaHandler = async (url) => {
|
||||
if (url.endsWith('new.wav')) {
|
||||
started = true
|
||||
return new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
}
|
||||
return new Response(Buffer.from('RIFFtest-WAVE'), { headers: { 'Content-Type': 'audio/wav' } })
|
||||
}
|
||||
const playlist = {
|
||||
ID: 99,
|
||||
name: 'Новый плейлист',
|
||||
tracks: [{ ID: 9, name: 'Новый', filename: 'https://media.test/new.wav' }],
|
||||
}
|
||||
const music = { ...objectMusic(2), playlist, trackIndex: 0 }
|
||||
f.model.remote.music = music
|
||||
f.model.musicOverride = { gamePlaylistId: 99, state: music, playlists: [playlist] }
|
||||
const pending = f.online.poll()
|
||||
await eventually(() => started, 'новый файл запрошен')
|
||||
assert.equal(f.controller.state.channels.music.source!.url, old)
|
||||
assert.equal(f.controller.state.channels.music.desired, 'playing')
|
||||
const channel = f.controller.state.channels.music
|
||||
const ended = f.online.musicEnded({
|
||||
session: f.controller.state.session,
|
||||
channel: 'music',
|
||||
operation: channel.operation,
|
||||
status: 'ended',
|
||||
position: 4,
|
||||
duration: 4,
|
||||
})
|
||||
await new Promise((done) => setImmediate(done))
|
||||
assert.equal(f.model.advanceCalls, 0, 'старый трек не переключает новый серверный трек')
|
||||
f.model.remote = {
|
||||
...f.model.remote,
|
||||
mode: 8,
|
||||
text: 'Новая сцена',
|
||||
command: 0,
|
||||
stateVersion: String(BigInt(String(f.model.remote.stateVersion)) + 1n),
|
||||
}
|
||||
const newer = f.online.poll()
|
||||
await eventually(() => f.controller.state.scene.text === 'Новая сцена', 'текст не ждёт музыку')
|
||||
release(new Response(Buffer.from('RIFFtest-WAVE'), { headers: { 'Content-Type': 'audio/wav' } }))
|
||||
await Promise.all([pending, newer, ended])
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.channels.music.playlistId, 99)
|
||||
assert.equal(f.controller.state.channels.music.desired, 'playing')
|
||||
assert.match(f.controller.state.channels.music.source!.url, /^screen-app:/)
|
||||
assert.notEqual(f.controller.state.channels.music.source!.url, old)
|
||||
})
|
||||
|
||||
test('устаревшая дозагрузка не применяет прежнюю серверную сцену', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
const original = f.controller.state.channels.main.source!.url
|
||||
let release!: (response: Response) => void
|
||||
let started = false
|
||||
f.model.introURL = 'https://media.test/new-intro.wav'
|
||||
f.model.remote = {
|
||||
...f.model.remote,
|
||||
introAudio: f.model.introURL,
|
||||
stateVersion: String(BigInt(String(f.model.remote.stateVersion)) + 1n),
|
||||
}
|
||||
f.model.mediaHandler = async () => {
|
||||
started = true
|
||||
return new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
}
|
||||
const first = f.online.poll()
|
||||
await eventually(() => started, 'загрузка нового вступления')
|
||||
assert.equal(f.controller.state.channels.main.source!.url, original)
|
||||
f.model.remote = {
|
||||
...f.model.remote,
|
||||
mode: 8,
|
||||
text: 'Актуальный текст',
|
||||
command: 0,
|
||||
stateVersion: String(BigInt(String(f.model.remote.stateVersion)) + 1n),
|
||||
}
|
||||
const second = f.online.poll()
|
||||
release(new Response(Buffer.from('RIFFtest-WAVE'), { headers: { 'Content-Type': 'audio/wav' } }))
|
||||
await Promise.all([first, second])
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.scene.mode, 8)
|
||||
assert.equal(f.controller.state.scene.text, 'Актуальный текст')
|
||||
assert.equal(f.controller.state.channels.main.desired, 'stopped')
|
||||
})
|
||||
|
||||
test('локальные режим, музыка и таймер публикуются на сервер без повторного сброса', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
await f.online.planCommand({ action: 'mode', mode: 3 })
|
||||
assert.equal(f.model.remote.mode, 3)
|
||||
await f.online.planCommand({ action: 'timer-set', label: 'Отдых', durationSeconds: 60 })
|
||||
assert.equal(f.model.remote.timerDurationSeconds, 60)
|
||||
await f.online.planCommand({ action: 'play', channel: 'music' })
|
||||
const music = f.model.remote.music as { playback: number }
|
||||
assert.equal(music.playback, 2)
|
||||
const operation = f.controller.state.channels.music.operation
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.channels.music.operation, operation)
|
||||
})
|
||||
|
||||
test('конкурентная серверная музыка во время подтверждения локальной команды применяется', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
f.model.stateResponse = async () => {
|
||||
const music = { ...objectMusic(21, 1), volume: 70 }
|
||||
f.model.remote.music = music
|
||||
return Response.json({ state: f.model.remote })
|
||||
}
|
||||
await f.online.planCommand({ action: 'play', channel: 'music' })
|
||||
assert.equal(f.controller.state.channels.music.trackIndex, 1)
|
||||
assert.equal(f.controller.state.channels.music.volume, 70)
|
||||
})
|
||||
|
||||
test('подтверждение музыкальной команды учитывает замену файла у прежнего трека', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
f.model.remote.music = objectMusic(1)
|
||||
await f.online.poll()
|
||||
const old = f.controller.state.channels.music.source!.url
|
||||
f.model.stateResponse = async () => {
|
||||
const playlist = {
|
||||
ID: 1,
|
||||
name: 'Музыка',
|
||||
tracks: [{ ID: 1, name: 'Замена', filename: 'https://media.test/replaced.wav' }],
|
||||
}
|
||||
const music = { ...objectMusic(21), playlist }
|
||||
f.model.remote.music = music
|
||||
f.model.musicOverride = { gamePlaylistId: 1, state: music, playlists: [playlist] }
|
||||
return Response.json({ state: f.model.remote })
|
||||
}
|
||||
await f.online.planCommand({ action: 'play', channel: 'music' })
|
||||
assert.notEqual(f.controller.state.channels.music.source!.url, old)
|
||||
assert.equal(f.controller.state.channels.music.desired, 'playing')
|
||||
assert.match(f.controller.state.channels.music.source!.url, /^screen-app:/)
|
||||
})
|
||||
|
||||
test('публикация текста после смены игры не отправляет устаревшую мутацию', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
let release!: (response: Response) => void
|
||||
f.model.planResponse = () =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const pending = f.online.planCommand({ action: 'text', text: 'Старый текст' })
|
||||
await new Promise((done) => setImmediate(done))
|
||||
await f.online.select(null)
|
||||
release(Response.json({ plan: { steps: [] } }))
|
||||
await pending
|
||||
assert.equal(f.model.planSaves, 0)
|
||||
assert.equal(f.model.planTransitions, 0)
|
||||
assert.equal(f.controller.state.catalog.key, null)
|
||||
assert.equal(f.controller.state.scene.stepIndex, null)
|
||||
})
|
||||
|
||||
+589
-115
@@ -10,7 +10,7 @@ import type {
|
||||
} from '../src/types/screen.js'
|
||||
import type { Controller } from './controller.js'
|
||||
import { PackageStore, packageKey } from './package-store.js'
|
||||
import { prepareScreenPackage } from './screen-package.js'
|
||||
import { type ScreenPackage, prepareScreenPackage, resolveMediaURL } from './screen-package.js'
|
||||
import { parseResults } from './results.js'
|
||||
import { parsePlan } from './screen-plan.js'
|
||||
import {
|
||||
@@ -86,6 +86,7 @@ export class Online {
|
||||
error: '',
|
||||
notice: '',
|
||||
progress: null,
|
||||
preparation: { active: false, completed: 0, total: 0, downloadedBytes: 0, error: '' },
|
||||
packagesRevision: 0,
|
||||
}
|
||||
private credentials: Credentials | null = null
|
||||
@@ -96,6 +97,292 @@ export class Online {
|
||||
private cancellation: AbortController | null = null
|
||||
private downloadKey: string | null = null
|
||||
private previous: RemoteState | null = null
|
||||
private received: RemoteState | null = null
|
||||
private mainApplied: RemoteState | null = null
|
||||
private musicApplied: RemoteState | null = null
|
||||
private materialAbort = new AbortController()
|
||||
private materialRefresh: Promise<void> | null = null
|
||||
private materialWork: Promise<void> | null = null
|
||||
private lastMaterials = -Infinity
|
||||
private mainWork: Promise<void> | null = null
|
||||
private musicWork: Promise<void> | null = null
|
||||
private mainSession = ''
|
||||
private musicSession = ''
|
||||
private receivedSignature = ''
|
||||
private localCommandVersion = 0
|
||||
private materialSignature = ''
|
||||
private async description(gameId: number): Promise<ScreenPackage> {
|
||||
if (!this.credentials) throw new ApiError(401)
|
||||
return prepareScreenPackage(
|
||||
{
|
||||
server: this.credentials.server,
|
||||
gameId,
|
||||
token: this.credentials.access,
|
||||
signal: this.materialAbort.signal,
|
||||
},
|
||||
(url, init) => {
|
||||
const parsed = new URL(String(url))
|
||||
if (parsed.origin !== this.credentials?.server) throw new Error('Недопустимый сервер API')
|
||||
return this.authorized(parsed.pathname, init)
|
||||
},
|
||||
)
|
||||
}
|
||||
private async refreshMaterials() {
|
||||
if (this.materialRefresh) return this.materialRefresh
|
||||
const pkg = this.selectedGame()
|
||||
if (!pkg || !this.planStatus().editable) return
|
||||
const generation = this.generation
|
||||
const signature = this.receivedSignature
|
||||
this.lastMaterials = Date.now()
|
||||
const work = (async () => {
|
||||
try {
|
||||
const description = await this.description(pkg.package.game.id)
|
||||
if (generation !== this.generation) return
|
||||
const current = this.selectedGame()
|
||||
if (
|
||||
current?.package.metadataVersion !== description.metadataVersion ||
|
||||
!this.packages.description(pkg.key)
|
||||
) {
|
||||
const local = await this.packages.remember(description)
|
||||
if (generation !== this.generation) return
|
||||
this.controller.selectPackage(local, true)
|
||||
this.state.packagesRevision++
|
||||
}
|
||||
this.materialSignature = signature
|
||||
this.prefetch()
|
||||
} catch (error) {
|
||||
if (generation === this.generation)
|
||||
this.state.preparation.error =
|
||||
error instanceof Error ? error.message : 'Описание игры недоступно'
|
||||
}
|
||||
})()
|
||||
this.materialRefresh = work
|
||||
try {
|
||||
await work
|
||||
} finally {
|
||||
if (this.materialRefresh === work) this.materialRefresh = null
|
||||
}
|
||||
}
|
||||
private wanted(next: RemoteState, music: boolean): string[] {
|
||||
const pkg = this.selectedGame()?.package
|
||||
if (!pkg) return []
|
||||
if (music) {
|
||||
const m = next.music
|
||||
const track = pkg.playlists
|
||||
.find((p) => p.id === m?.playlistId)
|
||||
?.tracks.find((t) => t.id === m?.trackId)
|
||||
if (m?.playlistId && m.trackId && !track)
|
||||
throw new Error('Описание выбранного трека ещё не получено')
|
||||
const expected = m?.filenames[m.trackIds.indexOf(m.trackId ?? 0)]
|
||||
if (
|
||||
track &&
|
||||
expected &&
|
||||
pkg.assets.find((a) => a.id === track.assetId)?.url !==
|
||||
resolveMediaURL(pkg.server, expected)
|
||||
)
|
||||
throw new Error('Ожидание актуального описания трека')
|
||||
return track ? [track.assetId] : []
|
||||
}
|
||||
for (const [id, expected] of [
|
||||
[
|
||||
next.mode === 1 ? pkg.media.cover : pkg.media.background,
|
||||
next.mode === 1 ? next.resources.cover : next.resources.background,
|
||||
],
|
||||
[
|
||||
next.mode === 2 ? pkg.media.intro : next.mode === 4 ? pkg.media.conclusion : null,
|
||||
next.mode === 2 ? next.resources.intro : next.mode === 4 ? next.resources.conclusion : '',
|
||||
],
|
||||
]) {
|
||||
if (
|
||||
expected &&
|
||||
pkg.assets.find((a) => a.id === id)?.url !== resolveMediaURL(pkg.server, expected)
|
||||
)
|
||||
throw new Error('Ожидание актуального описания экрана')
|
||||
}
|
||||
return [
|
||||
...new Set(
|
||||
[
|
||||
next.mode === 1 ? pkg.media.cover : pkg.media.background,
|
||||
next.mode === 2 ? pkg.media.intro : next.mode === 4 ? pkg.media.conclusion : null,
|
||||
].filter((id): id is string => !!id),
|
||||
),
|
||||
]
|
||||
}
|
||||
private prefetch() {
|
||||
if (this.materialWork) return
|
||||
const pkg = this.selectedGame()
|
||||
if (!pkg) return
|
||||
const generation = this.generation
|
||||
const signal = this.materialAbort.signal
|
||||
const description = pkg.package
|
||||
let wanted: string[] = []
|
||||
if (this.received) {
|
||||
try {
|
||||
wanted = [...this.wanted(this.received, false), ...this.wanted(this.received, true)]
|
||||
} catch {
|
||||
/* Новый плейлист будет включён после обновления описания. */
|
||||
}
|
||||
}
|
||||
const ids = [...new Set([...wanted, ...description.assets.map((a) => a.id)])]
|
||||
this.state.preparation = {
|
||||
active: true,
|
||||
completed: 0,
|
||||
total: ids.length,
|
||||
downloadedBytes: 0,
|
||||
error: '',
|
||||
}
|
||||
const work = (async () => {
|
||||
for (const id of ids) {
|
||||
if (generation !== this.generation || signal.aborted) return
|
||||
try {
|
||||
await this.packages.ensureAsset(description, id, signal, (bytes) => {
|
||||
if (generation === this.generation) this.state.preparation.downloadedBytes = bytes
|
||||
})
|
||||
if (generation === this.generation) {
|
||||
this.controller.markAssetReady(id)
|
||||
this.state.preparation.completed++
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== this.generation || signal.aborted) return
|
||||
this.state.preparation.error = error instanceof Error ? error.message : 'Файл недоступен'
|
||||
}
|
||||
}
|
||||
})()
|
||||
this.materialWork = work
|
||||
void work.finally(() => {
|
||||
if (this.materialWork === work) {
|
||||
this.materialWork = null
|
||||
if (generation === this.generation) this.state.preparation.active = false
|
||||
}
|
||||
})
|
||||
}
|
||||
private mainSourceMatches(next: RemoteState) {
|
||||
const kind = next.mode === 2 ? 'audio' : next.mode === 4 ? 'video' : null
|
||||
if (!kind) return true
|
||||
return (
|
||||
this.controller.state.channels.main.source?.url ===
|
||||
this.controller.state.catalog.media.find((source) => source.kind === kind)?.url
|
||||
)
|
||||
}
|
||||
private musicSourceMatches(next: RemoteState) {
|
||||
try {
|
||||
const id = this.wanted(next, true)[0]
|
||||
const channel = this.controller.state.channels.music
|
||||
return (
|
||||
next.music?.playlistId === channel.playlistId &&
|
||||
(id ? `screen-app://app/packages/${this.selectedGame()?.key}/${id}` : undefined) ===
|
||||
channel.source?.url
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
private async applyReceived(music: boolean, duringCommand = false) {
|
||||
let next = this.received
|
||||
const pkg = this.selectedGame()
|
||||
if (!next || !pkg || !this.controller.state.outputOpen || (this.planBusy && !duringCommand))
|
||||
return
|
||||
const session = this.controller.state.session
|
||||
const generation = this.generation
|
||||
const version = this.planVersion
|
||||
const old = music ? this.musicApplied : this.mainApplied
|
||||
const sameSession = (music ? this.musicSession : this.mainSession) === session
|
||||
if (
|
||||
old &&
|
||||
sameSession &&
|
||||
(music
|
||||
? JSON.stringify(old.music, (_key, value) =>
|
||||
typeof value === 'bigint' ? String(value) : value,
|
||||
) ===
|
||||
JSON.stringify(next.music, (_key, value) =>
|
||||
typeof value === 'bigint' ? String(value) : value,
|
||||
)
|
||||
: old.version === next.version &&
|
||||
this.mainSourceMatches(next) &&
|
||||
JSON.stringify(old.resources) === JSON.stringify(next.resources))
|
||||
)
|
||||
return
|
||||
try {
|
||||
if (this.materialSignature !== this.receivedSignature) await this.refreshMaterials()
|
||||
const description = this.selectedGame()?.package
|
||||
if (!description || generation !== this.generation) return
|
||||
if (!music && next.command === 0) await this.refreshPlan()
|
||||
const ids = this.wanted(next, music)
|
||||
await Promise.all(
|
||||
ids.map((id) => this.packages.ensureAsset(description, id, this.materialAbort.signal)),
|
||||
)
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
session !== this.controller.state.session ||
|
||||
version !== this.planVersion ||
|
||||
!this.controller.state.outputOpen
|
||||
)
|
||||
return
|
||||
for (const id of ids) this.controller.markAssetReady(id)
|
||||
const latest = this.received
|
||||
if (
|
||||
!latest ||
|
||||
(music
|
||||
? latest.music?.version !== next.music?.version ||
|
||||
JSON.stringify(latest.music?.filenames) !== JSON.stringify(next.music?.filenames)
|
||||
: latest.version !== next.version ||
|
||||
JSON.stringify(latest.resources) !== JSON.stringify(next.resources))
|
||||
)
|
||||
return
|
||||
next = latest
|
||||
const base =
|
||||
old && sameSession
|
||||
? { ...old }
|
||||
: {
|
||||
...next,
|
||||
version: -1n,
|
||||
mode: this.controller.state.scene.mode,
|
||||
music: null,
|
||||
}
|
||||
if (
|
||||
music &&
|
||||
old &&
|
||||
JSON.stringify(old.music?.filenames) !== JSON.stringify(next.music?.filenames)
|
||||
)
|
||||
base.music = null
|
||||
if (!music && old && JSON.stringify(old.resources) !== JSON.stringify(next.resources))
|
||||
base.version = -1n
|
||||
if (music) {
|
||||
applyRemote(this.controller, { ...base, version: next.version, sound: next.sound }, next)
|
||||
this.musicApplied = next
|
||||
this.musicSession = session
|
||||
} else {
|
||||
const key = this.controller.state.catalog.key
|
||||
const url = (id: string | null) => (id ? `screen-app://app/packages/${key}/${id}` : null)
|
||||
this.controller.setImages(url(description.media.cover), url(description.media.background))
|
||||
applyRemote(this.controller, { ...base, music: next.music }, { ...next, music: next.music })
|
||||
this.mainApplied = next
|
||||
this.mainSession = session
|
||||
if (next.command === 0) this.controller.markStep(this.plan.currentStep)
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation === this.generation && !this.materialAbort.signal.aborted)
|
||||
this.state.preparation.error =
|
||||
error instanceof Error ? error.message : 'Материал не подготовлен'
|
||||
}
|
||||
}
|
||||
private synchronize() {
|
||||
if (!this.mainWork) {
|
||||
const work = this.applyReceived(false)
|
||||
this.mainWork = work
|
||||
void work.finally(() => {
|
||||
if (this.mainWork === work) this.mainWork = null
|
||||
})
|
||||
}
|
||||
if (!this.musicWork) {
|
||||
const work = this.applyReceived(true)
|
||||
this.musicWork = work
|
||||
void work.finally(() => {
|
||||
if (this.musicWork === work) this.musicWork = null
|
||||
})
|
||||
}
|
||||
return Promise.all([this.mainWork, this.musicWork])
|
||||
}
|
||||
private pollBusy = false
|
||||
private pollSession = ''
|
||||
private lastSound: boolean | null = null
|
||||
@@ -208,11 +495,61 @@ export class Online {
|
||||
this.planBusy--
|
||||
}
|
||||
}
|
||||
private localAssets(intent: Intent, pkg: ScreenPackage): string[] {
|
||||
if ('channel' in intent && intent.channel === 'music') {
|
||||
const channel = this.controller.state.channels.music
|
||||
const playlist = pkg.playlists.find(
|
||||
(p) => p.id === (intent.action === 'playlist' ? intent.playlistId : channel.playlistId),
|
||||
)
|
||||
let index =
|
||||
intent.action === 'track'
|
||||
? intent.trackIndex
|
||||
: intent.action === 'playlist'
|
||||
? 0
|
||||
: channel.trackIndex
|
||||
if (intent.action === 'next' && playlist)
|
||||
index = (index + 1) % Math.max(1, playlist.tracks.length)
|
||||
const track = playlist?.tracks[index]
|
||||
return track ? [track.assetId] : []
|
||||
}
|
||||
let mode = this.controller.state.scene.mode
|
||||
if (intent.action === 'mode') mode = intent.mode
|
||||
if (intent.action === 'step')
|
||||
mode = this.controller.state.catalog.steps[intent.index]?.mode ?? mode
|
||||
if (intent.action === 'text') mode = 8
|
||||
if (intent.action === 'timer-set') mode = 7
|
||||
if (intent.action === 'select') mode = intent.sourceId === 'intro' ? 2 : 4
|
||||
return [
|
||||
mode === 1 ? pkg.media.cover : pkg.media.background,
|
||||
mode === 2 ? pkg.media.intro : mode === 4 ? pkg.media.conclusion : null,
|
||||
].filter((id): id is string => !!id)
|
||||
}
|
||||
async planCommand(intent: Intent) {
|
||||
if (this.busy) throw new Error('Подождите завершения текущего действия')
|
||||
const pkg = this.selectedGame()
|
||||
// Локальная команда не ждёт сеть и никогда не откатывается при обрыве связи.
|
||||
const localVersion = ++this.localCommandVersion
|
||||
const localGeneration = this.generation
|
||||
const localSession = this.controller.state.session
|
||||
if (pkg) {
|
||||
const ids = this.localAssets(intent, pkg.package)
|
||||
const hasError =
|
||||
this.controller.state.scene.status === 'error' ||
|
||||
('channel' in intent && this.controller.state.channels[intent.channel].status === 'error')
|
||||
if (hasError || ids.some((id) => !this.packages.canRead(pkg.key, id))) {
|
||||
await Promise.all(
|
||||
ids.map((id) => this.packages.ensureAsset(pkg.package, id, this.materialAbort.signal)),
|
||||
)
|
||||
if (
|
||||
localVersion !== this.localCommandVersion ||
|
||||
localGeneration !== this.generation ||
|
||||
localSession !== this.controller.state.session
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Готовые локальные ресурсы включаются без ожидания ответа сервера.
|
||||
send(this.controller, intent)
|
||||
const localState = structuredClone(this.controller.state)
|
||||
const sequence = this.controller.state.sequence
|
||||
const version = ++this.planVersion
|
||||
if (!pkg || !this.planStatus().editable) {
|
||||
@@ -234,10 +571,12 @@ export class Online {
|
||||
// В сеть уходит только последняя команда; после обрыва ожидающие действия не повторяются.
|
||||
const wasAvailable = await previousCommand
|
||||
isAvailable = !hasPendingCommand || wasAvailable
|
||||
if (generation !== this.generation || session !== this.controller.state.session) return
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
version !== this.planVersion ||
|
||||
session !== this.controller.state.session
|
||||
version !== this.planVersion &&
|
||||
['step', 'mode', 'text', 'select', 'timer-set', 'playlist', 'track', 'next'].includes(
|
||||
intent.action,
|
||||
)
|
||||
)
|
||||
return
|
||||
if (hasPendingCommand && !wasAvailable) {
|
||||
@@ -268,6 +607,76 @@ export class Online {
|
||||
) {
|
||||
if (confirmed.steps[intent.index]) send(this.controller, intent)
|
||||
}
|
||||
} else if ('channel' in intent && intent.channel === 'music') {
|
||||
const c = localState.channels.music
|
||||
await this.api(
|
||||
`/api/games/${pkg.package.game.id}/music`,
|
||||
{
|
||||
playlistID: c.playlistId ?? 0,
|
||||
trackIndex: c.trackIndex,
|
||||
positionMs: Math.max(0, Math.round(c.position * 1000)),
|
||||
volume: Math.round(c.volume),
|
||||
loop: c.loop,
|
||||
playback: c.desired === 'playing' ? 2 : c.desired === 'paused' ? 3 : 1,
|
||||
},
|
||||
true,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
} else if (intent.action === 'mode' || intent.action === 'select') {
|
||||
await this.api(
|
||||
`/api/games/${pkg.package.game.id}/screens/mode`,
|
||||
{ mode: localState.scene.mode },
|
||||
true,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
} else if (intent.action === 'text') {
|
||||
const path = `/api/games/${pkg.package.game.id}/screen-plan`
|
||||
const current = parsePlan((await this.api(path)).plan)
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
session !== this.controller.state.session ||
|
||||
version !== this.planVersion
|
||||
)
|
||||
return
|
||||
let index = current.steps.findIndex((step) => step.mode === 8 && step.text === intent.text)
|
||||
if (index < 0) {
|
||||
index = current.steps.length
|
||||
current.steps.push({
|
||||
mode: 8,
|
||||
text: intent.text,
|
||||
timerLabel: '',
|
||||
timerDurationSeconds: 0,
|
||||
})
|
||||
await this.json(
|
||||
await this.authorized(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(current),
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
session !== this.controller.state.session ||
|
||||
version !== this.planVersion
|
||||
)
|
||||
return
|
||||
const result = await this.api(`${path}/step`, { currentStep: index })
|
||||
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
session !== this.controller.state.session ||
|
||||
version !== this.planVersion
|
||||
)
|
||||
return
|
||||
this.controller.markStep(index)
|
||||
} else if (intent.action === 'timer-set') {
|
||||
await this.api(
|
||||
`/api/games/${pkg.package.game.id}/screens/timer`,
|
||||
{ label: intent.label, durationSeconds: intent.durationSeconds },
|
||||
true,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
} else {
|
||||
const index = this.controller.state.scene.stepIndex
|
||||
if (index !== null && index !== this.plan.currentStep) {
|
||||
@@ -331,8 +740,9 @@ export class Online {
|
||||
if (remote.gameId !== pkg.package.game.id) throw new Error('Привязка экрана изменилась')
|
||||
if (version !== this.planVersion) return
|
||||
const scene = this.controller.state.scene
|
||||
if (
|
||||
this.controller.state.sequence === sequence &&
|
||||
const isMusicCommand = 'channel' in intent && intent.channel === 'music'
|
||||
let needsMainSync =
|
||||
!isMusicCommand &&
|
||||
(remote.command !== expectedCommand ||
|
||||
remote.mode !== scene.mode ||
|
||||
(remote.mode === 8 && remote.text !== scene.text) ||
|
||||
@@ -340,31 +750,72 @@ export class Online {
|
||||
(remote.timer.label !== this.controller.state.timer.label ||
|
||||
remote.timer.durationMs !== this.controller.state.timer.durationMs)) ||
|
||||
(intent.action === 'seek' && remote.position !== intent.position) ||
|
||||
(intent.action === 'volume' && remote.volume !== intent.volume))
|
||||
) {
|
||||
applyRemote(this.controller, { ...remote, version: remote.version - 1n }, remote)
|
||||
if (remote.command === 0) {
|
||||
const result = await this.api(
|
||||
`/api/games/${pkg.package.game.id}/screen-plan`,
|
||||
undefined,
|
||||
true,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
version !== this.planVersion ||
|
||||
session !== this.controller.state.session
|
||||
)
|
||||
return
|
||||
this.controller.markStep(this.plan.currentStep)
|
||||
}
|
||||
(intent.action === 'volume' && remote.volume !== intent.volume) ||
|
||||
!this.mainSourceMatches(remote))
|
||||
try {
|
||||
this.wanted(remote, false)
|
||||
} catch {
|
||||
needsMainSync = !isMusicCommand
|
||||
}
|
||||
if (needsMainSync && remote.command === 0) {
|
||||
const result = await this.api(
|
||||
`/api/games/${pkg.package.game.id}/screen-plan`,
|
||||
undefined,
|
||||
true,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
version !== this.planVersion ||
|
||||
session !== this.controller.state.session
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!this.previous || this.previous.sound !== remote.sound)
|
||||
send(this.controller, { action: 'mute', enabled: !remote.sound })
|
||||
this.lastSound = remote.sound
|
||||
this.state.soundEnabled = remote.sound
|
||||
this.previous = { ...remote, music: this.previous?.music ?? null }
|
||||
this.previous = remote
|
||||
this.received = remote
|
||||
this.receivedSignature = JSON.stringify([
|
||||
remote.resources,
|
||||
remote.music?.playlistId,
|
||||
remote.music?.trackIds,
|
||||
remote.music?.filenames,
|
||||
])
|
||||
if (!('channel' in intent && intent.channel === 'music')) {
|
||||
if (needsMainSync) {
|
||||
this.mainApplied = null
|
||||
await this.applyReceived(false, true)
|
||||
} else {
|
||||
this.mainApplied = remote
|
||||
this.mainSession = session
|
||||
}
|
||||
}
|
||||
if ('channel' in intent && intent.channel === 'music') {
|
||||
const c = localState.channels.music
|
||||
const expectedTrack =
|
||||
localState.catalog.playlists.find((p) => p.id === c.playlistId)?.tracks[c.trackIndex]
|
||||
?.id ?? null
|
||||
const m = remote.music
|
||||
if (
|
||||
m &&
|
||||
m.playlistId === c.playlistId &&
|
||||
this.musicSourceMatches(remote) &&
|
||||
m.trackId === expectedTrack &&
|
||||
m.volume === Math.round(c.volume) &&
|
||||
m.loop === c.loop &&
|
||||
m.playback === (c.desired === 'playing' ? 2 : c.desired === 'paused' ? 3 : 1) &&
|
||||
Math.abs(m.position - c.position) < 0.01
|
||||
) {
|
||||
this.musicApplied = remote
|
||||
this.musicSession = session
|
||||
} else {
|
||||
this.musicApplied = null
|
||||
await this.applyReceived(true, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
isAvailable = !(error instanceof NetworkError)
|
||||
@@ -390,7 +841,9 @@ export class Online {
|
||||
private fetchAPI: typeof fetch = fetch,
|
||||
) {
|
||||
controller.onMusicEnded = (report) => {
|
||||
if (!this.screen) return false
|
||||
void this.musicEnded(report)
|
||||
return true
|
||||
}
|
||||
}
|
||||
get blocksOutput() {
|
||||
@@ -484,7 +937,8 @@ export class Online {
|
||||
if (this.busy) throw new Error('Подождите завершения подготовки материалов')
|
||||
let pkg
|
||||
try {
|
||||
pkg = key === null ? null : await this.packages.verify(key)
|
||||
pkg =
|
||||
key === null ? null : (this.packages.description(key) ?? (await this.packages.verify(key)))
|
||||
} catch (error) {
|
||||
if (key === this.controller.state.catalog.key) {
|
||||
this.detach()
|
||||
@@ -521,6 +975,21 @@ export class Online {
|
||||
}
|
||||
}
|
||||
private detach() {
|
||||
this.materialAbort.abort()
|
||||
this.materialAbort = new AbortController()
|
||||
this.received = null
|
||||
this.mainApplied = null
|
||||
this.musicApplied = null
|
||||
this.receivedSignature = ''
|
||||
this.materialSignature = ''
|
||||
this.lastMaterials = -Infinity
|
||||
this.state.preparation = {
|
||||
active: false,
|
||||
completed: 0,
|
||||
total: 0,
|
||||
downloadedBytes: 0,
|
||||
error: '',
|
||||
}
|
||||
this.generation++
|
||||
this.planVersion++
|
||||
this.screen = null
|
||||
@@ -780,17 +1249,28 @@ export class Online {
|
||||
this.state.packagesRevision++
|
||||
return
|
||||
}
|
||||
case 'retry-materials':
|
||||
this.lastMaterials = -Infinity
|
||||
await this.refreshMaterials()
|
||||
return
|
||||
case 'connect': {
|
||||
const pkg = this.packages.list().find((p) => p.key === this.controller.state.catalog.key)
|
||||
if (!pkg || pkg.package.server !== this.state.server)
|
||||
throw new Error('Выберите готовую игру с этого сервера')
|
||||
if (!this.credentials) throw new ApiError(401)
|
||||
let pkg = this.selectedGame()
|
||||
const gameId = data.gameId == null ? pkg?.package.game.id : integer(data.gameId, 1)
|
||||
if (!gameId) throw new Error('Выберите игру на сервере')
|
||||
this.detach()
|
||||
if (!pkg || pkg.package.game.id !== gameId || pkg.package.server !== this.state.server) {
|
||||
pkg = await this.packages.remember(await this.description(gameId))
|
||||
this.controller.selectPackage(pkg)
|
||||
this.plan = { steps: this.controller.state.catalog.steps, currentStep: 0 }
|
||||
this.state.packagesRevision++
|
||||
await this.saveSelection()
|
||||
}
|
||||
const result = await this.api('/api/screens', {}, false)
|
||||
const s: ScreenSession = {
|
||||
id: String(integer(result.screenID, 1)),
|
||||
token: token(result.accessToken),
|
||||
gameId: pkg.package.game.id,
|
||||
gameId,
|
||||
}
|
||||
await this.api(`/api/screens/${s.id}/bind`, {
|
||||
screenID: s.id,
|
||||
@@ -896,23 +1376,12 @@ export class Online {
|
||||
}
|
||||
}
|
||||
async poll() {
|
||||
if (!this.busy && !this.planBusy && Date.now() - this.lastPlan >= 5000) await this.refreshPlan()
|
||||
if (this.planBusy) return
|
||||
const planVersion = this.planVersion
|
||||
const s = this.screen
|
||||
const session = this.controller.state.session
|
||||
if (!s || !this.controller.state.outputOpen) {
|
||||
this.previous = null
|
||||
if (s) this.state.connection = 'waiting'
|
||||
return
|
||||
}
|
||||
if (this.pollBusy) return
|
||||
if (this.pollSession !== session) {
|
||||
this.previous = null
|
||||
this.pollSession = session
|
||||
}
|
||||
this.pollBusy = true
|
||||
const s = this.screen
|
||||
if (!s) return
|
||||
const generation = this.generation
|
||||
const version = this.planVersion
|
||||
this.pollBusy = true
|
||||
try {
|
||||
const result = await this.api(
|
||||
'/api/screens/state',
|
||||
@@ -920,120 +1389,125 @@ export class Online {
|
||||
false,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
session !== this.controller.state.session ||
|
||||
!this.controller.state.outputOpen ||
|
||||
planVersion !== this.planVersion
|
||||
)
|
||||
return
|
||||
if (generation !== this.generation || version !== this.planVersion) return
|
||||
let next = parseRemote(result.state)
|
||||
if (next.gameId !== s.gameId) {
|
||||
this.detach()
|
||||
await this.persistSecrets()
|
||||
this.state.error =
|
||||
'Привязка экрана изменилась на сервере. Локальный показ сохранён; подключите управление заново'
|
||||
this.state.error = 'Привязка экрана изменилась на сервере. Подключите нужную игру заново'
|
||||
return
|
||||
}
|
||||
const previous = this.previous
|
||||
if (previous && next.version > previous.version && next.command === 0) {
|
||||
await this.refreshPlan()
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
session !== this.controller.state.session ||
|
||||
planVersion !== this.planVersion
|
||||
)
|
||||
return
|
||||
}
|
||||
if (previous && next.version < previous.version)
|
||||
next = { ...previous, sound: next.sound, music: next.music }
|
||||
if (previous?.music && next.music && next.music.version < previous.music.version)
|
||||
next = { ...next, music: previous.music }
|
||||
if (this.received && next.version < this.received.version)
|
||||
next = { ...this.received, music: next.music, sound: next.sound }
|
||||
if (this.received?.music && next.music && next.music.version < this.received.music.version)
|
||||
next = { ...next, music: this.received.music }
|
||||
// Стабильная ссылка позволяет завершить подготовку, пока приходят одинаковые ответы.
|
||||
const equal = (a: unknown, b: unknown) =>
|
||||
JSON.stringify(a, (_key, value) => (typeof value === 'bigint' ? String(value) : value)) ===
|
||||
JSON.stringify(b, (_key, value) => (typeof value === 'bigint' ? String(value) : value))
|
||||
if (!equal(this.received, next)) this.received = next
|
||||
this.previous = next
|
||||
this.receivedSignature = JSON.stringify([
|
||||
next.resources,
|
||||
next.music?.playlistId,
|
||||
next.music?.trackIds,
|
||||
next.music?.filenames,
|
||||
])
|
||||
this.state.soundEnabled = next.sound
|
||||
if (this.state.connection === 'offline') this.state.error = ''
|
||||
this.state.connection = 'connected'
|
||||
if (!previous && this.lastSound !== next.sound) {
|
||||
if (this.lastSound !== next.sound)
|
||||
send(this.controller, { action: 'mute', enabled: !next.sound })
|
||||
}
|
||||
this.lastSound = next.sound
|
||||
if (previous) {
|
||||
try {
|
||||
applyRemote(this.controller, previous, next)
|
||||
if (next.version > previous.version && next.command === 0)
|
||||
this.controller.markStep(this.plan.currentStep)
|
||||
} catch (error) {
|
||||
this.state.error = error instanceof Error ? error.message : 'Команда сервера не выполнена'
|
||||
}
|
||||
}
|
||||
if (this.controller.state.scene.mode === 6 && Date.now() - this.lastResults >= 5000)
|
||||
this.state.connection = 'connected'
|
||||
this.state.error = ''
|
||||
if (!this.busy && Date.now() - this.lastPlan >= 5000) void this.refreshPlan()
|
||||
if (
|
||||
this.materialSignature !== this.receivedSignature ||
|
||||
Date.now() - this.lastMaterials >= 5000
|
||||
)
|
||||
void this.refreshMaterials()
|
||||
if (next.mode === 6 && Date.now() - this.lastResults >= 5000)
|
||||
void this.refreshResults().catch((error) => {
|
||||
this.state.error =
|
||||
error instanceof Error ? error.message : 'Не удалось обновить результаты'
|
||||
if (generation === this.generation)
|
||||
this.state.error = error instanceof Error ? error.message : 'Результаты недоступны'
|
||||
})
|
||||
} catch (error) {
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
session !== this.controller.state.session ||
|
||||
planVersion !== this.planVersion
|
||||
)
|
||||
return
|
||||
this.previous = null
|
||||
if (generation !== this.generation) return
|
||||
this.mainApplied = null
|
||||
this.musicApplied = null
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
this.detach()
|
||||
this.state.connection = 'expired'
|
||||
await this.persistSecrets().catch(() => {})
|
||||
} else this.state.connection = 'offline'
|
||||
this.state.error =
|
||||
error instanceof Error ? error.message : 'Не удалось получить состояние экрана'
|
||||
this.state.error = error instanceof Error ? error.message : 'Сервер недоступен'
|
||||
} finally {
|
||||
this.pollBusy = false
|
||||
}
|
||||
if (generation === this.generation && this.state.connection === 'connected')
|
||||
await this.synchronize()
|
||||
}
|
||||
async musicEnded(report: Report) {
|
||||
const m = this.previous?.music
|
||||
const c = this.controller.state.channels.music
|
||||
if (
|
||||
!this.screen ||
|
||||
!m ||
|
||||
this.advanceBusy ||
|
||||
this.state.connection !== 'connected' ||
|
||||
report.channel !== 'music' ||
|
||||
report.status !== 'ended' ||
|
||||
report.session !== this.controller.state.session ||
|
||||
report.operation !== c.operation ||
|
||||
c.desired !== 'playing' ||
|
||||
c.playlistId !== m.playlistId ||
|
||||
c.source?.id !== `track-${m.playlistId}-${m.trackId}` ||
|
||||
m.playback !== 2
|
||||
c.desired !== 'playing'
|
||||
)
|
||||
return
|
||||
this.advanceBusy = true
|
||||
const generation = this.generation
|
||||
this.advanceBusy = true
|
||||
try {
|
||||
if (!this.screen || this.state.connection !== 'connected') throw new NetworkError('Нет связи')
|
||||
if (this.received && !this.musicSourceMatches(this.received)) {
|
||||
await this.synchronize()
|
||||
return
|
||||
}
|
||||
const result = await this.api(
|
||||
'/api/screens/music/advance',
|
||||
{ screenID: this.screen.id, accessToken: this.screen.token },
|
||||
false,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
if (generation === this.generation && this.previous?.music?.version === m.version) {
|
||||
const music = parseRemote({ music: result.state }).music
|
||||
if (music) this.previous = { ...this.previous, music }
|
||||
if (generation !== this.generation || report.session !== this.controller.state.session) return
|
||||
const music = parseRemote({ music: result.state }).music
|
||||
if (this.received) {
|
||||
this.received = { ...this.received, music }
|
||||
await this.applyReceived(true)
|
||||
}
|
||||
} catch {
|
||||
if (generation === this.generation) {
|
||||
this.previous = null
|
||||
this.state.connection = 'offline'
|
||||
this.state.error = 'Переход музыки выполнен локально; сервер не подтвердил обновление'
|
||||
} catch (error) {
|
||||
if (generation !== this.generation || report.session !== this.controller.state.session) return
|
||||
const pkg = this.selectedGame()?.package
|
||||
const playlist = pkg?.playlists.find((p) => p.id === c.playlistId)
|
||||
if (pkg && playlist) {
|
||||
const nextIndex = c.trackIndex + 1
|
||||
const track =
|
||||
playlist.tracks[nextIndex < playlist.tracks.length ? nextIndex : c.loop ? 0 : -1]
|
||||
try {
|
||||
if (track) await this.packages.ensureAsset(pkg, track.assetId, this.materialAbort.signal)
|
||||
if (generation === this.generation && report.session === this.controller.state.session)
|
||||
send(this.controller, { action: 'next', channel: 'music' })
|
||||
} catch (failure) {
|
||||
this.state.preparation.error =
|
||||
failure instanceof Error ? failure.message : 'Следующий трек недоступен'
|
||||
}
|
||||
}
|
||||
this.state.connection = 'offline'
|
||||
this.state.error =
|
||||
error instanceof Error ? error.message : 'Сервер не подтвердил переход музыки'
|
||||
} finally {
|
||||
this.advanceBusy = false
|
||||
}
|
||||
}
|
||||
close() {
|
||||
async close() {
|
||||
this.cancellation?.abort()
|
||||
this.packages.cancel()
|
||||
this.detach()
|
||||
await Promise.allSettled([
|
||||
this.materialRefresh,
|
||||
this.materialWork,
|
||||
this.mainWork,
|
||||
this.musicWork,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,3 +398,71 @@ test('превышение времени бездействия повторя
|
||||
assert.equal(f.calls.length, 3)
|
||||
assert.deepEqual(f.store.list(), [])
|
||||
})
|
||||
|
||||
test('инкрементальный кеш: завершённый файл доступен до полного пакета, повторно не скачивается и переживает перезапуск', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.store.remember(f.pkg)
|
||||
const first = f.pkg.assets[0]!
|
||||
const second = f.pkg.assets[1]!
|
||||
await f.store.ensureAsset(f.pkg, first.id, new AbortController().signal)
|
||||
assert.equal(f.calls.length, 1)
|
||||
await assert.rejects(f.store.localFile(f.key, second.id))
|
||||
const range = await packageMediaResponse(
|
||||
f.store,
|
||||
new Request(`screen-app://app/packages/${f.key}/${first.id}`, {
|
||||
headers: { Range: 'bytes=2-5' },
|
||||
}),
|
||||
)
|
||||
assert.equal(range.status, 206)
|
||||
assert.deepEqual(Buffer.from(await range.arrayBuffer()), body.subarray(2, 6))
|
||||
await f.store.ensureAsset(f.pkg, first.id, new AbortController().signal)
|
||||
assert.equal(f.calls.length, 1)
|
||||
const restored = new PackageStore(f.root, f.dependencies)
|
||||
await restored.initialize()
|
||||
assert.equal(restored.description(f.key)?.package.metadataVersion, f.pkg.metadataVersion)
|
||||
await restored.ensureAsset(f.pkg, first.id, new AbortController().signal)
|
||||
assert.equal(f.calls.length, 1)
|
||||
assert.deepEqual(await readFile((await restored.localFile(f.key, first.id)).path), body)
|
||||
})
|
||||
|
||||
test('инкрементальный кеш: повреждённый файл восстанавливается независимо от остальных, параллельные запросы объединяются', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.store.download(f.pkg)
|
||||
const first = f.pkg.assets[0]!
|
||||
const second = f.pkg.assets[1]!
|
||||
const path = (await f.store.localFile(f.key, first.id)).path
|
||||
await writeFile(path, Buffer.alloc(body.length))
|
||||
const before = f.calls.length
|
||||
await Promise.all(
|
||||
[0, 1].map(() => f.store.ensureAsset(f.pkg, first.id, new AbortController().signal)),
|
||||
)
|
||||
assert.equal(f.calls.length, before + 1)
|
||||
assert.deepEqual(await readFile((await f.store.localFile(f.key, first.id)).path), body)
|
||||
assert.deepEqual(await readFile((await f.store.localFile(f.key, second.id)).path), body)
|
||||
const restored = new PackageStore(f.root, f.dependencies)
|
||||
await restored.initialize()
|
||||
assert.ok(restored.description(f.key), 'описание восстановленного пакета переживает перезапуск')
|
||||
assert.deepEqual(await readFile((await restored.localFile(f.key, first.id)).path), body)
|
||||
})
|
||||
|
||||
test('инкрементальный кеш: ошибка и отмена не публикуют незавершённый файл и сохраняют готовый', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.store.remember(f.pkg)
|
||||
const [first, second] = f.pkg.assets
|
||||
await f.store.ensureAsset(f.pkg, first!.id, new AbortController().signal)
|
||||
f.state.free = 0n
|
||||
await assert.rejects(
|
||||
f.store.ensureAsset(f.pkg, second!.id, new AbortController().signal),
|
||||
/места/,
|
||||
)
|
||||
await assert.rejects(f.store.localFile(f.key, second!.id))
|
||||
assert.deepEqual(await readFile((await f.store.localFile(f.key, first!.id)).path), body)
|
||||
f.state.free = highSpace
|
||||
const abort = new AbortController()
|
||||
f.state.handler = async () => {
|
||||
abort.abort()
|
||||
return mediaResponse()
|
||||
}
|
||||
await assert.rejects(f.store.ensureAsset(f.pkg, second!.id, abort.signal))
|
||||
await assert.rejects(f.store.localFile(f.key, second!.id))
|
||||
})
|
||||
|
||||
+166
-9
@@ -84,6 +84,11 @@ export class PackageStore {
|
||||
private busy = false
|
||||
private cancellation: AbortController | null = null
|
||||
private readonly ready = new Map<string, LocalPackage>()
|
||||
private readonly descriptions = new Map<string, LocalPackage>()
|
||||
private readonly remembering = new Map<string, Promise<LocalPackage>>()
|
||||
private readonly repaired = new Set<string>()
|
||||
private readonly checked = new Map<string, string>()
|
||||
private readonly loading = new Map<string, Promise<void>>()
|
||||
constructor(
|
||||
root: string,
|
||||
private readonly dependencies: Dependencies = {},
|
||||
@@ -153,8 +158,18 @@ export class PackageStore {
|
||||
await mkdir(this.root, { recursive: true })
|
||||
await this.path()
|
||||
this.ready.clear()
|
||||
this.descriptions.clear()
|
||||
for (const key of await readdir(this.root)) {
|
||||
if (!hex.test(key)) continue
|
||||
try {
|
||||
const description = parseScreenPackage(
|
||||
JSON.parse(await readFile(await this.path(key, 'description.json'), 'utf8')),
|
||||
)
|
||||
if (packageKey(description.server, description.game.id) === key)
|
||||
this.descriptions.set(key, this.descriptionPackage(description))
|
||||
} catch {
|
||||
/* Старые пакеты не содержат отдельного описания. */
|
||||
}
|
||||
try {
|
||||
await this.path(key)
|
||||
const manifest = await this.load(key)
|
||||
@@ -226,7 +241,7 @@ export class PackageStore {
|
||||
return { formatVersion: 1, key, version, package: pkg, files }
|
||||
}
|
||||
list(): LocalPackage[] {
|
||||
return structuredClone([...this.ready.values()])
|
||||
return structuredClone([...new Map([...this.ready, ...this.descriptions]).values()])
|
||||
}
|
||||
cancel(): void {
|
||||
this.cancellation?.abort()
|
||||
@@ -249,22 +264,161 @@ export class PackageStore {
|
||||
const dir = await this.path(key)
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
this.ready.delete(key)
|
||||
this.descriptions.delete(key)
|
||||
})
|
||||
}
|
||||
private descriptionPackage(pkg: ScreenPackage): LocalPackage {
|
||||
return {
|
||||
formatVersion: 1,
|
||||
key: packageKey(pkg.server, pkg.game.id),
|
||||
version: pkg.metadataVersion,
|
||||
package: pkg,
|
||||
files: [],
|
||||
}
|
||||
}
|
||||
description(key: string): LocalPackage | null {
|
||||
return structuredClone(this.descriptions.get(key) ?? null)
|
||||
}
|
||||
async remember(value: ScreenPackage): Promise<LocalPackage> {
|
||||
if (this.busy) throw new Error('Хранилище пакетов занято')
|
||||
const pkg = parseScreenPackage(value)
|
||||
const local = this.descriptionPackage(pkg)
|
||||
const previous = this.remembering.get(local.key)
|
||||
const job = (async () => {
|
||||
await previous?.catch(() => {})
|
||||
await mkdir(await this.path(local.key), { recursive: true })
|
||||
const temporary = await this.path(local.key, `description-${randomUUID()}.tmp`)
|
||||
try {
|
||||
await this.writeJSON(temporary, pkg)
|
||||
await rename(temporary, await this.path(local.key, 'description.json'))
|
||||
this.descriptions.set(local.key, local)
|
||||
return structuredClone(local)
|
||||
} finally {
|
||||
await rm(temporary, { force: true })
|
||||
}
|
||||
})()
|
||||
this.remembering.set(local.key, job)
|
||||
try {
|
||||
return await job
|
||||
} finally {
|
||||
if (this.remembering.get(local.key) === job) this.remembering.delete(local.key)
|
||||
}
|
||||
}
|
||||
private async cachedFile(key: string, assetId: string) {
|
||||
const data: unknown = JSON.parse(
|
||||
await readFile(await this.path(key, 'cache', `${assetId}.json`), 'utf8'),
|
||||
)
|
||||
if (!data || typeof data !== 'object') throw new Error('Повреждён кеш ресурса')
|
||||
const f = data as StoredFile
|
||||
if (
|
||||
f.id !== assetId ||
|
||||
!Number.isSafeInteger(f.size) ||
|
||||
f.size <= 0 ||
|
||||
!hex.test(f.sha256) ||
|
||||
!Object.values(mimeTypes).includes(f.mime)
|
||||
)
|
||||
throw new Error('Повреждён кеш ресурса')
|
||||
const path = await this.path(key, 'cache', assetId)
|
||||
const info = await lstat(path)
|
||||
if (!info.isFile() || info.size !== f.size) throw new Error('Повреждён файл кеша')
|
||||
return { path, size: f.size, mime: f.mime, sha256: f.sha256 }
|
||||
}
|
||||
async localFile(key: string, assetId: string) {
|
||||
checkHex(key)
|
||||
checkHex(assetId)
|
||||
const pkg = this.ready.get(key)
|
||||
const file = pkg?.files.find((file) => file.id === assetId)
|
||||
if (!pkg || !file) throw new Error('Готовый ресурс не найден')
|
||||
if (pkg && file && !this.repaired.has(`${key}/${assetId}`)) {
|
||||
try {
|
||||
const path = await this.path(key, `v-${pkg.version}`, 'assets', assetId)
|
||||
const info = await lstat(path)
|
||||
if (!info.isFile() || info.size !== file.size) throw new Error('Повреждён файл пакета')
|
||||
return { path, size: file.size, mime: file.mime, sha256: file.sha256 }
|
||||
} catch {
|
||||
/* Отдельный ресурс можно восстановить, не теряя остальные. */
|
||||
}
|
||||
}
|
||||
return this.cachedFile(key, assetId)
|
||||
}
|
||||
canRead(key: string, assetId: string) {
|
||||
return (
|
||||
this.checked.has(`${key}/${assetId}`) ||
|
||||
!!this.ready
|
||||
.get(key)
|
||||
?.files.some((f) => f.id === assetId && !this.repaired.has(`${key}/${assetId}`))
|
||||
)
|
||||
}
|
||||
async ensureAsset(
|
||||
pkg: ScreenPackage,
|
||||
assetId: string,
|
||||
signal: AbortSignal,
|
||||
progress: (bytes: number) => void = () => {},
|
||||
): Promise<void> {
|
||||
const key = packageKey(pkg.server, pkg.game.id)
|
||||
checkHex(assetId)
|
||||
const asset = pkg.assets.find((a) => a.id === assetId)
|
||||
if (!asset) throw new Error('Ресурс отсутствует в описании игры')
|
||||
const token = `${key}/${assetId}`
|
||||
const existing = this.loading.get(token)
|
||||
if (existing) return existing
|
||||
const job = (async () => {
|
||||
signal.throwIfAborted()
|
||||
try {
|
||||
const file = await this.localFile(key, assetId)
|
||||
const stat = await lstat(file.path)
|
||||
const signature = `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${file.sha256}`
|
||||
if (this.checked.get(token) === signature) return
|
||||
const actual = await checksum(file.path)
|
||||
if (actual.size === file.size && actual.sha256 === file.sha256) {
|
||||
this.checked.set(token, signature)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
/* Отсутствующий или повреждённый файл скачивается заново. */
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
await this.remembering.get(key)
|
||||
if (!this.descriptions.has(key)) await this.remember(pkg)
|
||||
signal.throwIfAborted()
|
||||
await mkdir(await this.path(key, 'cache'), { recursive: true })
|
||||
const name = `${assetId}-${randomUUID()}.tmp`
|
||||
const path = await this.path(key, 'cache', name)
|
||||
const metadata = await this.path(key, 'cache', `${name}.json`)
|
||||
try {
|
||||
let file: StoredFile | undefined
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
file = await this.fetchFile(asset, path, signal, progress)
|
||||
break
|
||||
} catch (error) {
|
||||
await rm(path, { force: true })
|
||||
signal.throwIfAborted()
|
||||
if (!(error instanceof RetryableError) || attempt === 2) throw error
|
||||
if (this.dependencies.wait) await this.dependencies.wait(attempt ? 3000 : 1000, signal)
|
||||
else await wait(attempt ? 3000 : 1000, undefined, { signal })
|
||||
}
|
||||
}
|
||||
if (!file) throw new Error('Файл не загружен')
|
||||
const actual = await checksum(path)
|
||||
if (actual.size !== file.size || actual.sha256 !== file.sha256)
|
||||
throw new Error('Ошибка проверки скачанного файла')
|
||||
signal.throwIfAborted()
|
||||
await this.writeJSON(metadata, file)
|
||||
await rename(path, await this.path(key, 'cache', assetId))
|
||||
await rename(metadata, await this.path(key, 'cache', `${assetId}.json`))
|
||||
// Восстановленный кеш заменяет повреждённый ресурс старого пакета.
|
||||
this.repaired.add(token)
|
||||
} finally {
|
||||
await rm(path, { force: true })
|
||||
await rm(metadata, { force: true })
|
||||
}
|
||||
})()
|
||||
this.loading.set(token, job)
|
||||
try {
|
||||
const path = await this.path(key, `v-${pkg.version}`, 'assets', assetId)
|
||||
const info = await lstat(path)
|
||||
if (!info.isFile() || info.size !== file.size) throw new Error('Повреждён файл пакета')
|
||||
return { path, size: file.size, mime: file.mime }
|
||||
} catch (error) {
|
||||
this.ready.delete(key)
|
||||
throw error
|
||||
await job
|
||||
} finally {
|
||||
if (this.loading.get(token) === job) this.loading.delete(token)
|
||||
}
|
||||
}
|
||||
private async space(needed = 0) {
|
||||
@@ -382,6 +536,9 @@ export class PackageStore {
|
||||
await rename(pointer, await this.path(key, 'current.json'))
|
||||
activated = true
|
||||
this.ready.set(key, manifest)
|
||||
this.descriptions.delete(key)
|
||||
await rm(await this.path(key, 'description.json'), { force: true })
|
||||
for (const file of files) this.repaired.delete(`${key}/${file.id}`)
|
||||
await this.syncDirectory(dir).catch(() => {})
|
||||
// После переключения указателя сбой очистки не отменяет успешную активацию.
|
||||
await this.cleanup(key, `v-${version}`).catch(() => {})
|
||||
|
||||
@@ -46,11 +46,13 @@ export interface RemoteState {
|
||||
remainingMs: number
|
||||
status: 'stopped' | 'running' | 'paused' | 'finished'
|
||||
}
|
||||
resources: { cover: string; background: string; intro: string; conclusion: string }
|
||||
music: {
|
||||
version: bigint
|
||||
playlistId: number | null
|
||||
trackId: number | null
|
||||
trackIds: number[]
|
||||
filenames: string[]
|
||||
position: number
|
||||
volume: number
|
||||
loop: boolean
|
||||
@@ -94,6 +96,7 @@ export function parseRemote(value: unknown): RemoteState {
|
||||
playlistId: p ? integer(p.ID, 1) : null,
|
||||
trackId: trackIds[index] ?? null,
|
||||
trackIds,
|
||||
filenames: tracks.map((t) => text(object(t).filename ?? '')),
|
||||
position: integer(m.positionMs ?? 0) / 1000,
|
||||
volume: integer(m.volume ?? 0, 0, 100),
|
||||
loop: m.loop === true,
|
||||
@@ -133,6 +136,12 @@ export function parseRemote(value: unknown): RemoteState {
|
||||
remainingMs: remaining * 1000,
|
||||
status: (['stopped', 'stopped', 'running', 'paused', 'finished'] as const)[timerState]!,
|
||||
},
|
||||
resources: {
|
||||
cover: text(s.coverImage ?? ''),
|
||||
background: text(s.backgroundImage ?? ''),
|
||||
intro: text(s.introAudio ?? ''),
|
||||
conclusion: text(s.conclusionVideo ?? ''),
|
||||
},
|
||||
music,
|
||||
}
|
||||
}
|
||||
@@ -149,6 +158,13 @@ export function applyRemote(controller: Controller, previous: RemoteState, next:
|
||||
if (next.version > previous.version) {
|
||||
if (
|
||||
next.command === 0 ||
|
||||
controller.state.scene.mode !== next.mode ||
|
||||
([2, 4].includes(next.mode) &&
|
||||
controller.state.channels.main.source?.url !==
|
||||
controller.state.catalog.media.find(
|
||||
(source) => source.kind === (next.mode === 2 ? 'audio' : 'video'),
|
||||
)?.url) ||
|
||||
JSON.stringify(next.resources) !== JSON.stringify(previous.resources) ||
|
||||
(next.command >= 8 && controller.state.scene.mode !== 7) ||
|
||||
next.mode !== previous.mode ||
|
||||
next.text !== previous.text ||
|
||||
@@ -180,7 +196,7 @@ export function applyRemote(controller: Controller, previous: RemoteState, next:
|
||||
}
|
||||
const m = next.music
|
||||
if (!m || (previous.music && m.version <= previous.music.version)) return
|
||||
if (!m.playlistId) {
|
||||
if (!m.playlistId || !m.trackIds.length) {
|
||||
if (controller.state.channels.music.source)
|
||||
send(controller, { action: 'stop', channel: 'music' })
|
||||
return
|
||||
@@ -191,9 +207,7 @@ export function applyRemote(controller: Controller, previous: RemoteState, next:
|
||||
playlist.tracks.length !== m.trackIds.length ||
|
||||
m.trackIds.some((id, index) => playlist.tracks[index]!.id !== id)
|
||||
) {
|
||||
if (controller.state.channels.music.source)
|
||||
send(controller, { action: 'stop', channel: 'music' })
|
||||
throw new Error('Музыка изменилась на сервере. Подготовьте материалы при закрытом показе')
|
||||
throw new Error('Музыка ожидает подготовки локальных файлов')
|
||||
}
|
||||
const index = playlist.tracks.findIndex((t) => t.id === m.trackId)
|
||||
if (index < 0) throw new Error('Трек отсутствует в подготовленных материалах')
|
||||
@@ -208,7 +222,10 @@ export function applyRemote(controller: Controller, previous: RemoteState, next:
|
||||
const c = controller.state.channels.music
|
||||
if (c.playlistId !== playlist.id)
|
||||
send(controller, { action: 'playlist', channel: 'music', playlistId: playlist.id })
|
||||
if (controller.state.channels.music.trackIndex !== index)
|
||||
if (
|
||||
controller.state.channels.music.trackIndex !== index ||
|
||||
controller.state.channels.music.source?.url !== playlist.tracks[index]?.source.url
|
||||
)
|
||||
send(controller, { action: 'track', channel: 'music', trackIndex: index })
|
||||
}
|
||||
send(controller, { action: 'volume', channel: 'music', volume: m.volume })
|
||||
|
||||
@@ -104,6 +104,20 @@ function mode(value: unknown): number {
|
||||
return result
|
||||
}
|
||||
|
||||
export function resolveMediaURL(server: string, value: string): string {
|
||||
const ref = value.trim()
|
||||
if (ref.startsWith('//') || (/^[a-z][a-z0-9+.-]*:/i.test(ref) && !/^https?:\/\//i.test(ref)))
|
||||
throw new Error('Недопустимый адрес файла')
|
||||
const url = httpURL(
|
||||
/^https?:\/\//i.test(ref)
|
||||
? ref
|
||||
: ref.startsWith('/')
|
||||
? new URL(ref, server).href
|
||||
: `${server}/api/files/${encodeURIComponent(ref)}`,
|
||||
).href
|
||||
return url
|
||||
}
|
||||
|
||||
// Только проекция данных показа: сырые ответы API могут содержать участников и секреты.
|
||||
export function buildScreenPackage(
|
||||
server: string,
|
||||
@@ -122,15 +136,7 @@ export function buildScreenPackage(
|
||||
function asset(value: unknown): string | null {
|
||||
const ref = text(value).trim()
|
||||
if (!ref) return null
|
||||
if (ref.startsWith('//') || (/^[a-z][a-z0-9+.-]*:/i.test(ref) && !/^https?:\/\//i.test(ref)))
|
||||
throw new Error('Недопустимый адрес файла')
|
||||
const url = httpURL(
|
||||
/^https?:\/\//i.test(ref)
|
||||
? ref
|
||||
: ref.startsWith('/')
|
||||
? new URL(ref, server).href
|
||||
: `${server}/api/files/${encodeURIComponent(ref)}`,
|
||||
).href
|
||||
const url = resolveMediaURL(server, ref)
|
||||
const assetId = hash(url)
|
||||
assets.set(assetId, { id: assetId, url })
|
||||
return assetId
|
||||
|
||||
@@ -15,6 +15,10 @@ module.exports = async function onlineSmoke({
|
||||
let version = 1
|
||||
let offline = false
|
||||
let polls = 0
|
||||
let music = null
|
||||
let holdTrack = false
|
||||
let releaseTrack
|
||||
let trackRequested = false
|
||||
let currentCommand = 'SCREEN_COMMAND_PLAY'
|
||||
let plan = { steps: [{ mode: 2 }], currentStep: 0 }
|
||||
let planSaveFailure = false
|
||||
@@ -29,6 +33,10 @@ module.exports = async function onlineSmoke({
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}
|
||||
const path = req.url
|
||||
if (offline && path !== '/api/screens/state') {
|
||||
res.destroy()
|
||||
return
|
||||
}
|
||||
requests.push(path)
|
||||
const send = (value, status = 200) => {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' })
|
||||
@@ -79,8 +87,17 @@ module.exports = async function onlineSmoke({
|
||||
version++
|
||||
return send({})
|
||||
}
|
||||
if (path === '/api/games/6/music') return send({})
|
||||
if (path === '/api/files/intro.wav') {
|
||||
if (path === '/api/games/6/music')
|
||||
return send(music ? { state: music, gamePlaylistId: 1, playlists: [music.playlist] } : {})
|
||||
if (
|
||||
['/api/files/intro.wav', '/api/files/music-a.wav', '/api/files/music-b.wav'].includes(path)
|
||||
) {
|
||||
if (path.endsWith('music-b.wav') && holdTrack) {
|
||||
trackRequested = true
|
||||
await new Promise((resolve) => {
|
||||
releaseTrack = resolve
|
||||
})
|
||||
}
|
||||
assert.equal(req.headers.authorization, undefined)
|
||||
res.writeHead(200, { 'Content-Type': 'audio/wav' })
|
||||
return res.end(readFileSync(join(root, 'fixtures/audio.wav')))
|
||||
@@ -95,6 +112,8 @@ module.exports = async function onlineSmoke({
|
||||
return send({
|
||||
state: {
|
||||
gameID: 6,
|
||||
music,
|
||||
introAudio: 'intro.wav',
|
||||
mode: remoteMode,
|
||||
text: remoteText,
|
||||
timerLabel,
|
||||
@@ -176,11 +195,10 @@ module.exports = async function onlineSmoke({
|
||||
'control target stays on local package despite server game selection',
|
||||
)
|
||||
assert.notEqual(selectedBeforeDownload, 'HTTP smoke')
|
||||
await act({ action: 'download', gameId: 6 })
|
||||
await act({ action: 'connect', gameId: 6 })
|
||||
const packages = await evaluate('window.screenBridge.packages()')
|
||||
const pkg = packages.find((p) => p.name === 'HTTP smoke')
|
||||
assert.ok(pkg)
|
||||
await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(pkg.key)})`)
|
||||
await poll(
|
||||
() =>
|
||||
evaluate(
|
||||
@@ -188,7 +206,11 @@ module.exports = async function onlineSmoke({
|
||||
),
|
||||
'control target follows selected local package',
|
||||
)
|
||||
await act({ action: 'connect' })
|
||||
await poll(
|
||||
async () => (await evaluate('window.screenBridge.onlineState()')).connection === 'connected',
|
||||
'connected with output closed',
|
||||
)
|
||||
assert.equal((await state()).outputOpen, false)
|
||||
await evaluate(`window.screenBridge.show(${displayId})`)
|
||||
await poll(
|
||||
async () => (await evaluate('window.screenBridge.onlineState()')).connection === 'connected',
|
||||
@@ -201,7 +223,10 @@ module.exports = async function onlineSmoke({
|
||||
),
|
||||
'server sound assignment shown in UI',
|
||||
)
|
||||
assert.equal((await state()).channels.main.desired, 'stopped')
|
||||
await poll(
|
||||
async () => (await state()).channels.main.desired === 'playing',
|
||||
'initial server state applied',
|
||||
)
|
||||
version++
|
||||
await poll(
|
||||
async () => (await state()).channels.main.status === 'playing',
|
||||
@@ -229,7 +254,10 @@ module.exports = async function onlineSmoke({
|
||||
async () => (await evaluate('window.screenBridge.onlineState()')).connection === 'connected',
|
||||
'reconnect baseline',
|
||||
)
|
||||
assert.equal((await state()).channels.main.desired, 'playing')
|
||||
await poll(
|
||||
async () => (await state()).channels.main.status === 'stopped',
|
||||
'reconnect applies current stop',
|
||||
)
|
||||
version++
|
||||
await poll(async () => (await state()).channels.main.status === 'stopped', 'new remote stop')
|
||||
const { powerMonitor } = require('electron')
|
||||
@@ -241,7 +269,10 @@ module.exports = async function onlineSmoke({
|
||||
const beforeReopenPolls = polls
|
||||
await evaluate(`window.screenBridge.show(${displayId})`)
|
||||
await poll(() => polls > beforeReopenPolls, 'post-incident baseline')
|
||||
assert.equal((await state()).channels.main.desired, 'stopped')
|
||||
await poll(
|
||||
async () => (await state()).channels.main.status === 'playing',
|
||||
'reopen applies server play',
|
||||
)
|
||||
version++
|
||||
await poll(
|
||||
async () => (await state()).channels.main.status === 'playing',
|
||||
@@ -261,6 +292,55 @@ module.exports = async function onlineSmoke({
|
||||
true,
|
||||
)
|
||||
}
|
||||
// Новые серверные плейлисты автоматически сохраняются на диск до включения.
|
||||
music = {
|
||||
stateVersion: '1',
|
||||
playlist: {
|
||||
ID: 1,
|
||||
name: 'Автомузыка',
|
||||
tracks: [{ ID: 41, name: 'Первый', filename: 'music-a.wav' }],
|
||||
},
|
||||
trackIndex: 0,
|
||||
playback: 2,
|
||||
volume: 40,
|
||||
loop: true,
|
||||
}
|
||||
await poll(
|
||||
async () => (await state()).channels.music.status === 'playing',
|
||||
'initial music automatically cached',
|
||||
)
|
||||
const oldMusic = (await state()).channels.music.source.url
|
||||
holdTrack = true
|
||||
music = {
|
||||
...music,
|
||||
stateVersion: '2',
|
||||
playlist: {
|
||||
...music.playlist,
|
||||
tracks: [...music.playlist.tracks, { ID: 42, name: 'Новый', filename: 'music-b.wav' }],
|
||||
},
|
||||
trackIndex: 1,
|
||||
}
|
||||
await poll(() => trackRequested, 'missing server track download starts')
|
||||
assert.equal((await state()).channels.music.source.url, oldMusic)
|
||||
remoteMode = 8
|
||||
remoteText = 'Текст не ждёт музыку'
|
||||
currentCommand = 0
|
||||
version++
|
||||
await poll(
|
||||
async () => (await state()).scene.text === remoteText,
|
||||
'scene independent from pending music',
|
||||
)
|
||||
holdTrack = false
|
||||
releaseTrack()
|
||||
await poll(
|
||||
async () =>
|
||||
(await state()).channels.music.trackIndex === 1 &&
|
||||
(await state()).channels.music.status === 'playing',
|
||||
'downloaded track plays from disk',
|
||||
)
|
||||
assert.match((await state()).channels.music.source.url, /^screen-app:/)
|
||||
assert.notEqual((await state()).channels.music.source.url, oldMusic)
|
||||
console.log('SCREEN_LIVE_MATERIALS_SMOKE_PASS')
|
||||
// Настоящий редактор: черновик, ошибка сохранения, порядок и переходы.
|
||||
const clickPlan = async (label) => {
|
||||
await poll(
|
||||
|
||||
@@ -21,6 +21,7 @@ const state = ref<OnlineState>({
|
||||
error: '',
|
||||
notice: '',
|
||||
progress: null,
|
||||
preparation: { active: false, completed: 0, total: 0, downloadedBytes: 0, error: '' },
|
||||
packagesRevision: 0,
|
||||
})
|
||||
const server = ref('')
|
||||
@@ -36,7 +37,7 @@ const gameStatusLabels = new Map([
|
||||
])
|
||||
const connectionLabels = {
|
||||
disabled: 'Управление не подключено',
|
||||
waiting: 'Ожидание открытия показа',
|
||||
waiting: 'Ожидание ответа сервера',
|
||||
connected: 'Связь с сервером есть',
|
||||
offline: 'Нет связи; локальный показ продолжается',
|
||||
expired: 'Сессия экрана недействительна; подключите управление заново',
|
||||
@@ -152,6 +153,11 @@ onUnmounted(() => clearInterval(timer))
|
||||
:input-props="{ 'aria-label': 'Игра на сервере' }"
|
||||
/>
|
||||
<p v-if="!state.games.length">Список игр пока пуст. Обновите его при доступном сервере.</p>
|
||||
<n-button
|
||||
:disabled="gameId === null || isBusy || state.busy"
|
||||
@click="run({ action: 'connect', gameId: gameId! })"
|
||||
>Подключить выбранную игру</n-button
|
||||
>
|
||||
<n-space>
|
||||
<n-button
|
||||
:disabled="gameId === null || isBusy || state.busy"
|
||||
@@ -165,10 +171,13 @@ onUnmounted(() => clearInterval(timer))
|
||||
>
|
||||
</n-space>
|
||||
<p>
|
||||
Выбранную игру можно обновить после закрытия показа. Для обновления файлов по прежним
|
||||
адресам загрузите материалы повторно.
|
||||
Новые материалы загружаются автоматически. Для принудительного обновления файлов по
|
||||
прежним адресам закройте показ и загрузите материалы повторно.
|
||||
</p>
|
||||
<p>
|
||||
Игру можно подключить без предварительной загрузки: недостающие файлы сохранятся
|
||||
автоматически.
|
||||
</p>
|
||||
<p>Выбор игры выше используется для загрузки материалов.</p>
|
||||
<p v-if="selectedKey" data-testid="control-game">Игра для управления: {{ selectedName }}</p>
|
||||
<n-button
|
||||
v-if="selectedKey"
|
||||
@@ -181,7 +190,9 @@ onUnmounted(() => clearInterval(timer))
|
||||
{{ connectionLabels[state.connection]
|
||||
}}{{ state.screenId ? ` · Экран №${state.screenId}` : '' }}
|
||||
</p>
|
||||
<p v-if="state.screenId && !outputOpen">Опрос сервера начнётся после открытия показа.</p>
|
||||
<p v-if="state.screenId && !outputOpen">
|
||||
Связь и загрузка материалов продолжаются; показ откройте вручную.
|
||||
</p>
|
||||
<template v-if="state.screenId">
|
||||
<p role="status" data-testid="sound-assignment">
|
||||
{{
|
||||
@@ -216,6 +227,19 @@ onUnmounted(() => clearInterval(timer))
|
||||
«Отключить управление» прекращает управление приложением; привязка экрана на сервере
|
||||
сохраняется.
|
||||
</p>
|
||||
<p v-if="state.preparation.active" role="status" data-testid="materials-progress">
|
||||
Подготовка материалов: {{ state.preparation.completed }} /
|
||||
{{ state.preparation.total }} файлов; текущая загрузка
|
||||
{{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ.
|
||||
</p>
|
||||
<n-alert v-if="state.preparation.error" type="warning">
|
||||
{{ state.preparation.error }}. Прежний показ сохраняется до готовности замены.
|
||||
<n-button
|
||||
:disabled="isBusy || state.busy || state.preparation.active"
|
||||
@click="run({ action: 'retry-materials' })"
|
||||
>Повторить подготовку</n-button
|
||||
>
|
||||
</n-alert>
|
||||
<n-alert v-if="state.error" type="error">{{ state.error }}</n-alert>
|
||||
<n-alert v-if="state.notice" type="info">{{ state.notice }}</n-alert>
|
||||
<template v-if="state.busy">
|
||||
|
||||
@@ -308,7 +308,7 @@ onUnmounted(() => {
|
||||
:value="state?.catalog.key ?? 'fixtures'"
|
||||
:options="packageOptions"
|
||||
:disabled="!bridge || isBusy || isPlanSaving"
|
||||
:input-props="{ 'aria-label': 'Подготовленный пакет' }"
|
||||
:input-props="{ 'aria-label': 'Игра показа' }"
|
||||
@update:value="selectPackage"
|
||||
/>
|
||||
<n-alert v-if="state && !state.catalog.key" type="info"
|
||||
|
||||
+10
-1
@@ -4,7 +4,9 @@ export type OnlineIntent =
|
||||
| { action: 'plan-save'; key: string; steps: ScreenStep[] }
|
||||
| { action: 'plan-refresh' }
|
||||
| { action: 'login'; server: string; email: string; password: string }
|
||||
| { action: 'logout' | 'games' | 'connect' | 'disconnect' | 'sound' | 'results' | 'cancel' }
|
||||
| { action: 'retry-materials' }
|
||||
| { action: 'connect'; gameId?: number }
|
||||
| { action: 'logout' | 'games' | 'disconnect' | 'sound' | 'results' | 'cancel' }
|
||||
| { action: 'download' | 'check'; gameId: number }
|
||||
| { action: 'remove'; key: string }
|
||||
export interface OnlineState {
|
||||
@@ -20,5 +22,12 @@ export interface OnlineState {
|
||||
error: string
|
||||
notice: string
|
||||
progress: { downloadedBytes: number; completedFiles: number; totalFiles: number } | null
|
||||
preparation: {
|
||||
active: boolean
|
||||
completed: number
|
||||
total: number
|
||||
downloadedBytes: number
|
||||
error: string
|
||||
}
|
||||
packagesRevision: number
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface MediaSource {
|
||||
name: string
|
||||
kind: MediaKind
|
||||
url: string
|
||||
ready?: boolean
|
||||
}
|
||||
export interface Playlist {
|
||||
id: number
|
||||
|
||||
@@ -112,7 +112,7 @@ export class MediaPlayer {
|
||||
const spare = this.elements.music.find((media) => media !== active)!
|
||||
spare.pause()
|
||||
spare.muted = true
|
||||
if (next) this.source(spare, next.source.url)
|
||||
if (next && next.source.ready !== false) this.source(spare, next.source.url)
|
||||
else this.clear(spare)
|
||||
}
|
||||
private async applyChannel(channel: Channel, snapshot: State) {
|
||||
@@ -124,7 +124,7 @@ export class MediaPlayer {
|
||||
r.operation = c.operation
|
||||
const session = snapshot.session
|
||||
const previousId = r.sourceId
|
||||
r.sourceId = c.source?.id ?? null
|
||||
r.sourceId = c.source ? `${c.source.id}:${c.source.url}` : null
|
||||
if (!c.source) {
|
||||
if (channel === 'main') {
|
||||
this.clear(this.elements.audio)
|
||||
@@ -139,7 +139,7 @@ export class MediaPlayer {
|
||||
this.clear(c.source.kind === 'audio' ? this.elements.video : this.elements.audio)
|
||||
} else {
|
||||
media =
|
||||
r.media && previousId === c.source.id
|
||||
r.media && previousId === r.sourceId
|
||||
? r.media
|
||||
: this.elements.music.find((element) => element !== r.media)!
|
||||
if (r.media && r.media !== media) r.media.pause()
|
||||
@@ -191,7 +191,7 @@ export class MediaPlayer {
|
||||
throw new Error('Не удалось определить длительность файла')
|
||||
const position = Math.min(c.position, media.duration)
|
||||
if (
|
||||
(c.seekOperation > r.seekOperation || previousId !== c.source.id) &&
|
||||
(c.seekOperation > r.seekOperation || previousId !== r.sourceId) &&
|
||||
Math.abs(media.currentTime - position) > 0.01
|
||||
) {
|
||||
await waitUntil(
|
||||
|
||||
Reference in New Issue
Block a user