From 3e2608eae31923a86a8dd6fef789d3c881e85953 Mon Sep 17 00:00:00 2001
From: Fedorov Vladimir
Date: Sun, 18 Oct 2026 03:11:10 +0700
Subject: [PATCH] up
---
COMPLETED_TASKS.md | 28 ++
PLAN.md | 7 +-
README.md | 22 +-
docs/EVENT_CHECKLIST.md | 2 +-
docs/SCREEN-006_ACCEPTANCE.md | 85 ++--
docs/SCREEN-007_ACCEPTANCE.md | 3 +-
docs/SCREEN-008_ACCEPTANCE.md | 4 +-
docs/USER_GUIDE.md | 81 ++--
electron/controller.ts | 37 +-
electron/main.ts | 32 +-
electron/media-player.test.ts | 29 +-
electron/online.test.ts | 282 +++++++++++-
electron/online.ts | 704 ++++++++++++++++++++++++-----
electron/package-store.test.ts | 68 +++
electron/package-store.ts | 175 ++++++-
electron/remote-state.ts | 27 +-
electron/screen-package.ts | 24 +-
scripts/online-smoke.cjs | 96 +++-
src/components/ConnectionPanel.vue | 34 +-
src/components/ControlPage.vue | 2 +-
src/types/online.ts | 11 +-
src/types/screen.ts | 1 +
src/utils/media-player.ts | 8 +-
23 files changed, 1460 insertions(+), 302 deletions(-)
diff --git a/COMPLETED_TASKS.md b/COMPLETED_TASKS.md
index 45f78f3..ce24209 100644
--- a/COMPLETED_TASKS.md
+++ b/COMPLETED_TASKS.md
@@ -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 не выполнялись.
diff --git a/PLAN.md b/PLAN.md
index eb2e180..a19cbbc 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -85,9 +85,10 @@ Linux проверяется на обоих указанных дистрибу
Серверные команды проходят через локальный контроллер и используют подготовленные
файлы. Одну версию команды нельзя применять повторно. При потере сети
уже запущенное воспроизведение и локальный пульт продолжают работать.
- При восстановлении сети необходимо сверить версии и не повторять
- устаревшую команду поверх локальных действий; правила согласования проверяются
- в SCREEN-006 на существующих ScreenState/ScreenCommand.
+ При восстановлении сети применяется текущее состояние сервера. Опрос работает
+ и при закрытом показе; открытие окна остаётся ручным. Недостающие материалы
+ скачиваются автоматически, воспроизведение начинается только с локального диска.
+ Эти правила SCREEN-012 заменяют прежнее пропускание первого серверного состояния.
9. Текущие результаты игры загружаются онлайн с отметкой времени; офлайн доступны
только последние сохранённые данные с явным предупреждением оператору.
Серверная игра, ответы команд и вычисление новых результатов офлайн не работают.
diff --git a/README.md b/README.md
index ffef996..88be6bc 100644
--- a/README.md
+++ b/README.md
@@ -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 — используйте повторную загрузку. Результаты доступны после
завершения игры; последний успешный снимок сохраняется с датой для офлайн-показа.
diff --git a/docs/EVENT_CHECKLIST.md b/docs/EVENT_CHECKLIST.md
index 6495e21..72a3af4 100644
--- a/docs/EVENT_CHECKLIST.md
+++ b/docs/EVENT_CHECKLIST.md
@@ -45,7 +45,7 @@
и выбор внешнего дисплея; явно нажать «Открыть показ» после восстановления.
- [ ] При отсутствии звука проверить системный выход, HDMI, mute и громкость обоих каналов.
- [ ] После аварии проверить остаток таймера на паузе; медиа запускать явно.
-- [ ] После возврата сети дождаться связи и отправить новую команду через сайт.
+- [ ] После возврата сети проверить применение текущего серверного состояния.
- [ ] При повторном сбое сохранить журналы и сведения по
[руководству](USER_GUIDE.md#журналы-и-обращение-за-помощью).
diff --git a/docs/SCREEN-006_ACCEPTANCE.md b/docs/SCREEN-006_ACCEPTANCE.md
index 71008d9..e5d9d4c 100644
--- a/docs/SCREEN-006_ACCEPTANCE.md
+++ b/docs/SCREEN-006_ACCEPTANCE.md
@@ -17,60 +17,41 @@
до выхода из приложения. На Linux требуется рабочий libsecret/KWallet;
`basic_text` не используется для сохранения секретов.
-## Серверные команды
+## Постоянная связь и автоматическая подготовка SCREEN-012
-1. Нажать «Подключить управление этой игрой», записать номер экрана.
- Открыть показ на внешнем дисплее. Дождаться сообщения о связи.
-2. Открыть существующую страницу управления этой игрой на сайте.
- Убедиться, что экран появился в её списке. Назначить звук этому экрану
- через сайт или одноимённую кнопку приложения; это отключает звук других
- экранов игры по существующим правилам сервера.
-3. Отправить новую команду смены режима, пуска вступления, паузы, продолжения,
- перемотки и громкости. Повторить с заключительным видео.
-4. Проверить текст, настройку/пуск/паузу/сброс таймера и музыку: выбор трека,
- громкость, повтор и автоматический переход. Все используемые ресурсы должны
- быть в скачанном пакете; новый плейлист требует подготовки материалов.
-5. Чередовать серверные команды и локальный пульт. Локальная пауза не должна отменяться повтором
- того же серверного состояния. Новая серверная команда должна применяться.
- Команды компьютера действуют только на этот показ и не рассылаются другим экранам.
-6. Проверить прежний браузерный экран отдельно: его вход, привязка и команды
- должны работать как раньше. Общие команды игры по-прежнему относятся ко всем
- её подключённым экранам.
+1. Выбрать игру, которой ещё нет на компьютере, и нажать «Подключить выбранную
+ игру». Проверить связь и прогресс загрузки при закрытом показе.
+2. Назначить звук экрану. Вручную открыть показ: текущие серверные экран и музыка
+ применяются после сохранения необходимых файлов. Проверить локальные адреса
+ медиа; прямого воспроизведения HTTP-файлов в renderer быть не должно.
+3. Через сайт сменить экран, текст, таймер и музыку. Повторное состояние не
+ перезапускает медиа. Закрыть показ, изменить серверное состояние и открыть его
+ снова: оно применяется, но закрытое окно самостоятельно не открывается.
+4. Добавить новый трек или сменить плейлист на ещё не подготовленный. На медленном
+ соединении прежняя музыка продолжает играть до готовности нового файла.
+ Смена текста в это время не ждёт музыку. Аналогично проверить новое вступление,
+ видео и изображения. Все связанные плейлисты и остальные материалы готовятся фоном.
+5. Во время загрузки несколько раз сменить серверную команду: устаревший файл
+ не должен включаться после завершения загрузки. Ошибка отдельного файла или
+ нехватка места не удаляет готовые материалы; проверить «Повторить подготовку».
+6. Проверить локальные режимы, шаги, основной канал, таймер и музыку: онлайн они
+ меняют общий сервер. Выключение всего звука остаётся локальным. Публикация
+ произвольного текста сохраняет и выбирает текстовый шаг общего плана.
+7. Отключить сеть, изменить показ локально, затем изменить состояние на сервере.
+ Вернуть сеть: серверные экран и музыка принимаются; локальные офлайн-команды
+ не отправляются позднее. После полного скачивания проверить час показа без сети.
+8. Перезапустить приложение без сети с тем же профилем. Сохранённые игра и снимок
+ результатов доступны, окно закрыто. Открыть его и проверить локальные файлы.
+ Повредить один тестовый файл, восстановить его автоматически при доступной сети
+ и повторить перезапуск: остальные ресурсы и исправленный файл должны сохраниться.
+9. Проверить истечение JWT и экранной сессии, смену игры во время загрузки,
+ выход без удаления файлов и сохранность предыдущего полного пакета после
+ неудачной ручной перезагрузки. Не менять рабочую базу ради проверки.
-Опрос идёт раз в секунду, только пока окно показа открыто. Первое состояние при
-подключении/повторном открытии становится исходной отметкой: старые команды не
-запускаются, назначение звука учитывается при первом подключении. Для показа
-отправьте новую команду. Сайт показывает наличие связи; точные подтверждения
-проигрывателя и ошибки доступны в пульте приложения, поскольку существующий API
-не принимает их от экрана.
-
-## Обрыв связи, обновления и перезапуск
-
-1. Во время воспроизведения отключить сеть компьютера. Проверить локальные
- play/pause/seek, переход музыки и продолжающийся таймер.
-2. При отключённой сети изменить показ локально, затем отправить команду
- через сайт. Вернуть сеть: накопленное состояние не должно отменить локальное
- действие. Следующая новая серверная команда должна примениться.
-3. Проверить истечение пользовательского входа и отдельно недействительную
- экранную сессию на тестовом сервере. Показ не прерывается; интерфейс предлагает
- повторный вход или подключение управления. Не менять рабочую базу ради проверки.
-4. Закрыть показ. Изменить материалы игры на сервере и проверить изменения
- в приложении. Явно загрузить новую версию. При открытом показе обновление
- выбранной игры должно отклоняться. Ошибка и отмена загрузки сохраняют старую копию.
-5. Завершить игру и обновить снимок результатов. Проверить таблицу и время снимка.
- Ошибка обновления должна сохранять последний успешный снимок.
-6. Закрыть приложение, отключить сеть и запустить снова. Выбранная игра и снимок
- результатов доступны; окно показа и звук сами не запускаются. Открыть показ
- и проверить локальное воспроизведение.
-7. Выход из аккаунта не удаляет материалы и не прерывает локальное воспроизведение,
- но отключает получение команд сервера. Удалить невыбранный пакет через интерфейс.
-
-Проверка метаданных не обнаруживает новые байты файла по прежнему URL:
-для этого нужна повторная загрузка. Сервер хранит последнее состояние, поэтому
-несколько команд между опросами могут объединиться. После неопределённого исхода
-запроса перехода музыки приложение не повторяет его автоматически. Секреты
-находятся отдельно от пакетов в зашифрованном файле `connection/credentials.bin`
-в userData; пароль не сохраняется. Снимки содержат только поля публичной таблицы.
+Ручная «Загрузить материалы» остаётся для принудительного обновления файлов по
+прежнему адресу и требует закрытого показа. Существующие полные пакеты совместимы
+с автоматическим кешем. Пароль не сохраняется, токены остаются в основном процессе
+и защищённом хранилище ОС; снимки содержат только публичные поля результатов.
## Фиксация результата
diff --git a/docs/SCREEN-007_ACCEPTANCE.md b/docs/SCREEN-007_ACCEPTANCE.md
index 62b637e..140c459 100644
--- a/docs/SCREEN-007_ACCEPTANCE.md
+++ b/docs/SCREEN-007_ACCEPTANCE.md
@@ -74,7 +74,8 @@
другой экран требуется выбрать вручную. Автоматического открытия нет.
3. Вернуть сеть и подключить управление игрой. После аварийного закрытия
отправить через сайт команду PLAY, затем восстановить и вручную открыть показ.
- Накопленная команда не запускает звук. Следующая новая команда применяется.
+ Пока окно закрыто, звука нет. После ручного открытия применяется текущая
+ серверная команда; повторные ответы её не перезапускают.
4. Закрыть приложение во время показа. Звук прекращается, блокировка сна
освобождается, материалы остаются на диске. При повторном запуске нет автопуска.
diff --git a/docs/SCREEN-008_ACCEPTANCE.md b/docs/SCREEN-008_ACCEPTANCE.md
index 99a32cd..9ad4c68 100644
--- a/docs/SCREEN-008_ACCEPTANCE.md
+++ b/docs/SCREEN-008_ACCEPTANCE.md
@@ -129,8 +129,8 @@ preload, локальную выдачу Range, восстановление п
4. Выполнить SCREEN-007: переподключение проектора, смена параметров дисплея,
fullscreen, автоматический/принудительный сон, кабельный/USB/HDMI звук.
Восстановление требует явного открытия; медиа остановлены, таймер на паузе.
-5. Вернуть сеть. Старая серверная команда не отменяет локальное действие;
- новая команда применяется. Завершить игру и сохранить результаты с датой снимка.
+5. Вернуть сеть. Принимаются текущие серверные экран и музыка, локальные
+ офлайн-команды не отправляются позднее. Завершить игру и сохранить результаты с датой снимка.
6. Перезапустить приложение без сети: выбранная игра/снимок доступны, окно показа
закрыто, звук не начинается. Заменить папку приложения новой сборкой с тем же
пользовательским профилем и повторить проверку сохранности данных.
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index 16d625f..9b9ade6 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -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, «Выключить весь звук», громкость канала, открытое окно и статус проигрывателя. Для серверных команд проверьте назначение звука экрану. |
| Ошибка подготовки сцены | Проверьте сообщение и нажмите «Повторить подготовку». При повторении проверьте пакет до продолжения игры. |
| Пакет повреждён или отсутствует файл | При доступной сети закройте показ и загрузите материалы заново. Для удаления выберите другой пакет или тестовый режим, затем «Удалить материалы» у нужной игры. Без сети восстановить отсутствующие байты нельзя. |
| Истёк вход или сессия экрана | Повторно войдите или подключите управление по сообщению пульта. Готовые материалы остаются на диске. |
-| Приложение перезапущено | Выбранная игра и сохранённые результаты восстанавливаются. Откройте показ и запустите нужные медиа/таймер явно; их текущее выполнение не восстанавливается. |
+| Приложение перезапущено | Выбранная игра и сохранённые результаты восстанавливаются. Откройте показ вручную: онлайн применяется текущее состояние сервера, офлайн запускайте медиа локально. |
| Пульт пуст или зависает при запуске | Запишите ОС, версию, симптомы и сообщения запуска, передайте отчёт. Не считайте такой запуск успешным. |
При открытом показе приложение запрашивает блокировку автоматического сна,
diff --git a/electron/controller.ts b/electron/controller.ts
index 363d2a7..31f4a7d 100644
--- a/electron/controller.ts
+++ b/electron/controller.ts
@@ -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()
diff --git a/electron/main.ts b/electron/main.ts
index 2f46653..5f493d3 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -480,6 +480,7 @@ app
})
let localSession = ''
let localSequence = 0
+ const pendingCommands = new Set()
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
diff --git a/electron/media-player.test.ts b/electron/media-player.test.ts
index f69f944..c47c341 100644
--- a/electron/media-player.test.ts
+++ b/electron/media-player.test.ts
@@ -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)
+})
diff --git a/electron/online.test.ts b/electron/online.test.ts
index fc89a94..8d93a24 100644
--- a/electron/online.test.ts
+++ b/electron/online.test.ts
@@ -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 | null,
+ mediaHandler: null as ((url: string) => Promise) | 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
+ 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
+ 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)
+})
diff --git a/electron/online.ts b/electron/online.ts
index 9656a73..ffbae09 100644
--- a/electron/online.ts
+++ b/electron/online.ts
@@ -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 | null = null
+ private materialWork: Promise | null = null
+ private lastMaterials = -Infinity
+ private mainWork: Promise | null = null
+ private musicWork: Promise | null = null
+ private mainSession = ''
+ private musicSession = ''
+ private receivedSignature = ''
+ private localCommandVersion = 0
+ private materialSignature = ''
+ private async description(gameId: number): Promise {
+ 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,
+ ])
}
}
diff --git a/electron/package-store.test.ts b/electron/package-store.test.ts
index bd30049..dabb184 100644
--- a/electron/package-store.test.ts
+++ b/electron/package-store.test.ts
@@ -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))
+})
diff --git a/electron/package-store.ts b/electron/package-store.ts
index ce8a565..fc466cc 100644
--- a/electron/package-store.ts
+++ b/electron/package-store.ts
@@ -84,6 +84,11 @@ export class PackageStore {
private busy = false
private cancellation: AbortController | null = null
private readonly ready = new Map()
+ private readonly descriptions = new Map()
+ private readonly remembering = new Map>()
+ private readonly repaired = new Set()
+ private readonly checked = new Map()
+ private readonly loading = new Map>()
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 {
+ 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 {
+ 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(() => {})
diff --git a/electron/remote-state.ts b/electron/remote-state.ts
index d063c1e..426e96e 100644
--- a/electron/remote-state.ts
+++ b/electron/remote-state.ts
@@ -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 })
diff --git a/electron/screen-package.ts b/electron/screen-package.ts
index 427d335..17c993f 100644
--- a/electron/screen-package.ts
+++ b/electron/screen-package.ts
@@ -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
diff --git a/scripts/online-smoke.cjs b/scripts/online-smoke.cjs
index 17593f9..080e8b7 100644
--- a/scripts/online-smoke.cjs
+++ b/scripts/online-smoke.cjs
@@ -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(
diff --git a/src/components/ConnectionPanel.vue b/src/components/ConnectionPanel.vue
index 9df7621..29e020a 100644
--- a/src/components/ConnectionPanel.vue
+++ b/src/components/ConnectionPanel.vue
@@ -21,6 +21,7 @@ const state = ref({
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': 'Игра на сервере' }"
/>
Список игр пока пуст. Обновите его при доступном сервере.
+ Подключить выбранную игру
clearInterval(timer))
>
- Выбранную игру можно обновить после закрытия показа. Для обновления файлов по прежним
- адресам загрузите материалы повторно.
+ Новые материалы загружаются автоматически. Для принудительного обновления файлов по
+ прежним адресам закройте показ и загрузите материалы повторно.
+
+
+ Игру можно подключить без предварительной загрузки: недостающие файлы сохранятся
+ автоматически.
- Выбор игры выше используется для загрузки материалов.
Игра для управления: {{ selectedName }}
clearInterval(timer))
{{ connectionLabels[state.connection]
}}{{ state.screenId ? ` · Экран №${state.screenId}` : '' }}
- Опрос сервера начнётся после открытия показа.
+
+ Связь и загрузка материалов продолжаются; показ откройте вручную.
+
{{
@@ -216,6 +227,19 @@ onUnmounted(() => clearInterval(timer))
«Отключить управление» прекращает управление приложением; привязка экрана на сервере
сохраняется.
+
+ Подготовка материалов: {{ state.preparation.completed }} /
+ {{ state.preparation.total }} файлов; текущая загрузка
+ {{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ.
+
+
+ {{ state.preparation.error }}. Прежний показ сохраняется до готовности замены.
+ Повторить подготовку
+
{{ state.error }}
{{ state.notice }}
diff --git a/src/components/ControlPage.vue b/src/components/ControlPage.vue
index af07051..71435fa 100644
--- a/src/components/ControlPage.vue
+++ b/src/components/ControlPage.vue
@@ -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"
/>
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(