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}` : '' }}

-

Опрос сервера начнётся после открытия показа.

+

+ Связь и загрузка материалов продолжаются; показ откройте вручную. +