This commit is contained in:
2026-10-18 03:11:10 +07:00
parent 8610f45a29
commit 3e2608eae3
23 changed files with 1460 additions and 302 deletions
+28
View File
@@ -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 не выполнялись.
+4 -3
View File
@@ -85,9 +85,10 @@ Linux проверяется на обоих указанных дистрибу
Серверные команды проходят через локальный контроллер и используют подготовленные
файлы. Одну версию команды нельзя применять повторно. При потере сети
уже запущенное воспроизведение и локальный пульт продолжают работать.
При восстановлении сети необходимо сверить версии и не повторять
устаревшую команду поверх локальных действий; правила согласования проверяются
в SCREEN-006 на существующих ScreenState/ScreenCommand.
При восстановлении сети применяется текущее состояние сервера. Опрос работает
и при закрытом показе; открытие окна остаётся ручным. Недостающие материалы
скачиваются автоматически, воспроизведение начинается только с локального диска.
Эти правила SCREEN-012 заменяют прежнее пропускание первого серверного состояния.
9. Текущие результаты игры загружаются онлайн с отметкой времени; офлайн доступны
только последние сохранённые данные с явным предупреждением оператору.
Серверная игра, ответы команд и вычисление новых результатов офлайн не работают.
+15 -7
View File
@@ -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 — используйте повторную загрузку. Результаты доступны после
завершения игры; последний успешный снимок сохраняется с датой для офлайн-показа.
+1 -1
View File
@@ -45,7 +45,7 @@
и выбор внешнего дисплея; явно нажать «Открыть показ» после восстановления.
- [ ] При отсутствии звука проверить системный выход, HDMI, mute и громкость обоих каналов.
- [ ] После аварии проверить остаток таймера на паузе; медиа запускать явно.
- [ ] После возврата сети дождаться связи и отправить новую команду через сайт.
- [ ] После возврата сети проверить применение текущего серверного состояния.
- [ ] При повторном сбое сохранить журналы и сведения по
[руководству](USER_GUIDE.md#журналы-и-обращение-за-помощью).
+33 -52
View File
@@ -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; пароль не сохраняется. Снимки содержат только поля публичной таблицы.
Ручная «Загрузить материалы» остаётся для принудительного обновления файлов по
прежнему адресу и требует закрытого показа. Существующие полные пакеты совместимы
с автоматическим кешем. Пароль не сохраняется, токены остаются в основном процессе
и защищённом хранилище ОС; снимки содержат только публичные поля результатов.
## Фиксация результата
+2 -1
View File
@@ -74,7 +74,8 @@
другой экран требуется выбрать вручную. Автоматического открытия нет.
3. Вернуть сеть и подключить управление игрой. После аварийного закрытия
отправить через сайт команду PLAY, затем восстановить и вручную открыть показ.
Накопленная команда не запускает звук. Следующая новая команда применяется.
Пока окно закрыто, звука нет. После ручного открытия применяется текущая
серверная команда; повторные ответы её не перезапускают.
4. Закрыть приложение во время показа. Звук прекращается, блокировка сна
освобождается, материалы остаются на диске. При повторном запуске нет автопуска.
+2 -2
View File
@@ -129,8 +129,8 @@ preload, локальную выдачу Range, восстановление п
4. Выполнить SCREEN-007: переподключение проектора, смена параметров дисплея,
fullscreen, автоматический/принудительный сон, кабельный/USB/HDMI звук.
Восстановление требует явного открытия; медиа остановлены, таймер на паузе.
5. Вернуть сеть. Старая серверная команда не отменяет локальное действие;
новая команда применяется. Завершить игру и сохранить результаты с датой снимка.
5. Вернуть сеть. Принимаются текущие серверные экран и музыка, локальные
офлайн-команды не отправляются позднее. Завершить игру и сохранить результаты с датой снимка.
6. Перезапустить приложение без сети: выбранная игра/снимок доступны, окно показа
закрыто, звук не начинается. Заменить папку приложения новой сборкой с тем же
пользовательским профилем и повторить проверку сохранности данных.
+41 -40
View File
@@ -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
View File
@@ -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
View File
@@ -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
+28 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
])
}
}
+68
View File
@@ -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
View File
@@ -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(() => {})
+22 -5
View File
@@ -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 })
+15 -9
View File
@@ -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
+88 -8
View File
@@ -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(
+29 -5
View File
@@ -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">
+1 -1
View File
@@ -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
View File
@@ -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
}
+1
View File
@@ -23,6 +23,7 @@ export interface MediaSource {
name: string
kind: MediaKind
url: string
ready?: boolean
}
export interface Playlist {
id: number
+4 -4
View File
@@ -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(