up
This commit is contained in:
@@ -443,3 +443,16 @@
|
|||||||
- Границы: изменения только evening_detective_screen. Сервер, proto и клиент
|
- Границы: изменения только evening_detective_screen. Сервер, proto и клиент
|
||||||
генерации не менялись. Production-подключение не проверялось; установочные
|
генерации не менялись. Production-подключение не проверялось; установочные
|
||||||
архивы не пересобирались, commit/push не выполнялись.
|
архивы не пересобирались, commit/push не выполнялись.
|
||||||
|
|
||||||
|
## SCREEN-022 — Пять блоков управления и редактор музыки
|
||||||
|
|
||||||
|
- Статус: `DONE` (2026-10-18).
|
||||||
|
- Согласованный план: Сервер, Игра, Экран, План экранов, План музыки; CRUD плейлистов, MP3, ручной порядок; звук включён на 100% при явном подключении игры.
|
||||||
|
- Изменения разрешены в приложении экрана, сервере и protobuf-клиентах сайта.
|
||||||
|
- Проверка: тесты и проверки трёх репозиториев, Electron smoke, ручное ревью стандартов.
|
||||||
|
- Результат: пять карточек в согласованном порядке; предпросмотр, монитор, назначение звука и mute находятся в «Экран». Один общий цикл состояния подключения. В «План музыки» доступны CRUD плейлистов, native MP3 до 64 МиБ, сохранение/отмена порядка, явное назначение игре; выбор редактора не меняет воспроизведение.
|
||||||
|
- Звук: локально оба канала начинаются с 100% и без mute; явный BindScreen(initializeAudio=true) назначает звук и устанавливает уровни на сервере, сохраняя транспорт. Автовосстановление связи уровни не сбрасывает. Первичная привязка копирует текущий показ отдельно, повторная не переинициализирует его.
|
||||||
|
- Сервер: ReorderPlaylistTracks атомарно проверяет состав и меняет позиции/индексы по ID трека; GetMusic читает согласованный snapshot. Параллельные загрузки сериализованы; используемость учитывает game_music_states. Новые поля/API сгенерированы штатно во всех трёх репозиториях; два вызова сайта явно сохраняют прежний режим привязки. Без новых зависимостей и миграций схемы.
|
||||||
|
- Проверка: 121 тест приложения, type-check, ESLint, Stylelint, build и Electron GUI smoke — PASS. GUI проверяет все пять карточек, CRUD, сохранение ввода при опросе, native MP3, назначение игре, отмену/сохранение порядка без смены файла/операции/позиции. Сервер go test ./... — PASS; отдельная локальная PostgreSQL-база проверила атомарность/конфликты, сохранение track ID/тайминга, совместные чтение и перестановку, параллельные загрузки и назначение звука двум экранам. Сайт: type-check, lint, lint:css, build-only — PASS.
|
||||||
|
- Ревью: полный diff вручную проверен по Go/frontend стандартам, независимые ревью сервера и приложения учтены; git diff --check во всех трёх репозиториях — PASS. Документация и чек-лист обновлены.
|
||||||
|
- Ограничения: рабочий сервер не развёртывался; для нового API нужен обновлённый бэкенд. Установочные архивы и аппаратная матрица не пересобирались/не проверялись. Коммиты и push не выполнялись.
|
||||||
|
|||||||
@@ -78,10 +78,10 @@ npm run dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
До запуска подключите второй дисплей в режиме расширенного рабочего стола
|
До запуска подключите второй дисплей в режиме расширенного рабочего стола
|
||||||
и назначьте экран компьютера основным. В блоке «Дисплей показа» выберите
|
и назначьте экран компьютера основным. В блоке «Экран» выберите
|
||||||
внешний дисплей и нажмите «Открыть показ». Аудио и видео — синтетические локальные файлы длительностью
|
внешний дисплей и нажмите «Открыть показ». Аудио и видео — синтетические локальные файлы длительностью
|
||||||
60 секунд; бинарники находятся в игнорируемом fixtures/ и не входят в Git.
|
60 секунд; бинарники находятся в игнорируемом fixtures/ и не входят в Git.
|
||||||
В блоке «Игра и сервер» доступны вход, выбор игры и подготовка материалов.
|
В блоке «Сервер» доступны адрес и вход; в «Игра» — выбор игры и подготовка материалов.
|
||||||
|
|
||||||
Для постоянной связи войдите, выберите игру в серверном списке и нажмите
|
Для постоянной связи войдите, выберите игру в серверном списке и нажмите
|
||||||
«Подключить выбранную игру». Готовый пакет заранее не требуется: сначала сохраняются
|
«Подключить выбранную игру». Готовый пакет заранее не требуется: сначала сохраняются
|
||||||
@@ -188,12 +188,17 @@ URL, независимо от исходного имени файла. Лок
|
|||||||
|
|
||||||
## Локальный проигрыватель SCREEN-004
|
## Локальный проигрыватель SCREEN-004
|
||||||
|
|
||||||
Подключите игру в блоке «Игра и сервер», выберите внешний дисплей
|
Подключите игру в блоке «Игра», выберите внешний дисплей
|
||||||
и откройте показ. В активном шаге «Введение» или «Закрывашка» доступны управление воспроизведением,
|
и откройте показ. В активном шаге «Введение» или «Закрывашка» доступны управление воспроизведением,
|
||||||
громкость, перемотка и статус основного канала. Файл определяется режимом шага;
|
громкость, перемотка и статус основного канала. Файл определяется режимом шага;
|
||||||
отдельного блока выбора медиа нет. Смена шага останавливает прежнее основное медиа. Музыка воспроизводится отдельным
|
отдельного блока выбора медиа нет. Смена шага останавливает прежнее основное медиа. Музыка воспроизводится отдельным
|
||||||
каналом без автоматического приглушения. Звук выводится только окном показа.
|
каналом без автоматического приглушения. Звук выводится только окном показа.
|
||||||
Начальная громкость каждого канала — 35%; общий mute не меняет сохранённые уровни.
|
Начальная громкость каждого канала — 100%; общий mute не меняет сохранённые уровни.
|
||||||
|
При явном подключении игры звук назначается этому экрану, mute снимается, оба канала
|
||||||
|
устанавливаются на 100%. Дальнейшие команды сервера имеют приоритет; автоматическое
|
||||||
|
восстановление связи громкость не сбрасывает.
|
||||||
|
В «План музыки» доступны плейлисты, загрузка MP3 до 64 МиБ и порядок треков ↑ / ↓.
|
||||||
|
«Сохранить порядок» публикует перестановку, «Использовать в игре» назначает плейлист.
|
||||||
|
|
||||||
«Пуск с начала» и «Повторить с начала» запускают выбранный ресурс с нуля,
|
«Пуск с начала» и «Повторить с начала» запускают выбранный ресурс с нуля,
|
||||||
«Пауза» сохраняет фактическую позицию, «Продолжить» возобновляет её, «Стоп»
|
«Пауза» сохраняет фактическую позицию, «Продолжить» возобновляет её, «Стоп»
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
## До выезда
|
## До выезда
|
||||||
|
|
||||||
- [ ] Выбран архив своей ОС/архитектуры, SHA-256 совпал, приложение запускается.
|
- [ ] Выбран архив своей ОС/архитектуры, SHA-256 совпал, приложение запускается.
|
||||||
- [ ] В «Игра и сервер» выполнен вход, нужная игра загружена до завершения.
|
- [ ] В «Сервер» выполнен вход, в «Игра» нужная игра подготовлена до завершения.
|
||||||
- [ ] В блоке «Игра и сервер» подключена нужная игра; подготовка материалов завершена.
|
- [ ] В блоке «Игра» подключена нужная игра; подготовка материалов завершена.
|
||||||
- [ ] Проверены обложка, шаги, вступление, видео, текст, таймер и нужные плейлисты.
|
- [ ] Проверены обложка, шаги, вступление, видео, текст, таймер и нужные плейлисты.
|
||||||
- [ ] Пробный показ и перезапуск без интернета подтвердили готовность локальной копии.
|
- [ ] Пробный показ и перезапуск без интернета подтвердили готовность локальной копии.
|
||||||
- [ ] Если нужны результаты, сохранён доступный снимок и проверена его дата.
|
- [ ] Если нужны результаты, сохранён доступный снимок и проверена его дата.
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
## До прихода игроков
|
## До прихода игроков
|
||||||
|
|
||||||
- [ ] Проектор подключён в режиме расширения; экран компьютера назначен основным.
|
- [ ] Проектор подключён в режиме расширения; экран компьютера назначен основным.
|
||||||
- [ ] В «Дисплей показа» выбран внешний дисплей, нажато «Открыть показ».
|
- [ ] В «Экран» выбран внешний дисплей, нажато «Открыть показ».
|
||||||
- [ ] На проекторе материалы, на компьютере пульт; кириллица и изображение читаются.
|
- [ ] На проекторе материалы, на компьютере пульт; кириллица и изображение читаются.
|
||||||
- [ ] В ОС выбран нужный аудиовыход. Пробный звук слышен физически, mute выключен,
|
- [ ] В ОС выбран нужный аудиовыход. Пробный звук слышен физически, mute выключен,
|
||||||
громкость основного канала и музыки подходит для помещения.
|
громкость основного канала и музыки подходит для помещения.
|
||||||
|
|||||||
+29
-8
@@ -42,21 +42,21 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
|||||||
Основным назначьте экран компьютера; зеркалирование для раздельных окон не подходит.
|
Основным назначьте экран компьютера; зеркалирование для раздельных окон не подходит.
|
||||||
2. В настройках звука ОС выберите кабельный, USB или HDMI-выход и проверьте громкость.
|
2. В настройках звука ОС выберите кабельный, USB или HDMI-выход и проверьте громкость.
|
||||||
После подключения HDMI система может переключить выход на проектор.
|
После подключения HDMI система может переключить выход на проектор.
|
||||||
3. В приложении, в блоке «Дисплей показа», выберите внешний дисплей.
|
3. В приложении, в блоке «Экран», выберите внешний дисплей.
|
||||||
Если список пуст, проверьте кабель, переходник и режим рабочего стола ОС.
|
Если список пуст, проверьте кабель, переходник и режим рабочего стола ОС.
|
||||||
4. После выбора материалов нажмите «Открыть показ». Проверьте реальное изображение:
|
4. После выбора материалов нажмите «Открыть показ». Проверьте реальное изображение:
|
||||||
на проекторе должны быть только материалы, а пульт — на компьютере.
|
на проекторе должны быть только материалы, а пульт — на компьютере.
|
||||||
Блок «Текущий показ» показывает кадр окна показа с небольшой задержкой.
|
Предпросмотр в блоке «Экран» показывает кадр окна показа с небольшой задержкой.
|
||||||
|
|
||||||
Звук воспроизводит окно показа; предпросмотр второго звука не создаёт.
|
Звук воспроизводит окно показа; предпросмотр второго звука не создаёт.
|
||||||
Приложение использует аудиовыход ОС и не выбирает устройство самостоятельно.
|
Приложение использует аудиовыход ОС и не выбирает устройство самостоятельно.
|
||||||
|
|
||||||
## Подготовка игры при доступной сети
|
## Подготовка игры при доступной сети
|
||||||
|
|
||||||
1. В «Игра и сервер» введите адрес сервера без пути, например адрес сайта
|
1. В «Сервер» введите адрес сервера без пути, например адрес сайта
|
||||||
с `https://`, email и пароль организатора, автора или администратора.
|
с `https://`, email и пароль организатора, автора или администратора.
|
||||||
Нажмите «Войти». Пароль вводите только в приложении, не отправляйте в отчёты.
|
Нажмите «Войти». Пароль вводите только в приложении, не отправляйте в отчёты.
|
||||||
2. При необходимости нажмите «Обновить список игр» и выберите игру
|
2. В «Игра» при необходимости нажмите «Обновить список игр» и выберите игру
|
||||||
в поле «Выберите игру для подготовки».
|
в поле «Выберите игру для подготовки».
|
||||||
3. Нажмите «Подключить выбранную игру». Она станет игрой показа, даже если ещё
|
3. Нажмите «Подключить выбранную игру». Она станет игрой показа, даже если ещё
|
||||||
не подготовлена на этом компьютере. Отдельно скачивать полный пакет не требуется.
|
не подготовлена на этом компьютере. Отдельно скачивать полный пакет не требуется.
|
||||||
@@ -117,7 +117,8 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
|||||||
|
|
||||||
### Вступление, видео и музыка
|
### Вступление, видео и музыка
|
||||||
|
|
||||||
В «Вступление и видео» выберите медиа; в «Музыка» — плейлист и трек.
|
Основное медиа определяется активным шагом плана экранов.
|
||||||
|
В «План музыки» выберите плейлист и трек для воспроизведения.
|
||||||
|
|
||||||
| Управление | Действие |
|
| Управление | Действие |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -125,7 +126,7 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
|||||||
| «Пауза», «Продолжить» | Сохраняет позицию и возобновляет с неё. |
|
| «Пауза», «Продолжить» | Сохраняет позицию и возобновляет с неё. |
|
||||||
| «Стоп» | Останавливает и сбрасывает позицию. |
|
| «Стоп» | Останавливает и сбрасывает позицию. |
|
||||||
| «Позиция, секунды» → «Перемотать» | Меняет позицию, сохраняя паузу или воспроизведение. |
|
| «Позиция, секунды» → «Перемотать» | Меняет позицию, сохраняя паузу или воспроизведение. |
|
||||||
| «Громкость, %» | Меняет громкость отдельного канала. Начальное значение — 35%. |
|
| «Громкость, %» | Меняет громкость отдельного канала. Начальное значение — 100%. |
|
||||||
| «Выключить весь звук» | Отключает оба канала, сохраняя их уровни громкости. |
|
| «Выключить весь звук» | Отключает оба канала, сохраняя их уровни громкости. |
|
||||||
| «Следующий трек» | Переключает музыку, сохраняя паузу или воспроизведение. |
|
| «Следующий трек» | Переключает музыку, сохраняя паузу или воспроизведение. |
|
||||||
| «Повторять плейлист» | После последнего трека возвращается к первому. |
|
| «Повторять плейлист» | После последнего трека возвращается к первому. |
|
||||||
@@ -140,6 +141,21 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
|||||||
громкость сами. Смена сцены останавливает основное медиа, музыка продолжает играть.
|
громкость сами. Смена сцены останавливает основное медиа, музыка продолжает играть.
|
||||||
Смена плейлиста останавливает музыку. После последнего трека без повтора она завершается.
|
Смена плейлиста останавливает музыку. После последнего трека без повтора она завершается.
|
||||||
|
|
||||||
|
### Редактирование музыки
|
||||||
|
|
||||||
|
В «План музыки» доступны создание, переименование и удаление плейлистов,
|
||||||
|
добавление MP3 до 64 МиБ через системный диалог и удаление треков.
|
||||||
|
Выбор плейлиста для редактирования не меняет играющую музыку.
|
||||||
|
«Использовать в игре» назначает выбранный плейлист игре в остановленном состоянии.
|
||||||
|
|
||||||
|
Переставляйте треки кнопками ↑ / ↓, затем нажмите «Сохранить порядок».
|
||||||
|
«Отменить» возвращает серверный порядок. До сохранения или отмены нельзя
|
||||||
|
добавлять и удалять треки. При перестановке текущий файл продолжает играть;
|
||||||
|
следующий трек выбирается по новому порядку. Если состав треков изменился
|
||||||
|
на сервере, отмените черновик и повторите перестановку по актуальному списку.
|
||||||
|
Используемый игрой или сценарием плейлист и его треки удалить нельзя.
|
||||||
|
Редактирование требует сети; сохранённая музыка продолжает играть без неё.
|
||||||
|
|
||||||
### Текст, таймер и результаты
|
### Текст, таймер и результаты
|
||||||
|
|
||||||
- В редакторе плана добавьте шаг «Текст», заполните текст и сохраните план.
|
- В редакторе плана добавьте шаг «Текст», заполните текст и сохраните план.
|
||||||
@@ -149,7 +165,7 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
|||||||
и «Сбросить». При смене шага отсчёт продолжается в фоне;
|
и «Сбросить». При смене шага отсчёт продолжается в фоне;
|
||||||
на нуле остаётся 00:00 без сигнала и автоматического перехода.
|
на нуле остаётся 00:00 без сигнала и автоматического перехода.
|
||||||
- После завершения игры при доступной сети нажмите «Обновить снимок результатов»
|
- После завершения игры при доступной сети нажмите «Обновить снимок результатов»
|
||||||
в «Игра и сервер» (кнопка доступна после подключения управления).
|
в «Игра» (кнопка доступна после подключения управления).
|
||||||
Покажите шаг «Результаты» и проверьте дату снимка. Офлайн показывается
|
Покажите шаг «Результаты» и проверьте дату снимка. Офлайн показывается
|
||||||
последний сохранённый снимок, а не обновляемые серверные результаты.
|
последний сохранённый снимок, а не обновляемые серверные результаты.
|
||||||
Неудачное обновление сохраняет прежний снимок.
|
Неудачное обновление сохраняет прежний снимок.
|
||||||
@@ -159,7 +175,7 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
|||||||
|
|
||||||
## Связь с сервером
|
## Связь с сервером
|
||||||
|
|
||||||
Подключите выбранную игру кнопкой в «Игра и сервер». Для ранее сохранённой игры
|
Подключите выбранную игру кнопкой в «Игра». Для ранее сохранённой игры
|
||||||
можно использовать «Подключить управление этой игрой». Проверьте название
|
можно использовать «Подключить управление этой игрой». Проверьте название
|
||||||
в строке «Игра для управления» и номер экрана. Подготовка файлов не требует
|
в строке «Игра для управления» и номер экрана. Подготовка файлов не требует
|
||||||
открытого показа; само окно автоматически не открывается.
|
открытого показа; само окно автоматически не открывается.
|
||||||
@@ -170,6 +186,11 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
|||||||
Команды локального пульта при доступной сети отправляются на сервер и относятся
|
Команды локального пульта при доступной сети отправляются на сервер и относятся
|
||||||
ко всем привязанным экранам. Выключение всего звука остаётся локальной настройкой.
|
ко всем привязанным экранам. Выключение всего звука остаётся локальной настройкой.
|
||||||
|
|
||||||
|
При явном подключении игры звук назначается этому экрану, локальный mute
|
||||||
|
выключается, основной канал и музыка устанавливаются на 100%.
|
||||||
|
Дальнейшие изменения сервера применяются как обычно; восстановление связи
|
||||||
|
без повторного подключения игры громкость не сбрасывает.
|
||||||
|
|
||||||
Назначение звука экрану меняется отдельно через сайт или «Назначить звук этому
|
Назначение звука экрану меняется отдельно через сайт или «Назначить звук этому
|
||||||
экрану». Оно отключает звук остальных экранов по правилам сервера. При отсутствии
|
экрану». Оно отключает звук остальных экранов по правилам сервера. При отсутствии
|
||||||
связи отображается последнее подтверждённое назначение.
|
связи отображается последнее подтверждённое назначение.
|
||||||
|
|||||||
@@ -259,6 +259,15 @@ export type UploadPlaylistTrackRsp = {
|
|||||||
playlist: Playlist | undefined;
|
playlist: Playlist | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReorderPlaylistTracksReq = {
|
||||||
|
playlistID: number | undefined;
|
||||||
|
trackIDs: number[] | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReorderPlaylistTracksRsp = {
|
||||||
|
playlist: Playlist | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export type DeletePlaylistTrackReq = {
|
export type DeletePlaylistTrackReq = {
|
||||||
playlistID: number | undefined;
|
playlistID: number | undefined;
|
||||||
trackID: number | undefined;
|
trackID: number | undefined;
|
||||||
@@ -697,6 +706,7 @@ export type BindScreenReq = {
|
|||||||
screenID: string | undefined;
|
screenID: string | undefined;
|
||||||
accessToken: string | undefined;
|
accessToken: string | undefined;
|
||||||
gameID: number | undefined;
|
gameID: number | undefined;
|
||||||
|
initializeAudio: boolean | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BindScreenRsp = {
|
export type BindScreenRsp = {
|
||||||
@@ -1395,6 +1405,7 @@ export interface EveningDetectiveServer {
|
|||||||
UpdatePlaylist(request: UpdatePlaylistReq): Promise<UpdatePlaylistRsp>;
|
UpdatePlaylist(request: UpdatePlaylistReq): Promise<UpdatePlaylistRsp>;
|
||||||
DeletePlaylist(request: DeletePlaylistReq): Promise<DeletePlaylistRsp>;
|
DeletePlaylist(request: DeletePlaylistReq): Promise<DeletePlaylistRsp>;
|
||||||
UploadPlaylistTrack(request: UploadPlaylistTrackReq): Promise<UploadPlaylistTrackRsp>;
|
UploadPlaylistTrack(request: UploadPlaylistTrackReq): Promise<UploadPlaylistTrackRsp>;
|
||||||
|
ReorderPlaylistTracks(request: ReorderPlaylistTracksReq): Promise<ReorderPlaylistTracksRsp>;
|
||||||
DeletePlaylistTrack(request: DeletePlaylistTrackReq): Promise<DeletePlaylistTrackRsp>;
|
DeletePlaylistTrack(request: DeletePlaylistTrackReq): Promise<DeletePlaylistTrackRsp>;
|
||||||
SetScenarioPlaylist(request: SetScenarioPlaylistReq): Promise<SetScenarioPlaylistRsp>;
|
SetScenarioPlaylist(request: SetScenarioPlaylistReq): Promise<SetScenarioPlaylistRsp>;
|
||||||
AddScenario(request: AddScenarioReq): Promise<AddScenarioRsp>;
|
AddScenario(request: AddScenarioReq): Promise<AddScenarioRsp>;
|
||||||
@@ -2119,6 +2130,26 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "UploadPlaylistTrack",
|
method: "UploadPlaylistTrack",
|
||||||
}) as Promise<UploadPlaylistTrackRsp>;
|
}) as Promise<UploadPlaylistTrackRsp>;
|
||||||
},
|
},
|
||||||
|
ReorderPlaylistTracks(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
|
if (!request.playlistID) {
|
||||||
|
throw new Error("missing required field request.playlistID");
|
||||||
|
}
|
||||||
|
const path = `api/playlists/${request.playlistID}/tracks/order`; // eslint-disable-line quotes
|
||||||
|
const body = JSON.stringify(request);
|
||||||
|
const queryParams: string[] = [];
|
||||||
|
let uri = path;
|
||||||
|
if (queryParams.length > 0) {
|
||||||
|
uri += `?${queryParams.join("&")}`
|
||||||
|
}
|
||||||
|
return handler({
|
||||||
|
path: uri,
|
||||||
|
method: "PUT",
|
||||||
|
body,
|
||||||
|
}, {
|
||||||
|
service: "EveningDetectiveServer",
|
||||||
|
method: "ReorderPlaylistTracks",
|
||||||
|
}) as Promise<ReorderPlaylistTracksRsp>;
|
||||||
|
},
|
||||||
DeletePlaylistTrack(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
DeletePlaylistTrack(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.playlistID) {
|
if (!request.playlistID) {
|
||||||
throw new Error("missing required field request.playlistID");
|
throw new Error("missing required field request.playlistID");
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ test('музыка независима от основного канала, mu
|
|||||||
assert.equal(c.state.channels.main.position, 20)
|
assert.equal(c.state.channels.main.position, 20)
|
||||||
assert.equal(c.state.channels.music.desired, 'playing')
|
assert.equal(c.state.channels.music.desired, 'playing')
|
||||||
assert.equal(c.state.channels.music.position, 15)
|
assert.equal(c.state.channels.music.position, 15)
|
||||||
assert.equal(c.state.channels.music.volume, 35)
|
assert.equal(c.state.channels.music.volume, 100)
|
||||||
send(c, { action: 'mute', enabled: false })
|
send(c, { action: 'mute', enabled: false })
|
||||||
assert.equal(c.state.channels.main.volume, 80)
|
assert.equal(c.state.channels.main.volume, 80)
|
||||||
assert.throws(
|
assert.throws(
|
||||||
@@ -237,3 +237,23 @@ test('Range: полный файл, границы, суффикс и отказ
|
|||||||
for (const value of ['bytes=100-', 'bytes=20-10', 'bytes=-0', 'bytes=0-1,3-5', 'bytes=-', 'abc'])
|
for (const value of ['bytes=100-', 'bytes=20-10', 'bytes=-0', 'bytes=0-1,3-5', 'bytes=-', 'abc'])
|
||||||
assert.equal(parseRange(value, 100), null)
|
assert.equal(parseRange(value, 100), null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('обновление порядка сохраняет текущий файл и операцию, следующий трек берётся из нового порядка', () => {
|
||||||
|
const c = controller()
|
||||||
|
const pkg = testPackage()
|
||||||
|
c.selectPackage(pkg)
|
||||||
|
send(c, { action: 'play', channel: 'music' })
|
||||||
|
ack(c, 'music', 'playing', 23)
|
||||||
|
const before = structuredClone(c.state.channels.music)
|
||||||
|
pkg.package.playlists[0]!.tracks.reverse()
|
||||||
|
c.selectPackage(pkg, true)
|
||||||
|
const after = c.state.channels.music
|
||||||
|
assert.equal(after.trackIndex, 1)
|
||||||
|
assert.equal(after.source?.url, before.source?.url)
|
||||||
|
assert.equal(after.operation, before.operation)
|
||||||
|
assert.equal(after.position, 23)
|
||||||
|
assert.equal(after.desired, 'playing')
|
||||||
|
send(c, { action: 'loop', channel: 'music', enabled: true })
|
||||||
|
send(c, { action: 'next', channel: 'music' })
|
||||||
|
assert.equal(c.state.channels.music.trackIndex, 0)
|
||||||
|
})
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ function initialChannel(): ChannelState {
|
|||||||
status: 'idle',
|
status: 'idle',
|
||||||
position: 0,
|
position: 0,
|
||||||
duration: null,
|
duration: null,
|
||||||
volume: 35,
|
volume: 100,
|
||||||
error: '',
|
error: '',
|
||||||
playlistId: null,
|
playlistId: null,
|
||||||
trackIndex: 0,
|
trackIndex: 0,
|
||||||
@@ -268,6 +268,10 @@ export class Controller {
|
|||||||
if (preserve && catalog.key === this.state.catalog.key) {
|
if (preserve && catalog.key === this.state.catalog.key) {
|
||||||
catalog.cover = this.state.catalog.cover
|
catalog.cover = this.state.catalog.cover
|
||||||
catalog.background = this.state.catalog.background
|
catalog.background = this.state.catalog.background
|
||||||
|
const music = this.state.channels.music
|
||||||
|
const tracks = catalog.playlists.find((p) => p.id === music.playlistId)?.tracks
|
||||||
|
const index = tracks?.findIndex((t) => t.source.url === music.source?.url) ?? -1
|
||||||
|
if (index >= 0) music.trackIndex = index
|
||||||
this.state.catalog = catalog
|
this.state.catalog = catalog
|
||||||
this.broadcast()
|
this.broadcast()
|
||||||
return
|
return
|
||||||
|
|||||||
+15
-2
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
|
dialog,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
ipcMain,
|
ipcMain,
|
||||||
protocol,
|
protocol,
|
||||||
@@ -10,8 +11,8 @@ import {
|
|||||||
powerSaveBlocker,
|
powerSaveBlocker,
|
||||||
} from 'electron'
|
} from 'electron'
|
||||||
import { createReadStream } from 'node:fs'
|
import { createReadStream } from 'node:fs'
|
||||||
import { stat } from 'node:fs/promises'
|
import { readFile, stat } from 'node:fs/promises'
|
||||||
import { join } from 'node:path'
|
import { basename, join } from 'node:path'
|
||||||
import { Readable } from 'node:stream'
|
import { Readable } from 'node:stream'
|
||||||
import { DiagnosticLog } from './diagnostics.js'
|
import { DiagnosticLog } from './diagnostics.js'
|
||||||
import { Controller, parseCommand } from './controller.js'
|
import { Controller, parseCommand } from './controller.js'
|
||||||
@@ -304,6 +305,18 @@ app
|
|||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
(failure) => diagnostics?.record('request-error', failure),
|
(failure) => diagnostics?.record('request-error', failure),
|
||||||
|
async () => {
|
||||||
|
const result = await dialog.showOpenDialog({
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [{ name: 'MP3', extensions: ['mp3'] }],
|
||||||
|
})
|
||||||
|
const path = result.filePaths[0]
|
||||||
|
if (result.canceled || !path) return null
|
||||||
|
const info = await stat(path)
|
||||||
|
if (!info.isFile() || !/\.mp3$/i.test(path) || !info.size || info.size > 64 * 1048576)
|
||||||
|
throw new Error('Выберите MP3 размером до 64 МиБ')
|
||||||
|
return { filename: basename(path), data: await readFile(path) }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
await online.initialize()
|
await online.initialize()
|
||||||
let previousConnection = online.state.connection
|
let previousConnection = online.state.connection
|
||||||
|
|||||||
+120
-4
@@ -34,6 +34,19 @@ async function fixture(t: TestContext, secure = true) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
const model = {
|
const model = {
|
||||||
|
library: [
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
name: 'Музыка',
|
||||||
|
tracks: [
|
||||||
|
{ ID: 1, name: 'Один' },
|
||||||
|
{ ID: 2, name: 'Два' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
playlistError: 0,
|
||||||
|
upload: null as { filename: string; data: Buffer } | null,
|
||||||
|
bindings: [] as Record<string, unknown>[],
|
||||||
offline: false,
|
offline: false,
|
||||||
server: 'https://server.test',
|
server: 'https://server.test',
|
||||||
introURL: 'https://media.test/intro.wav',
|
introURL: 'https://media.test/intro.wav',
|
||||||
@@ -233,7 +246,34 @@ async function fixture(t: TestContext, secure = true) {
|
|||||||
}
|
}
|
||||||
return json({})
|
return json({})
|
||||||
}
|
}
|
||||||
if (path.endsWith('/bind') || path.endsWith('/sound')) return json({})
|
if (path.endsWith('/bind')) {
|
||||||
|
model.bindings.push(JSON.parse(String(init?.body)))
|
||||||
|
return json({})
|
||||||
|
}
|
||||||
|
if (path.endsWith('/sound')) return json({})
|
||||||
|
if (path.startsWith('/api/playlists')) {
|
||||||
|
if (model.playlistError) return json({}, model.playlistError)
|
||||||
|
const data = init?.body ? JSON.parse(String(init.body)) : {}
|
||||||
|
if (path === '/api/playlists') {
|
||||||
|
if (init?.method === 'POST') model.library.push({ ID: 2, name: data.name, tracks: [] })
|
||||||
|
return json({ playlists: model.library })
|
||||||
|
}
|
||||||
|
if (path === '/api/playlists/1' && init?.method === 'PUT') model.library[0]!.name = data.name
|
||||||
|
if (path === '/api/playlists/2' && init?.method === 'DELETE') model.library.pop()
|
||||||
|
if (path === '/api/playlists/1/tracks/order') {
|
||||||
|
const tracks = model.library[0]!.tracks
|
||||||
|
model.library[0]!.tracks = data.trackIDs.map((id: number) =>
|
||||||
|
tracks.find((t) => t.ID === id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (path === '/api/playlists/1/tracks' && init?.method === 'POST') {
|
||||||
|
assert.equal(Buffer.from(data.data, 'base64').toString(), 'ID3test')
|
||||||
|
model.library[0]!.tracks.push({ ID: 3, name: data.name })
|
||||||
|
}
|
||||||
|
if (path === '/api/playlists/1/tracks/3' && init?.method === 'DELETE')
|
||||||
|
model.library[0]!.tracks.pop()
|
||||||
|
return json({})
|
||||||
|
}
|
||||||
throw new Error(`Unexpected request ${path}`)
|
throw new Error(`Unexpected request ${path}`)
|
||||||
}
|
}
|
||||||
const store = new PackageStore(join(directory, 'packages'), {
|
const store = new PackageStore(join(directory, 'packages'), {
|
||||||
@@ -243,7 +283,15 @@ async function fixture(t: TestContext, secure = true) {
|
|||||||
})
|
})
|
||||||
await store.initialize()
|
await store.initialize()
|
||||||
const controller = new Controller(() => {})
|
const controller = new Controller(() => {})
|
||||||
const online = new Online(join(directory, 'connection'), store, controller, secrets, fetchAPI)
|
const online = new Online(
|
||||||
|
join(directory, 'connection'),
|
||||||
|
store,
|
||||||
|
controller,
|
||||||
|
secrets,
|
||||||
|
fetchAPI,
|
||||||
|
undefined,
|
||||||
|
async () => model.upload,
|
||||||
|
)
|
||||||
await online.initialize()
|
await online.initialize()
|
||||||
shutdown = () => online.close()
|
shutdown = () => online.close()
|
||||||
const login = () =>
|
const login = () =>
|
||||||
@@ -721,7 +769,7 @@ test('вход без прав управления не сохраняет то
|
|||||||
test('назначение звука подтверждается сервером отдельно от локального mute и сбрасывается с сессией', async (t) => {
|
test('назначение звука подтверждается сервером отдельно от локального mute и сбрасывается с сессией', async (t) => {
|
||||||
const f = await fixture(t)
|
const f = await fixture(t)
|
||||||
await f.prepare()
|
await f.prepare()
|
||||||
assert.equal(f.online.state.soundEnabled, null)
|
assert.equal(f.online.state.soundEnabled, true)
|
||||||
f.controller.output(true)
|
f.controller.output(true)
|
||||||
f.model.remote.soundEnabled = false
|
f.model.remote.soundEnabled = false
|
||||||
await f.online.poll()
|
await f.online.poll()
|
||||||
@@ -743,7 +791,7 @@ test('назначение звука подтверждается сервер
|
|||||||
await f.online.poll()
|
await f.online.poll()
|
||||||
assert.equal(f.online.state.soundEnabled, false)
|
assert.equal(f.online.state.soundEnabled, false)
|
||||||
await f.online.act({ action: 'connect' })
|
await f.online.act({ action: 'connect' })
|
||||||
assert.equal(f.online.state.soundEnabled, null)
|
assert.equal(f.online.state.soundEnabled, true)
|
||||||
await f.online.poll()
|
await f.online.poll()
|
||||||
await f.online.act({ action: 'disconnect' })
|
await f.online.act({ action: 'disconnect' })
|
||||||
assert.equal(f.online.state.soundEnabled, null)
|
assert.equal(f.online.state.soundEnabled, null)
|
||||||
@@ -1332,3 +1380,71 @@ test('без истории и медиа текст, таймер и музык
|
|||||||
assert.equal(f.online.snapshot().connection, 'connected')
|
assert.equal(f.online.snapshot().connection, 'connected')
|
||||||
assert.equal(f.online.snapshot().preparation.error, '')
|
assert.equal(f.online.snapshot().preparation.error, '')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('явное подключение включает оба канала на 100%, дальнейшие команды и reconnect сохраняют власть сервера', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.prepare()
|
||||||
|
assert.equal(f.model.bindings[0]!.initializeAudio, true)
|
||||||
|
assert.equal(f.controller.state.muted, false)
|
||||||
|
assert.equal(f.controller.state.channels.main.volume, 100)
|
||||||
|
assert.equal(f.controller.state.channels.music.volume, 100)
|
||||||
|
f.controller.output(true)
|
||||||
|
f.model.remote.volume = 27
|
||||||
|
f.model.remote.soundEnabled = false
|
||||||
|
await f.online.poll()
|
||||||
|
assert.equal(f.controller.state.channels.main.volume, 27)
|
||||||
|
assert.equal(f.controller.state.muted, true)
|
||||||
|
f.model.offline = true
|
||||||
|
await f.online.poll()
|
||||||
|
f.model.offline = false
|
||||||
|
await f.online.poll()
|
||||||
|
assert.equal(f.model.bindings.length, 1)
|
||||||
|
assert.equal(f.controller.state.channels.main.volume, 27)
|
||||||
|
assert.equal(f.controller.state.muted, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('редактор музыки: CRUD, native MP3, порядок, валидация и конфликт не меняют текущий канал', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.login()
|
||||||
|
await f.online.act({ action: 'playlists' })
|
||||||
|
assert.equal(f.online.snapshot().playlists[0]!.tracks.length, 2)
|
||||||
|
const before = structuredClone(f.controller.state.channels.music)
|
||||||
|
await f.online.act({ action: 'playlist-create', name: 'Новый' })
|
||||||
|
assert.equal(f.online.state.playlists[1]!.name, 'Новый')
|
||||||
|
await f.online.act({ action: 'playlist-rename', playlistId: 1, name: 'Новое имя' })
|
||||||
|
await f.online.act({ action: 'playlist-reorder', playlistId: 1, trackIds: [2, 1] })
|
||||||
|
assert.deepEqual(
|
||||||
|
f.online.state.playlists[0]!.tracks.map((t) => t.id),
|
||||||
|
[2, 1],
|
||||||
|
)
|
||||||
|
assert.deepEqual(f.controller.state.channels.music, before)
|
||||||
|
await assert.rejects(
|
||||||
|
f.online.act({ action: 'playlist-reorder', playlistId: 1, trackIds: [1, 1] }),
|
||||||
|
/повторяться/,
|
||||||
|
)
|
||||||
|
await assert.rejects(
|
||||||
|
f.online.act({ action: 'playlist-upload', playlistId: '../etc/passwd' }),
|
||||||
|
/число|Некоррект/,
|
||||||
|
)
|
||||||
|
f.model.upload = { filename: 'wrong.wav', data: Buffer.from('ID3test') }
|
||||||
|
await assert.rejects(f.online.act({ action: 'playlist-upload', playlistId: 1 }), /MP3/)
|
||||||
|
f.model.upload = { filename: 'large.mp3', data: Buffer.alloc(64 * 1048576 + 1) }
|
||||||
|
await assert.rejects(f.online.act({ action: 'playlist-upload', playlistId: 1 }), /64 МиБ/)
|
||||||
|
f.model.upload = { filename: 'test.mp3', data: Buffer.from('ID3test') }
|
||||||
|
await f.online.act({ action: 'playlist-upload', playlistId: 1 })
|
||||||
|
assert.equal(f.online.state.playlists[0]!.tracks.length, 3)
|
||||||
|
await f.online.act({ action: 'playlist-track-delete', playlistId: 1, trackId: 3 })
|
||||||
|
await f.online.act({ action: 'playlist-delete', playlistId: 2 })
|
||||||
|
assert.equal(f.online.state.playlists.length, 1)
|
||||||
|
f.model.playlistError = 409
|
||||||
|
await assert.rejects(
|
||||||
|
f.online.act({ action: 'playlist-reorder', playlistId: 1, trackIds: [1, 2] }),
|
||||||
|
/Состав треков изменился/,
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
f.online.state.playlists[0]!.tracks.map((t) => t.id),
|
||||||
|
[2, 1],
|
||||||
|
)
|
||||||
|
f.model.playlistError = 403
|
||||||
|
await assert.rejects(f.online.act({ action: 'playlist-create', name: 'Нет прав' }), /Нет доступа/)
|
||||||
|
})
|
||||||
|
|||||||
+110
-3
@@ -72,6 +72,7 @@ export class Online {
|
|||||||
busy: false,
|
busy: false,
|
||||||
canCancel: false,
|
canCancel: false,
|
||||||
games: [],
|
games: [],
|
||||||
|
playlists: [],
|
||||||
screenId: null,
|
screenId: null,
|
||||||
soundEnabled: null,
|
soundEnabled: null,
|
||||||
connection: 'disabled',
|
connection: 'disabled',
|
||||||
@@ -336,7 +337,8 @@ export class Online {
|
|||||||
if (
|
if (
|
||||||
music &&
|
music &&
|
||||||
old &&
|
old &&
|
||||||
JSON.stringify(old.music?.filenames) !== JSON.stringify(next.music?.filenames)
|
old.music?.trackId === next.music?.trackId &&
|
||||||
|
!this.musicSourceMatches(next)
|
||||||
)
|
)
|
||||||
base.music = null
|
base.music = null
|
||||||
if (!music && old && JSON.stringify(old.resources) !== JSON.stringify(next.resources))
|
if (!music && old && JSON.stringify(old.resources) !== JSON.stringify(next.resources))
|
||||||
@@ -813,6 +815,8 @@ export class Online {
|
|||||||
private onRequestFailure: NonNullable<
|
private onRequestFailure: NonNullable<
|
||||||
import('./screen-package.js').PackageRequest['onRequestFailure']
|
import('./screen-package.js').PackageRequest['onRequestFailure']
|
||||||
> = () => {},
|
> = () => {},
|
||||||
|
private chooseTrack: () => Promise<{ filename: string; data: Buffer } | null> = async () =>
|
||||||
|
null,
|
||||||
) {
|
) {
|
||||||
controller.onMusicEnded = (report) => {
|
controller.onMusicEnded = (report) => {
|
||||||
if (!this.screen) return false
|
if (!this.screen) return false
|
||||||
@@ -975,7 +979,9 @@ export class Online {
|
|||||||
}
|
}
|
||||||
private async raw(server: string, path: string, init: RequestInit = {}): Promise<Response> {
|
private async raw(server: string, path: string, init: RequestInit = {}): Promise<Response> {
|
||||||
const signal = AbortSignal.any([
|
const signal = AbortSignal.any([
|
||||||
AbortSignal.timeout(30_000),
|
AbortSignal.timeout(
|
||||||
|
init.method === 'POST' && /^\/api\/playlists\/\d+\/tracks$/.test(path) ? 120_000 : 30_000,
|
||||||
|
),
|
||||||
...(init.signal ? [init.signal] : []),
|
...(init.signal ? [init.signal] : []),
|
||||||
])
|
])
|
||||||
try {
|
try {
|
||||||
@@ -1072,6 +1078,12 @@ export class Online {
|
|||||||
parsePlan(result.plan)
|
parsePlan(result.plan)
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof ApiError &&
|
||||||
|
error.status === 400 &&
|
||||||
|
['DeletePlaylist', 'DeletePlaylistTrack'].includes(meta.method)
|
||||||
|
)
|
||||||
|
error.message = 'Плейлист используется игрой или сценарием; удаление недоступно'
|
||||||
throw requestErrorContext(error, meta.method, request.method, path)
|
throw requestErrorContext(error, meta.method, request.method, path)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1101,6 +1113,16 @@ export class Online {
|
|||||||
private async perform(data: Record<string, unknown>) {
|
private async perform(data: Record<string, unknown>) {
|
||||||
const action = data.action as OnlineIntent['action']
|
const action = data.action as OnlineIntent['action']
|
||||||
switch (action) {
|
switch (action) {
|
||||||
|
case 'playlists':
|
||||||
|
return this.loadPlaylists()
|
||||||
|
case 'playlist-create':
|
||||||
|
case 'playlist-rename':
|
||||||
|
case 'playlist-delete':
|
||||||
|
case 'playlist-upload':
|
||||||
|
case 'playlist-track-delete':
|
||||||
|
case 'playlist-reorder':
|
||||||
|
case 'playlist-use':
|
||||||
|
return this.editPlaylist(data)
|
||||||
case 'plan-save':
|
case 'plan-save':
|
||||||
return this.savePlan(data)
|
return this.savePlan(data)
|
||||||
case 'plan-refresh':
|
case 'plan-refresh':
|
||||||
@@ -1115,6 +1137,7 @@ export class Online {
|
|||||||
this.credentials = null
|
this.credentials = null
|
||||||
this.state.authenticated = false
|
this.state.authenticated = false
|
||||||
this.state.games = []
|
this.state.games = []
|
||||||
|
this.state.playlists = []
|
||||||
await this.persistSecrets()
|
await this.persistSecrets()
|
||||||
this.state.server = server
|
this.state.server = server
|
||||||
const result = await this.client(false, undefined, server).Login({ email, password })
|
const result = await this.client(false, undefined, server).Login({ email, password })
|
||||||
@@ -1149,6 +1172,7 @@ export class Online {
|
|||||||
this.credentials = null
|
this.credentials = null
|
||||||
this.state.authenticated = false
|
this.state.authenticated = false
|
||||||
this.state.games = []
|
this.state.games = []
|
||||||
|
this.state.playlists = []
|
||||||
await this.persistSecrets()
|
await this.persistSecrets()
|
||||||
return
|
return
|
||||||
case 'games':
|
case 'games':
|
||||||
@@ -1253,7 +1277,16 @@ export class Online {
|
|||||||
token: token(result.accessToken),
|
token: token(result.accessToken),
|
||||||
gameId,
|
gameId,
|
||||||
}
|
}
|
||||||
await this.client().BindScreen({ screenID: s.id, accessToken: s.token, gameID: s.gameId })
|
await this.client().BindScreen({
|
||||||
|
screenID: s.id,
|
||||||
|
accessToken: s.token,
|
||||||
|
gameID: s.gameId,
|
||||||
|
initializeAudio: true,
|
||||||
|
})
|
||||||
|
send(this.controller, { action: 'mute', enabled: false })
|
||||||
|
send(this.controller, { action: 'volume', channel: 'main', volume: 100 })
|
||||||
|
send(this.controller, { action: 'volume', channel: 'music', volume: 100 })
|
||||||
|
this.state.soundEnabled = true
|
||||||
this.screen = s
|
this.screen = s
|
||||||
this.state.screenId = s.id
|
this.state.screenId = s.id
|
||||||
this.state.connection = 'waiting'
|
this.state.connection = 'waiting'
|
||||||
@@ -1277,6 +1310,80 @@ export class Online {
|
|||||||
throw new Error('Неизвестное действие')
|
throw new Error('Неизвестное действие')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private async loadPlaylists() {
|
||||||
|
const result = await this.client().GetPlaylists({})
|
||||||
|
if (!Array.isArray(result.playlists ?? [])) throw new Error('Некорректный список плейлистов')
|
||||||
|
this.state.playlists = (result.playlists ?? []).map((value) => {
|
||||||
|
const p = object(value)
|
||||||
|
if (!Array.isArray(p.tracks ?? [])) throw new Error('Некорректный список треков')
|
||||||
|
return {
|
||||||
|
id: integer(p.ID, 1),
|
||||||
|
name: text(p.name),
|
||||||
|
tracks: ((p.tracks ?? []) as unknown[]).map((value) => {
|
||||||
|
const t = object(value)
|
||||||
|
return { id: integer(t.ID, 1), name: text(t.name) }
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
private async editPlaylist(data: Record<string, unknown>) {
|
||||||
|
if (!this.credentials) throw new ApiError(401)
|
||||||
|
const generation = this.generation
|
||||||
|
const id = data.action === 'playlist-create' ? 0 : integer(data.playlistId, 1, 2147483647)
|
||||||
|
const client = this.client()
|
||||||
|
if (data.action === 'playlist-create' || data.action === 'playlist-rename') {
|
||||||
|
const name = text(data.name).trim()
|
||||||
|
if (!name || Buffer.byteLength(name) > 255) throw new Error('Имя плейлиста: от 1 до 255 байт')
|
||||||
|
if (data.action === 'playlist-create') await client.CreatePlaylist({ name })
|
||||||
|
else await client.UpdatePlaylist({ ID: id, name })
|
||||||
|
} else if (data.action === 'playlist-delete') await client.DeletePlaylist({ ID: id })
|
||||||
|
else if (data.action === 'playlist-track-delete')
|
||||||
|
await client.DeletePlaylistTrack({
|
||||||
|
playlistID: id,
|
||||||
|
trackID: integer(data.trackId, 1, 2147483647),
|
||||||
|
})
|
||||||
|
else if (data.action === 'playlist-reorder') {
|
||||||
|
if (!Array.isArray(data.trackIds)) throw new Error('Некорректный порядок треков')
|
||||||
|
const ids = data.trackIds.map((value) => integer(value, 1, 2147483647))
|
||||||
|
if (new Set(ids).size !== ids.length) throw new Error('Треки не должны повторяться')
|
||||||
|
try {
|
||||||
|
await client.ReorderPlaylistTracks({ playlistID: id, trackIDs: ids })
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.status === 409)
|
||||||
|
throw new Error(
|
||||||
|
'Состав треков изменился на сервере. Обновите список и повторите перестановку',
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
} else if (data.action === 'playlist-upload') {
|
||||||
|
const file = await this.chooseTrack()
|
||||||
|
if (generation !== this.generation) throw new SessionChangedError()
|
||||||
|
if (!file) return
|
||||||
|
if (!/\.mp3$/i.test(file.filename) || !file.data.length || file.data.length > 64 * 1048576)
|
||||||
|
throw new Error('Выберите MP3 размером до 64 МиБ')
|
||||||
|
await client.UploadPlaylistTrack({
|
||||||
|
playlistID: id,
|
||||||
|
name: file.filename,
|
||||||
|
filename: file.filename,
|
||||||
|
data: file.data.toString('base64'),
|
||||||
|
})
|
||||||
|
} else if (data.action === 'playlist-use') {
|
||||||
|
const pkg = this.planGame()
|
||||||
|
await client.SetGameMusic({
|
||||||
|
gameID: pkg.package.game.id,
|
||||||
|
playlistID: id,
|
||||||
|
trackIndex: 0,
|
||||||
|
positionMs: 0,
|
||||||
|
volume: this.controller.state.channels.music.volume,
|
||||||
|
loop: this.controller.state.channels.music.loop,
|
||||||
|
playback: 'MUSIC_PLAYBACK_STOPPED',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await this.loadPlaylists()
|
||||||
|
this.lastMaterials = -Infinity
|
||||||
|
await this.refreshMaterials()
|
||||||
|
await this.poll()
|
||||||
|
}
|
||||||
private async loadGames() {
|
private async loadGames() {
|
||||||
const result = await this.client().GetGames({})
|
const result = await this.client().GetGames({})
|
||||||
if (!Array.isArray(result.games ?? [])) throw new Error('Некорректный список игр')
|
if (!Array.isArray(result.games ?? [])) throw new Error('Некорректный список игр')
|
||||||
|
|||||||
@@ -122,6 +122,13 @@ export function requestErrorContext(
|
|||||||
AdvanceScreenMusic: 'Следующий трек',
|
AdvanceScreenMusic: 'Следующий трек',
|
||||||
Login: 'Вход',
|
Login: 'Вход',
|
||||||
Refresh: 'Обновление сессии',
|
Refresh: 'Обновление сессии',
|
||||||
|
GetPlaylists: 'Список плейлистов',
|
||||||
|
CreatePlaylist: 'Создание плейлиста',
|
||||||
|
UpdatePlaylist: 'Название плейлиста',
|
||||||
|
DeletePlaylist: 'Удаление плейлиста',
|
||||||
|
UploadPlaylistTrack: 'Загрузка MP3',
|
||||||
|
DeletePlaylistTrack: 'Удаление трека',
|
||||||
|
ReorderPlaylistTracks: 'Порядок треков',
|
||||||
GetMe: 'Данные пользователя',
|
GetMe: 'Данные пользователя',
|
||||||
GetGames: 'Список игр',
|
GetGames: 'Список игр',
|
||||||
CreateScreenSession: 'Создание экрана',
|
CreateScreenSession: 'Создание экрана',
|
||||||
|
|||||||
+204
-4
@@ -1,6 +1,8 @@
|
|||||||
const assert = require('node:assert/strict')
|
const assert = require('node:assert/strict')
|
||||||
const { createServer } = require('node:http')
|
const { createServer } = require('node:http')
|
||||||
const { readFileSync } = require('node:fs')
|
const { readFileSync, writeFileSync, mkdtempSync, rmSync } = require('node:fs')
|
||||||
|
const { tmpdir } = require('node:os')
|
||||||
|
const { dialog } = require('electron')
|
||||||
const { join } = require('node:path')
|
const { join } = require('node:path')
|
||||||
|
|
||||||
module.exports = async function onlineSmoke({
|
module.exports = async function onlineSmoke({
|
||||||
@@ -31,6 +33,17 @@ module.exports = async function onlineSmoke({
|
|||||||
let timerLabel = 'Перерыв'
|
let timerLabel = 'Перерыв'
|
||||||
let timerDurationSeconds = 600
|
let timerDurationSeconds = 600
|
||||||
let timerState = 1
|
let timerState = 1
|
||||||
|
const library = [
|
||||||
|
{
|
||||||
|
ID: 9,
|
||||||
|
name: 'Редактор музыки',
|
||||||
|
tracks: [
|
||||||
|
{ ID: 91, name: 'Раз', filename: 'music-a.wav', position: 0 },
|
||||||
|
{ ID: 92, name: 'Два', filename: 'music-b.wav', position: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
let nextPlaylistId = 10
|
||||||
const requests = []
|
const requests = []
|
||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
const chunks = []
|
const chunks = []
|
||||||
@@ -102,8 +115,58 @@ module.exports = async function onlineSmoke({
|
|||||||
version++
|
version++
|
||||||
return send({})
|
return send({})
|
||||||
}
|
}
|
||||||
if (path === '/api/games/6/music')
|
if (path === '/api/playlists') {
|
||||||
return send(music ? { state: music, gamePlaylistId: 1, playlists: [music.playlist] } : {})
|
if (req.method === 'POST') library.push({ ID: nextPlaylistId++, name: body.name, tracks: [] })
|
||||||
|
return send({ playlists: library })
|
||||||
|
}
|
||||||
|
const playlistMatch = path.match(/^\/api\/playlists\/(\d+)(?:\/tracks(?:\/(\d+|order))?)?$/)
|
||||||
|
if (playlistMatch) {
|
||||||
|
const item = library.find((p) => p.ID === Number(playlistMatch[1]))
|
||||||
|
if (!item) return send({}, 404)
|
||||||
|
if (path.endsWith('/order')) {
|
||||||
|
item.tracks = body.trackIDs.map((id, position) => ({
|
||||||
|
...item.tracks.find((t) => t.ID === id),
|
||||||
|
position,
|
||||||
|
}))
|
||||||
|
if (music?.playlist.ID === item.ID) {
|
||||||
|
const trackID = music.playlist.tracks[music.trackIndex].ID
|
||||||
|
music = {
|
||||||
|
...music,
|
||||||
|
stateVersion: String(Number(music.stateVersion) + 1),
|
||||||
|
playlist: structuredClone(item),
|
||||||
|
trackIndex: item.tracks.findIndex((t) => t.ID === trackID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (path.endsWith('/tracks') && req.method === 'POST') {
|
||||||
|
assert.equal(body.filename, 'native.mp3')
|
||||||
|
assert.equal(Buffer.from(body.data, 'base64').toString(), 'ID3test')
|
||||||
|
item.tracks.push({
|
||||||
|
ID: 93,
|
||||||
|
name: body.name,
|
||||||
|
filename: 'native.mp3',
|
||||||
|
position: item.tracks.length,
|
||||||
|
})
|
||||||
|
} else if (playlistMatch[2] && req.method === 'DELETE')
|
||||||
|
item.tracks = item.tracks.filter((t) => t.ID !== Number(playlistMatch[2]))
|
||||||
|
else if (req.method === 'PUT') item.name = body.name
|
||||||
|
else if (req.method === 'DELETE') library.splice(library.indexOf(item), 1)
|
||||||
|
return send({ playlist: item })
|
||||||
|
}
|
||||||
|
if (path === '/api/games/6/music') {
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const item = library.find((p) => p.ID === body.playlistID)
|
||||||
|
music = {
|
||||||
|
...body,
|
||||||
|
stateVersion: String(Number(music?.stateVersion || 0) + 1),
|
||||||
|
playlist: structuredClone(item),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return send(
|
||||||
|
music
|
||||||
|
? { state: music, gamePlaylistId: music.playlist.ID, playlists: [music.playlist] }
|
||||||
|
: {},
|
||||||
|
)
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
['/api/files/intro.wav', '/api/files/music-a.wav', '/api/files/music-b.wav'].includes(path)
|
['/api/files/intro.wav', '/api/files/music-a.wav', '/api/files/music-b.wav'].includes(path)
|
||||||
) {
|
) {
|
||||||
@@ -118,7 +181,10 @@ module.exports = async function onlineSmoke({
|
|||||||
return res.end(readFileSync(join(root, 'fixtures/audio.wav')))
|
return res.end(readFileSync(join(root, 'fixtures/audio.wav')))
|
||||||
}
|
}
|
||||||
if (path === '/api/screens') return send({ screenID: '67', accessToken: 'TEST-SCREEN' })
|
if (path === '/api/screens') return send({ screenID: '67', accessToken: 'TEST-SCREEN' })
|
||||||
if (path === '/api/screens/67/bind') return send({})
|
if (path === '/api/screens/67/bind') {
|
||||||
|
assert.equal(body.initializeAudio, true)
|
||||||
|
return send({})
|
||||||
|
}
|
||||||
if (path === '/api/screens/state') {
|
if (path === '/api/screens/state') {
|
||||||
polls++
|
polls++
|
||||||
assert.equal(req.headers.authorization, undefined)
|
assert.equal(req.headers.authorization, undefined)
|
||||||
@@ -188,6 +254,19 @@ module.exports = async function onlineSmoke({
|
|||||||
),
|
),
|
||||||
'server game selector',
|
'server game selector',
|
||||||
)
|
)
|
||||||
|
await poll(
|
||||||
|
() =>
|
||||||
|
evaluate(
|
||||||
|
`document.querySelectorAll('main.control > .n-card').length === 4 && document.querySelectorAll('.n-card-header__main').length === 5`,
|
||||||
|
),
|
||||||
|
'five control cards',
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
await evaluate(
|
||||||
|
`Array.from(document.querySelectorAll('.n-card-header__main')).map(e => e.textContent.trim())`,
|
||||||
|
),
|
||||||
|
['Сервер', 'Игра', 'Экран', 'План экранов', 'План музыки'],
|
||||||
|
)
|
||||||
varyGames = true
|
varyGames = true
|
||||||
for (let refresh = 0; refresh < 5; refresh++) {
|
for (let refresh = 0; refresh < 5; refresh++) {
|
||||||
await poll(
|
await poll(
|
||||||
@@ -541,6 +620,127 @@ module.exports = async function onlineSmoke({
|
|||||||
await clickPlan('Сохранить план')
|
await clickPlan('Сохранить план')
|
||||||
await poll(async () => (await state()).catalog.steps.length === 0, 'plan deleted')
|
await poll(async () => (await state()).catalog.steps.length === 0, 'plan deleted')
|
||||||
console.log('SCREEN_PLAN_SMOKE_PASS')
|
console.log('SCREEN_PLAN_SMOKE_PASS')
|
||||||
|
const clickMusic = async (label) =>
|
||||||
|
poll(
|
||||||
|
() =>
|
||||||
|
evaluate(`(() => {
|
||||||
|
const button = Array.from(document.querySelectorAll('[data-testid="music-plan-editor"] button')).find(b => b.textContent.trim() === ${JSON.stringify(label)})
|
||||||
|
if (!button || button.disabled) return false
|
||||||
|
button.click(); return true
|
||||||
|
})()`),
|
||||||
|
`music button ${label}`,
|
||||||
|
)
|
||||||
|
const choosePlaylist = async (name) => {
|
||||||
|
await poll(
|
||||||
|
() =>
|
||||||
|
evaluate(
|
||||||
|
`(() => { const select = document.querySelector('[data-testid="music-plan-editor"] .n-base-selection'); return select && !select.classList.contains('n-base-selection--disabled') })()`,
|
||||||
|
),
|
||||||
|
'library selector ready',
|
||||||
|
)
|
||||||
|
await evaluate(
|
||||||
|
`document.querySelector('[data-testid="music-plan-editor"] .n-base-selection').click()`,
|
||||||
|
)
|
||||||
|
await poll(
|
||||||
|
() =>
|
||||||
|
evaluate(
|
||||||
|
`Array.from(document.querySelectorAll('.n-base-select-option')).some(e => e.textContent.trim() === ${JSON.stringify(name)})`,
|
||||||
|
),
|
||||||
|
'library option',
|
||||||
|
)
|
||||||
|
await evaluate(
|
||||||
|
`Array.from(document.querySelectorAll('.n-base-select-option')).find(e => e.textContent.trim() === ${JSON.stringify(name)}).click()`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const confirmDelete = async (title) => {
|
||||||
|
await poll(
|
||||||
|
() =>
|
||||||
|
evaluate(`(() => {
|
||||||
|
const dialog = Array.from(document.querySelectorAll('.n-dialog')).find(d => d.textContent.includes(${JSON.stringify(title)}))
|
||||||
|
const button = dialog && Array.from(dialog.querySelectorAll('button')).find(b => b.textContent.trim() === 'Удалить')
|
||||||
|
if (!button || button.disabled) return false
|
||||||
|
button.click(); return true
|
||||||
|
})()`),
|
||||||
|
'delete confirmation ' + title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await clickMusic('Обновить плейлисты')
|
||||||
|
await setInput('#new-playlist-name', 'Создан из приложения')
|
||||||
|
await clickMusic('Создать плейлист')
|
||||||
|
await poll(() => library.some((p) => p.name === 'Создан из приложения'), 'playlist created')
|
||||||
|
await poll(
|
||||||
|
() => evaluate(`document.querySelector('#playlist-name')?.value === 'Создан из приложения'`),
|
||||||
|
'new playlist selected',
|
||||||
|
)
|
||||||
|
await setInput('#playlist-name', 'Переименован')
|
||||||
|
// Повторный snapshot не затирает ввод названия.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 650))
|
||||||
|
assert.equal(await evaluate(`document.querySelector('#playlist-name').value`), 'Переименован')
|
||||||
|
await clickMusic('Сохранить название')
|
||||||
|
await poll(() => library.some((p) => p.name === 'Переименован'), 'playlist renamed')
|
||||||
|
const uploadDirectory = mkdtempSync(join(tmpdir(), 'screen-mp3-smoke-'))
|
||||||
|
const originalDialog = dialog.showOpenDialog
|
||||||
|
try {
|
||||||
|
const file = join(uploadDirectory, 'native.mp3')
|
||||||
|
writeFileSync(file, 'ID3test')
|
||||||
|
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [file] })
|
||||||
|
await clickMusic('Добавить MP3')
|
||||||
|
await poll(
|
||||||
|
() =>
|
||||||
|
evaluate(
|
||||||
|
`document.querySelector('[data-testid="music-plan-editor"] ol')?.textContent.includes('native.mp3')`,
|
||||||
|
),
|
||||||
|
'native MP3 uploaded',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
dialog.showOpenDialog = originalDialog
|
||||||
|
rmSync(uploadDirectory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
await clickMusic('Удалить трек')
|
||||||
|
await confirmDelete('Удалить трек?')
|
||||||
|
await poll(
|
||||||
|
() =>
|
||||||
|
evaluate(
|
||||||
|
`!document.querySelector('[data-testid="music-plan-editor"] ol')?.textContent.includes('native.mp3')`,
|
||||||
|
),
|
||||||
|
'track removed',
|
||||||
|
)
|
||||||
|
await clickMusic('Удалить плейлист')
|
||||||
|
await confirmDelete('Удалить плейлист?')
|
||||||
|
await poll(() => !library.some((p) => p.name === 'Переименован'), 'playlist removed')
|
||||||
|
await choosePlaylist('Редактор музыки')
|
||||||
|
await clickMusic('Использовать в игре')
|
||||||
|
await poll(
|
||||||
|
async () => (await state()).channels.music.playlistId === 9,
|
||||||
|
'playlist assigned to game',
|
||||||
|
)
|
||||||
|
music = { ...music, playback: 2, stateVersion: String(Number(music.stateVersion) + 1) }
|
||||||
|
await poll(
|
||||||
|
async () => (await state()).channels.music.status === 'playing',
|
||||||
|
'assigned music plays',
|
||||||
|
)
|
||||||
|
const beforeOrder = (await state()).channels.music
|
||||||
|
await evaluate(`document.querySelector('[aria-label="Опустить трек 1"]').click()`)
|
||||||
|
assert.deepEqual(
|
||||||
|
library[0].tracks.map((t) => t.ID),
|
||||||
|
[91, 92],
|
||||||
|
'draft not published',
|
||||||
|
)
|
||||||
|
await clickMusic('Отменить')
|
||||||
|
await evaluate(`document.querySelector('[aria-label="Опустить трек 1"]').click()`)
|
||||||
|
await clickMusic('Сохранить порядок')
|
||||||
|
await poll(
|
||||||
|
async () => (await state()).channels.music.trackIndex === 1,
|
||||||
|
'order applied without changing file',
|
||||||
|
)
|
||||||
|
const afterOrder = (await state()).channels.music
|
||||||
|
assert.equal(afterOrder.source.url, beforeOrder.source.url)
|
||||||
|
assert.equal(afterOrder.operation, beforeOrder.operation)
|
||||||
|
assert.equal(afterOrder.seekOperation, beforeOrder.seekOperation)
|
||||||
|
assert.equal(afterOrder.desired, 'playing')
|
||||||
|
assert.ok(afterOrder.position >= beforeOrder.position)
|
||||||
|
console.log('SCREEN_MUSIC_PLAN_SMOKE_PASS')
|
||||||
|
|
||||||
await act({ action: 'logout' })
|
await act({ action: 'logout' })
|
||||||
assert.equal((await evaluate('window.screenBridge.onlineState()')).authenticated, false)
|
assert.equal((await evaluate('window.screenBridge.onlineState()')).authenticated, false)
|
||||||
console.log('SCREEN_ONLINE_SMOKE_PASS', JSON.stringify({ polls, requests: requests.length }))
|
console.log('SCREEN_ONLINE_SMOKE_PASS', JSON.stringify({ polls, requests: requests.length }))
|
||||||
|
|||||||
@@ -1,33 +1,41 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NAlert, NButton, NCard, NInput, NSelect, NSpace, useMessage } from 'naive-ui'
|
import { NAlert, NButton, NCard, NInput, NSelect, NSpace } from 'naive-ui'
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { DEFAULT_SERVER, type OnlineIntent, type OnlineState } from '../types/online'
|
import { DEFAULT_SERVER, type OnlineIntent, type OnlineState } from '../types/online'
|
||||||
|
|
||||||
defineProps<{ outputOpen: boolean; selectedKey: string | null; selectedName: string | null }>()
|
const props = defineProps<{
|
||||||
|
state: OnlineState
|
||||||
|
isReady: boolean
|
||||||
|
isBusy: boolean
|
||||||
|
selectedKey: string | null
|
||||||
|
selectedName: string | null
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ action: [intent: OnlineIntent]; cancel: [] }>()
|
||||||
const bridge = window.screenBridge
|
const bridge = window.screenBridge
|
||||||
const message = useMessage()
|
|
||||||
const isReady = ref(false)
|
|
||||||
const state = ref<OnlineState>({
|
|
||||||
server: DEFAULT_SERVER,
|
|
||||||
authenticated: false,
|
|
||||||
remembered: false,
|
|
||||||
busy: false,
|
|
||||||
canCancel: false,
|
|
||||||
games: [],
|
|
||||||
screenId: null,
|
|
||||||
soundEnabled: null,
|
|
||||||
connection: 'disabled',
|
|
||||||
error: '',
|
|
||||||
notice: '',
|
|
||||||
progress: null,
|
|
||||||
preparation: { active: false, completed: 0, total: 0, downloadedBytes: 0, error: '' },
|
|
||||||
packagesRevision: 0,
|
|
||||||
})
|
|
||||||
const server = ref(DEFAULT_SERVER)
|
const server = ref(DEFAULT_SERVER)
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const gameId = ref<number | null>(null)
|
const gameId = ref<number | null>(null)
|
||||||
const isBusy = ref(false)
|
watch(
|
||||||
|
() => props.state.server,
|
||||||
|
(value) => {
|
||||||
|
server.value = value
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => props.state.authenticated,
|
||||||
|
() => {
|
||||||
|
password.value = ''
|
||||||
|
},
|
||||||
|
)
|
||||||
|
function run(intent: OnlineIntent) {
|
||||||
|
emit('action', intent)
|
||||||
|
if (intent.action === 'login') password.value = ''
|
||||||
|
}
|
||||||
|
function cancel() {
|
||||||
|
emit('cancel')
|
||||||
|
}
|
||||||
const gameStatusLabels = new Map([
|
const gameStatusLabels = new Map([
|
||||||
['draft', 'Черновик'],
|
['draft', 'Черновик'],
|
||||||
['run', 'Идёт'],
|
['run', 'Идёт'],
|
||||||
@@ -41,55 +49,9 @@ const connectionLabels = {
|
|||||||
offline: 'Нет связи; локальный показ продолжается',
|
offline: 'Нет связи; локальный показ продолжается',
|
||||||
expired: 'Сессия экрана недействительна; подключите управление заново',
|
expired: 'Сессия экрана недействительна; подключите управление заново',
|
||||||
}
|
}
|
||||||
let timer: ReturnType<typeof setInterval> | undefined
|
|
||||||
let refreshing = false
|
|
||||||
function accept(value: OnlineState) {
|
|
||||||
state.value = value
|
|
||||||
isReady.value = true
|
|
||||||
}
|
|
||||||
async function refresh() {
|
|
||||||
if (!bridge || refreshing) return
|
|
||||||
refreshing = true
|
|
||||||
try {
|
|
||||||
accept(await bridge.onlineState())
|
|
||||||
} catch {
|
|
||||||
/* Закрытие приложения не влияет на подготовленные материалы. */
|
|
||||||
} finally {
|
|
||||||
refreshing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function run(intent: OnlineIntent) {
|
|
||||||
if (!bridge || isBusy.value) return
|
|
||||||
isBusy.value = true
|
|
||||||
try {
|
|
||||||
accept(await bridge.onlineAction(intent))
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error instanceof Error ? error.message : 'Действие не выполнено')
|
|
||||||
} finally {
|
|
||||||
if (intent.action === 'login') password.value = ''
|
|
||||||
isBusy.value = false
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function cancel() {
|
|
||||||
try {
|
|
||||||
await bridge?.onlineAction({ action: 'cancel' })
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error instanceof Error ? error.message : 'Не удалось отменить загрузку')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onMounted(async () => {
|
|
||||||
await refresh()
|
|
||||||
server.value = state.value?.server ?? ''
|
|
||||||
timer = setInterval(() => {
|
|
||||||
void refresh()
|
|
||||||
}, 500)
|
|
||||||
})
|
|
||||||
onUnmounted(() => clearInterval(timer))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-card v-if="bridge && isReady" title="Игра и сервер">
|
<n-card v-if="bridge && isReady" title="Сервер">
|
||||||
<div class="connection-content">
|
<div class="connection-content">
|
||||||
<template v-if="!state.authenticated">
|
<template v-if="!state.authenticated">
|
||||||
<label for="server">Адрес сервера</label>
|
<label for="server">Адрес сервера</label>
|
||||||
@@ -128,14 +90,27 @@ onUnmounted(() => clearInterval(timer))
|
|||||||
>Хранилище секретов ОС недоступно. Вход действует до закрытия приложения; материалы
|
>Хранилище секретов ОС недоступно. Вход действует до закрытия приложения; материалы
|
||||||
сохраняются.</n-alert
|
сохраняются.</n-alert
|
||||||
>
|
>
|
||||||
<n-space>
|
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'logout' })"
|
||||||
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'games' })"
|
>Выйти</n-button
|
||||||
>Обновить список игр</n-button
|
>
|
||||||
>
|
</template>
|
||||||
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'logout' })"
|
<p role="status">{{ connectionLabels[state.connection] }}</p>
|
||||||
>Выйти</n-button
|
<n-alert
|
||||||
>
|
v-if="state.error"
|
||||||
</n-space>
|
type="error"
|
||||||
|
class="connection-error"
|
||||||
|
data-testid="connection-error"
|
||||||
|
>{{ state.error }}</n-alert
|
||||||
|
>
|
||||||
|
<n-alert v-if="state.notice" type="info">{{ state.notice }}</n-alert>
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
<n-card v-if="bridge && isReady" title="Игра">
|
||||||
|
<div class="connection-content">
|
||||||
|
<template v-if="state.authenticated">
|
||||||
|
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'games' })"
|
||||||
|
>Обновить список игр</n-button
|
||||||
|
>
|
||||||
<n-select
|
<n-select
|
||||||
v-model:value="gameId"
|
v-model:value="gameId"
|
||||||
:options="
|
:options="
|
||||||
@@ -182,47 +157,12 @@ onUnmounted(() => clearInterval(timer))
|
|||||||
>Подключить управление этой игрой</n-button
|
>Подключить управление этой игрой</n-button
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
<p role="status">
|
<n-button
|
||||||
{{ connectionLabels[state.connection]
|
v-if="state.screenId"
|
||||||
}}{{ state.screenId ? ` · Экран №${state.screenId}` : '' }}
|
:disabled="isBusy || state.busy"
|
||||||
</p>
|
@click="run({ action: 'results' })"
|
||||||
<p v-if="state.screenId && !outputOpen">
|
>Обновить снимок результатов</n-button
|
||||||
Связь и загрузка материалов продолжаются; показ откройте вручную.
|
>
|
||||||
</p>
|
|
||||||
<template v-if="state.screenId">
|
|
||||||
<p role="status" data-testid="sound-assignment">
|
|
||||||
{{
|
|
||||||
state.soundEnabled === null
|
|
||||||
? 'Назначение звука пока неизвестно'
|
|
||||||
: state.soundEnabled
|
|
||||||
? 'Экран назначен для звука'
|
|
||||||
: 'Экран не назначен для звука'
|
|
||||||
}}
|
|
||||||
<template
|
|
||||||
v-if="state.soundEnabled !== null && (!outputOpen || state.connection !== 'connected')"
|
|
||||||
>
|
|
||||||
· Последнее подтверждённое состояние; сейчас назначение не проверяется.
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
<p>Переключатель «Выключить весь звук» управляет локальным звуком отдельно.</p>
|
|
||||||
</template>
|
|
||||||
<n-space v-if="state.screenId">
|
|
||||||
<n-button
|
|
||||||
:disabled="isBusy || state.busy || !state.authenticated"
|
|
||||||
@click="run({ action: 'sound' })"
|
|
||||||
>Назначить звук этому экрану</n-button
|
|
||||||
>
|
|
||||||
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'results' })"
|
|
||||||
>Обновить снимок результатов</n-button
|
|
||||||
>
|
|
||||||
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'disconnect' })"
|
|
||||||
>Отключить управление</n-button
|
|
||||||
>
|
|
||||||
</n-space>
|
|
||||||
<p v-if="state.screenId">
|
|
||||||
«Отключить управление» прекращает управление приложением; привязка экрана на сервере
|
|
||||||
сохраняется.
|
|
||||||
</p>
|
|
||||||
<p v-if="state.preparation.active" role="status" data-testid="materials-progress">
|
<p v-if="state.preparation.active" role="status" data-testid="materials-progress">
|
||||||
Подготовка материалов: {{ state.preparation.completed }} /
|
Подготовка материалов: {{ state.preparation.completed }} /
|
||||||
{{ state.preparation.total }} файлов; текущая загрузка
|
{{ state.preparation.total }} файлов; текущая загрузка
|
||||||
@@ -236,14 +176,6 @@ onUnmounted(() => clearInterval(timer))
|
|||||||
>Повторить подготовку</n-button
|
>Повторить подготовку</n-button
|
||||||
>
|
>
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<n-alert
|
|
||||||
v-if="state.error"
|
|
||||||
type="error"
|
|
||||||
class="connection-error"
|
|
||||||
data-testid="connection-error"
|
|
||||||
>{{ state.error }}</n-alert
|
|
||||||
>
|
|
||||||
<n-alert v-if="state.notice" type="info">{{ state.notice }}</n-alert>
|
|
||||||
<template v-if="state.busy">
|
<template v-if="state.busy">
|
||||||
<p role="status">
|
<p role="status">
|
||||||
{{
|
{{
|
||||||
@@ -254,6 +186,7 @@ onUnmounted(() => clearInterval(timer))
|
|||||||
</p>
|
</p>
|
||||||
<n-button v-if="state.canCancel" @click="cancel">Отменить загрузку</n-button>
|
<n-button v-if="state.canCancel" @click="cancel">Отменить загрузку</n-button>
|
||||||
</template>
|
</template>
|
||||||
|
<p v-if="!state.authenticated">Войдите на сервер, чтобы выбрать игру.</p>
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -23,9 +23,19 @@ import type {
|
|||||||
} from '../types/screen'
|
} from '../types/screen'
|
||||||
|
|
||||||
import ConnectionPanel from './ConnectionPanel.vue'
|
import ConnectionPanel from './ConnectionPanel.vue'
|
||||||
|
import ScreenConnectionPanel from './ScreenConnectionPanel.vue'
|
||||||
|
import MusicPlanPanel from './MusicPlanPanel.vue'
|
||||||
|
import { useOnlineConnection } from '../composables/useOnlineConnection'
|
||||||
import ScreenPlanPanel from './ScreenPlanPanel.vue'
|
import ScreenPlanPanel from './ScreenPlanPanel.vue'
|
||||||
import { useScreenTimer } from '../composables/useScreenTimer'
|
import { useScreenTimer } from '../composables/useScreenTimer'
|
||||||
|
|
||||||
|
const {
|
||||||
|
state: onlineState,
|
||||||
|
isReady: isOnlineReady,
|
||||||
|
isBusy: isOnlineBusy,
|
||||||
|
run: runOnline,
|
||||||
|
cancel: cancelOnline,
|
||||||
|
} = useOnlineConnection()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const planStatus = ref<PlanStatus>({ key: null, editable: false, notice: '' })
|
const planStatus = ref<PlanStatus>({ key: null, editable: false, notice: '' })
|
||||||
@@ -247,11 +257,15 @@ onUnmounted(() => {
|
|||||||
<h1>Вечерний детектив · Локальный проигрыватель</h1>
|
<h1>Вечерний детектив · Локальный проигрыватель</h1>
|
||||||
<n-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert>
|
<n-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert>
|
||||||
<connection-panel
|
<connection-panel
|
||||||
:output-open="state?.outputOpen ?? false"
|
:state="onlineState"
|
||||||
|
:is-ready="isOnlineReady"
|
||||||
|
:is-busy="isOnlineBusy"
|
||||||
|
@action="runOnline"
|
||||||
|
@cancel="cancelOnline"
|
||||||
:selected-key="state?.catalog.key ?? null"
|
:selected-key="state?.catalog.key ?? null"
|
||||||
:selected-name="state?.catalog.name ?? null"
|
:selected-name="state?.catalog.name ?? null"
|
||||||
/>
|
/>
|
||||||
<n-card v-if="bridge" title="Дисплей показа">
|
<n-card v-if="bridge" title="Экран">
|
||||||
<n-space vertical>
|
<n-space vertical>
|
||||||
<n-alert v-if="host.notice" type="warning">{{ host.notice }}</n-alert>
|
<n-alert v-if="host.notice" type="warning">{{ host.notice }}</n-alert>
|
||||||
<p role="status">
|
<p role="status">
|
||||||
@@ -281,6 +295,38 @@ onUnmounted(() => {
|
|||||||
<n-button v-if="state?.outputOpen" @click="run(() => bridge!.closeOutput())"
|
<n-button v-if="state?.outputOpen" @click="run(() => bridge!.closeOutput())"
|
||||||
>Закрыть показ</n-button
|
>Закрыть показ</n-button
|
||||||
>
|
>
|
||||||
|
<ScreenConnectionPanel
|
||||||
|
:state="onlineState"
|
||||||
|
:is-busy="isOnlineBusy"
|
||||||
|
:output-open="state?.outputOpen ?? false"
|
||||||
|
@action="runOnline"
|
||||||
|
/>
|
||||||
|
<template v-if="state">
|
||||||
|
<label class="mute"
|
||||||
|
>Выключить весь звук
|
||||||
|
<n-switch
|
||||||
|
:value="state.muted"
|
||||||
|
aria-label="Выключить весь звук"
|
||||||
|
@update:value="send({ action: 'mute', enabled: $event })"
|
||||||
|
/></label>
|
||||||
|
<img
|
||||||
|
v-if="previewURL"
|
||||||
|
class="preview"
|
||||||
|
data-testid="preview"
|
||||||
|
:src="previewURL"
|
||||||
|
alt="Предпросмотр текущего показа"
|
||||||
|
/>
|
||||||
|
<div v-else class="preview-empty">
|
||||||
|
{{
|
||||||
|
!state.outputOpen
|
||||||
|
? 'Окно показа закрыто'
|
||||||
|
: state.scene.status === 'pending'
|
||||||
|
? 'Подготовка показа…'
|
||||||
|
: 'Предпросмотр недоступен'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<p>Кадр реального окна показа, без второго звука.</p>
|
||||||
|
</template>
|
||||||
</n-space>
|
</n-space>
|
||||||
</n-card>
|
</n-card>
|
||||||
<template v-if="state">
|
<template v-if="state">
|
||||||
@@ -377,35 +423,16 @@ onUnmounted(() => {
|
|||||||
</p>
|
</p>
|
||||||
</n-space>
|
</n-space>
|
||||||
</n-card>
|
</n-card>
|
||||||
<n-card title="Текущий показ">
|
|
||||||
<img
|
|
||||||
v-if="previewURL"
|
|
||||||
class="preview"
|
|
||||||
data-testid="preview"
|
|
||||||
:src="previewURL"
|
|
||||||
alt="Предпросмотр текущего показа"
|
|
||||||
/>
|
|
||||||
<div v-else class="preview-empty">
|
|
||||||
{{
|
|
||||||
!state.outputOpen
|
|
||||||
? 'Окно показа закрыто'
|
|
||||||
: state.scene.status === 'pending'
|
|
||||||
? 'Подготовка показа…'
|
|
||||||
: 'Предпросмотр недоступен'
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<p>Кадр реального окна показа, без второго звука.</p>
|
|
||||||
</n-card>
|
|
||||||
</section>
|
</section>
|
||||||
<label class="mute"
|
<n-card title="План музыки">
|
||||||
>Выключить весь звук
|
|
||||||
<n-switch
|
|
||||||
:value="state.muted"
|
|
||||||
aria-label="Выключить весь звук"
|
|
||||||
@update:value="send({ action: 'mute', enabled: $event })"
|
|
||||||
/></label>
|
|
||||||
<n-card title="Музыка">
|
|
||||||
<n-space vertical>
|
<n-space vertical>
|
||||||
|
<MusicPlanPanel
|
||||||
|
:playlists="onlineState.playlists"
|
||||||
|
:can-edit="onlineState.authenticated && onlineState.connection !== 'offline'"
|
||||||
|
:can-use="planStatus.editable"
|
||||||
|
:is-busy="isOnlineBusy || onlineState.busy"
|
||||||
|
@action="runOnline"
|
||||||
|
/>
|
||||||
<n-select
|
<n-select
|
||||||
:value="state.channels.music.playlistId"
|
:value="state.channels.music.playlistId"
|
||||||
:options="state.catalog.playlists.map((p) => ({ label: p.name, value: p.id }))"
|
:options="state.catalog.playlists.map((p) => ({ label: p.name, value: p.id }))"
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NButton, NInput, NSelect, NSpace, useDialog } from 'naive-ui'
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import type { MusicPlaylist, OnlineIntent } from '../types/online'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
playlists: MusicPlaylist[]
|
||||||
|
canEdit: boolean
|
||||||
|
canUse: boolean
|
||||||
|
isBusy: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ action: [intent: OnlineIntent] }>()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const selectedId = ref<number | null>(null)
|
||||||
|
const name = ref('')
|
||||||
|
const newName = ref('')
|
||||||
|
const order = ref<number[]>([])
|
||||||
|
const original = ref<number[]>([])
|
||||||
|
const selected = computed(() => props.playlists.find((p) => p.id === selectedId.value))
|
||||||
|
const isDirty = computed(() => JSON.stringify(order.value) !== JSON.stringify(original.value))
|
||||||
|
const hasConflict = computed(
|
||||||
|
() =>
|
||||||
|
selected.value &&
|
||||||
|
JSON.stringify(selected.value.tracks.map((t) => t.id).sort((a, b) => a - b)) !==
|
||||||
|
JSON.stringify([...original.value].sort((a, b) => a - b)),
|
||||||
|
)
|
||||||
|
const isDisabled = computed(() => !props.canEdit || props.isBusy)
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined
|
||||||
|
function reset() {
|
||||||
|
original.value = selected.value?.tracks.map((t) => t.id) ?? []
|
||||||
|
order.value = [...original.value]
|
||||||
|
name.value = selected.value?.name ?? ''
|
||||||
|
}
|
||||||
|
watch(selectedId, reset)
|
||||||
|
watch(
|
||||||
|
() => props.playlists,
|
||||||
|
(items, old) => {
|
||||||
|
const added = items.find((p) => !old.some((o) => o.id === p.id))
|
||||||
|
if (!isDirty.value && added) selectedId.value = added.id
|
||||||
|
if (selectedId.value && !selected.value) selectedId.value = null
|
||||||
|
const oldName = old.find((p) => p.id === selectedId.value)?.name
|
||||||
|
if (name.value === oldName) name.value = selected.value?.name ?? ''
|
||||||
|
const ids = selected.value?.tracks.map((t) => t.id) ?? []
|
||||||
|
if (!isDirty.value || JSON.stringify(order.value) === JSON.stringify(ids)) {
|
||||||
|
original.value = ids
|
||||||
|
order.value = [...ids]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (props.canEdit && !props.isBusy) emit('action', { action: 'playlists' })
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
() => props.canEdit,
|
||||||
|
(value) => {
|
||||||
|
if (value) refresh()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
onMounted(() => {
|
||||||
|
refresh()
|
||||||
|
timer = setInterval(refresh, 10000)
|
||||||
|
})
|
||||||
|
onUnmounted(() => clearInterval(timer))
|
||||||
|
function move(index: number, offset: number) {
|
||||||
|
const target = index + offset
|
||||||
|
if (target < 0 || target >= order.value.length) return
|
||||||
|
const [id] = order.value.splice(index, 1)
|
||||||
|
if (id !== undefined) order.value.splice(target, 0, id)
|
||||||
|
}
|
||||||
|
function create() {
|
||||||
|
emit('action', { action: 'playlist-create', name: newName.value })
|
||||||
|
}
|
||||||
|
function remove(trackId?: number) {
|
||||||
|
if (!selected.value) return
|
||||||
|
const playlistId = selected.value.id
|
||||||
|
dialog.warning({
|
||||||
|
title: trackId === undefined ? 'Удалить плейлист?' : 'Удалить трек?',
|
||||||
|
content: 'Изменение сохранится на сервере. Используемые плейлисты и треки удалить нельзя.',
|
||||||
|
positiveText: 'Удалить',
|
||||||
|
negativeText: 'Отмена',
|
||||||
|
onPositiveClick: () =>
|
||||||
|
emit(
|
||||||
|
'action',
|
||||||
|
trackId === undefined
|
||||||
|
? { action: 'playlist-delete', playlistId }
|
||||||
|
: { action: 'playlist-track-delete', playlistId, trackId },
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-space vertical data-testid="music-plan-editor">
|
||||||
|
<p v-if="!canEdit">Для редактирования библиотеки подключитесь к серверу.</p>
|
||||||
|
<template v-else>
|
||||||
|
<n-button :disabled="isDisabled" @click="refresh">Обновить плейлисты</n-button>
|
||||||
|
<label for="new-playlist-name">Новый плейлист</label>
|
||||||
|
<n-input
|
||||||
|
v-model:value="newName"
|
||||||
|
:input-props="{ id: 'new-playlist-name' }"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
placeholder="Название плейлиста"
|
||||||
|
/>
|
||||||
|
<n-button :disabled="isDisabled || isDirty || !newName.trim()" @click="create"
|
||||||
|
>Создать плейлист</n-button
|
||||||
|
>
|
||||||
|
<n-select
|
||||||
|
v-model:value="selectedId"
|
||||||
|
:options="playlists.map((p) => ({ label: p.name, value: p.id }))"
|
||||||
|
:disabled="isDisabled || isDirty"
|
||||||
|
placeholder="Плейлист для редактирования"
|
||||||
|
:input-props="{ 'aria-label': 'Плейлист для редактирования' }"
|
||||||
|
/>
|
||||||
|
<p v-if="!playlists.length">Плейлистов пока нет. Создайте первый.</p>
|
||||||
|
<template v-if="selected">
|
||||||
|
<label for="playlist-name">Название плейлиста</label>
|
||||||
|
<n-input
|
||||||
|
v-model:value="name"
|
||||||
|
:input-props="{ id: 'playlist-name' }"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
/>
|
||||||
|
<n-space>
|
||||||
|
<n-button
|
||||||
|
:disabled="isDisabled || !name.trim() || name === selected.name"
|
||||||
|
@click="emit('action', { action: 'playlist-rename', playlistId: selected.id, name })"
|
||||||
|
>Сохранить название</n-button
|
||||||
|
>
|
||||||
|
<n-button :disabled="isDisabled || isDirty" @click="remove()">Удалить плейлист</n-button>
|
||||||
|
<n-button
|
||||||
|
:disabled="isDisabled || isDirty"
|
||||||
|
@click="emit('action', { action: 'playlist-upload', playlistId: selected.id })"
|
||||||
|
>Добавить MP3</n-button
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
:disabled="isDisabled || isDirty || !canUse || !selected.tracks.length"
|
||||||
|
@click="emit('action', { action: 'playlist-use', playlistId: selected.id })"
|
||||||
|
>Использовать в игре</n-button
|
||||||
|
>
|
||||||
|
</n-space>
|
||||||
|
<p>Выбор в редакторе не меняет музыку игры. MP3 — до 64 МиБ.</p>
|
||||||
|
<p v-if="!order.length">Треков пока нет.</p>
|
||||||
|
<ol class="track-list">
|
||||||
|
<li v-for="(id, index) in order" :key="id">
|
||||||
|
<span>{{
|
||||||
|
selected.tracks.find((t) => t.id === id)?.name ?? `Трек №${id} удалён на сервере`
|
||||||
|
}}</span>
|
||||||
|
<n-space>
|
||||||
|
<n-button
|
||||||
|
:disabled="isDisabled || index === 0"
|
||||||
|
:aria-label="`Поднять трек ${index + 1}`"
|
||||||
|
@click="move(index, -1)"
|
||||||
|
>↑</n-button
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
:disabled="isDisabled || index === order.length - 1"
|
||||||
|
:aria-label="`Опустить трек ${index + 1}`"
|
||||||
|
@click="move(index, 1)"
|
||||||
|
>↓</n-button
|
||||||
|
>
|
||||||
|
<n-button :disabled="isDisabled || isDirty" @click="remove(id)"
|
||||||
|
>Удалить трек</n-button
|
||||||
|
>
|
||||||
|
</n-space>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<n-alert v-if="hasConflict" type="warning"
|
||||||
|
>Состав треков изменился на сервере. Отмените черновик, чтобы принять актуальный
|
||||||
|
список.</n-alert
|
||||||
|
>
|
||||||
|
<n-space v-if="isDirty || hasConflict">
|
||||||
|
<n-button
|
||||||
|
:disabled="isDisabled || hasConflict"
|
||||||
|
@click="
|
||||||
|
emit('action', {
|
||||||
|
action: 'playlist-reorder',
|
||||||
|
playlistId: selected.id,
|
||||||
|
trackIds: [...order],
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>Сохранить порядок</n-button
|
||||||
|
>
|
||||||
|
<n-button :disabled="props.isBusy" @click="reset">Отменить</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.track-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-list li > span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NButton, NSpace } from 'naive-ui'
|
||||||
|
import type { OnlineIntent, OnlineState } from '../types/online'
|
||||||
|
defineProps<{ state: OnlineState; isBusy: boolean; outputOpen: boolean }>()
|
||||||
|
const emit = defineEmits<{ action: [intent: OnlineIntent] }>()
|
||||||
|
function run(intent: OnlineIntent) {
|
||||||
|
emit('action', intent)
|
||||||
|
}
|
||||||
|
const connectionLabels = {
|
||||||
|
disabled: 'Управление не подключено',
|
||||||
|
waiting: 'Ожидание ответа сервера',
|
||||||
|
connected: 'Связь с сервером есть',
|
||||||
|
offline: 'Нет связи; локальный показ продолжается',
|
||||||
|
expired: 'Сессия экрана недействительна; подключите управление заново',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<p v-if="state.screenId" role="status">
|
||||||
|
{{ connectionLabels[state.connection]
|
||||||
|
}}{{ state.screenId ? ` · Экран №${state.screenId}` : '' }}
|
||||||
|
</p>
|
||||||
|
<p v-if="state.screenId && !outputOpen">
|
||||||
|
Связь и загрузка материалов продолжаются; показ откройте вручную.
|
||||||
|
</p>
|
||||||
|
<template v-if="state.screenId">
|
||||||
|
<p role="status" data-testid="sound-assignment">
|
||||||
|
{{
|
||||||
|
state.soundEnabled === null
|
||||||
|
? 'Назначение звука пока неизвестно'
|
||||||
|
: state.soundEnabled
|
||||||
|
? 'Экран назначен для звука'
|
||||||
|
: 'Экран не назначен для звука'
|
||||||
|
}}
|
||||||
|
<template v-if="state.soundEnabled !== null && state.connection !== 'connected'">
|
||||||
|
· Последнее подтверждённое состояние; сейчас назначение не проверяется.
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
<p>Переключатель «Выключить весь звук» управляет локальным звуком отдельно.</p>
|
||||||
|
</template>
|
||||||
|
<n-space v-if="state.screenId">
|
||||||
|
<n-button
|
||||||
|
:disabled="isBusy || state.busy || !state.authenticated"
|
||||||
|
@click="run({ action: 'sound' })"
|
||||||
|
>Назначить звук этому экрану</n-button
|
||||||
|
>
|
||||||
|
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'disconnect' })"
|
||||||
|
>Отключить управление</n-button
|
||||||
|
>
|
||||||
|
</n-space>
|
||||||
|
<p v-if="state.screenId">
|
||||||
|
«Отключить управление» прекращает управление приложением; привязка экрана на сервере
|
||||||
|
сохраняется.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { DEFAULT_SERVER, type OnlineIntent, type OnlineState } from '../types/online'
|
||||||
|
|
||||||
|
export function useOnlineConnection() {
|
||||||
|
const bridge = window.screenBridge
|
||||||
|
const message = useMessage()
|
||||||
|
const isReady = ref(false)
|
||||||
|
const state = ref<OnlineState>({
|
||||||
|
server: DEFAULT_SERVER,
|
||||||
|
authenticated: false,
|
||||||
|
remembered: false,
|
||||||
|
busy: false,
|
||||||
|
canCancel: false,
|
||||||
|
games: [],
|
||||||
|
playlists: [],
|
||||||
|
screenId: null,
|
||||||
|
soundEnabled: null,
|
||||||
|
connection: 'disabled',
|
||||||
|
error: '',
|
||||||
|
notice: '',
|
||||||
|
progress: null,
|
||||||
|
preparation: { active: false, completed: 0, total: 0, downloadedBytes: 0, error: '' },
|
||||||
|
packagesRevision: 0,
|
||||||
|
})
|
||||||
|
const isBusy = ref(false)
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined
|
||||||
|
let refreshing = false
|
||||||
|
function accept(value: OnlineState) {
|
||||||
|
state.value = value
|
||||||
|
isReady.value = true
|
||||||
|
}
|
||||||
|
async function refresh() {
|
||||||
|
if (!bridge || refreshing) return
|
||||||
|
refreshing = true
|
||||||
|
try {
|
||||||
|
accept(await bridge.onlineState())
|
||||||
|
} catch {
|
||||||
|
/* Закрытие приложения не влияет на подготовленные материалы. */
|
||||||
|
} finally {
|
||||||
|
refreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function run(intent: OnlineIntent) {
|
||||||
|
if (!bridge || isBusy.value) return
|
||||||
|
isBusy.value = true
|
||||||
|
try {
|
||||||
|
accept(await bridge.onlineAction(intent))
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : 'Действие не выполнено')
|
||||||
|
} finally {
|
||||||
|
isBusy.value = false
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function cancel() {
|
||||||
|
try {
|
||||||
|
await bridge?.onlineAction({ action: 'cancel' })
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : 'Не удалось отменить загрузку')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(async () => {
|
||||||
|
await refresh()
|
||||||
|
timer = setInterval(() => {
|
||||||
|
void refresh()
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
onUnmounted(() => clearInterval(timer))
|
||||||
|
|
||||||
|
return { state, isReady, isBusy, run, cancel }
|
||||||
|
}
|
||||||
@@ -2,7 +2,19 @@ import type { ScreenStep } from './screen.js'
|
|||||||
|
|
||||||
export const DEFAULT_SERVER = 'https://evening-detective.crabs-games.art'
|
export const DEFAULT_SERVER = 'https://evening-detective.crabs-games.art'
|
||||||
|
|
||||||
|
export interface MusicPlaylist {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
tracks: { id: number; name: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
export type OnlineIntent =
|
export type OnlineIntent =
|
||||||
|
| { action: 'playlists' }
|
||||||
|
| { action: 'playlist-create'; name: string }
|
||||||
|
| { action: 'playlist-rename'; playlistId: number; name: string }
|
||||||
|
| { action: 'playlist-delete' | 'playlist-upload' | 'playlist-use'; playlistId: number }
|
||||||
|
| { action: 'playlist-track-delete'; playlistId: number; trackId: number }
|
||||||
|
| { action: 'playlist-reorder'; playlistId: number; trackIds: number[] }
|
||||||
| { action: 'plan-save'; key: string; steps: ScreenStep[] }
|
| { action: 'plan-save'; key: string; steps: ScreenStep[] }
|
||||||
| { action: 'plan-refresh' }
|
| { action: 'plan-refresh' }
|
||||||
| { action: 'login'; server: string; email: string; password: string }
|
| { action: 'login'; server: string; email: string; password: string }
|
||||||
@@ -17,6 +29,7 @@ export interface OnlineState {
|
|||||||
remembered: boolean
|
remembered: boolean
|
||||||
busy: boolean
|
busy: boolean
|
||||||
canCancel: boolean
|
canCancel: boolean
|
||||||
|
playlists: MusicPlaylist[]
|
||||||
games: { id: number; name: string; status: string }[]
|
games: { id: number; name: string; status: string }[]
|
||||||
soundEnabled: boolean | null
|
soundEnabled: boolean | null
|
||||||
screenId: string | null
|
screenId: string | null
|
||||||
|
|||||||
Reference in New Issue
Block a user