up
This commit is contained in:
@@ -443,3 +443,16 @@
|
||||
- Границы: изменения только evening_detective_screen. Сервер, proto и клиент
|
||||
генерации не менялись. Production-подключение не проверялось; установочные
|
||||
архивы не пересобирались, 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.
|
||||
В блоке «Игра и сервер» доступны вход, выбор игры и подготовка материалов.
|
||||
В блоке «Сервер» доступны адрес и вход; в «Игра» — выбор игры и подготовка материалов.
|
||||
|
||||
Для постоянной связи войдите, выберите игру в серверном списке и нажмите
|
||||
«Подключить выбранную игру». Готовый пакет заранее не требуется: сначала сохраняются
|
||||
@@ -188,12 +188,17 @@ URL, независимо от исходного имени файла. Лок
|
||||
|
||||
## Локальный проигрыватель SCREEN-004
|
||||
|
||||
Подключите игру в блоке «Игра и сервер», выберите внешний дисплей
|
||||
Подключите игру в блоке «Игра», выберите внешний дисплей
|
||||
и откройте показ. В активном шаге «Введение» или «Закрывашка» доступны управление воспроизведением,
|
||||
громкость, перемотка и статус основного канала. Файл определяется режимом шага;
|
||||
отдельного блока выбора медиа нет. Смена шага останавливает прежнее основное медиа. Музыка воспроизводится отдельным
|
||||
каналом без автоматического приглушения. Звук выводится только окном показа.
|
||||
Начальная громкость каждого канала — 35%; общий mute не меняет сохранённые уровни.
|
||||
Начальная громкость каждого канала — 100%; общий mute не меняет сохранённые уровни.
|
||||
При явном подключении игры звук назначается этому экрану, mute снимается, оба канала
|
||||
устанавливаются на 100%. Дальнейшие команды сервера имеют приоритет; автоматическое
|
||||
восстановление связи громкость не сбрасывает.
|
||||
В «План музыки» доступны плейлисты, загрузка MP3 до 64 МиБ и порядок треков ↑ / ↓.
|
||||
«Сохранить порядок» публикует перестановку, «Использовать в игре» назначает плейлист.
|
||||
|
||||
«Пуск с начала» и «Повторить с начала» запускают выбранный ресурс с нуля,
|
||||
«Пауза» сохраняет фактическую позицию, «Продолжить» возобновляет её, «Стоп»
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
## До выезда
|
||||
|
||||
- [ ] Выбран архив своей ОС/архитектуры, SHA-256 совпал, приложение запускается.
|
||||
- [ ] В «Игра и сервер» выполнен вход, нужная игра загружена до завершения.
|
||||
- [ ] В блоке «Игра и сервер» подключена нужная игра; подготовка материалов завершена.
|
||||
- [ ] В «Сервер» выполнен вход, в «Игра» нужная игра подготовлена до завершения.
|
||||
- [ ] В блоке «Игра» подключена нужная игра; подготовка материалов завершена.
|
||||
- [ ] Проверены обложка, шаги, вступление, видео, текст, таймер и нужные плейлисты.
|
||||
- [ ] Пробный показ и перезапуск без интернета подтвердили готовность локальной копии.
|
||||
- [ ] Если нужны результаты, сохранён доступный снимок и проверена его дата.
|
||||
@@ -17,7 +17,7 @@
|
||||
## До прихода игроков
|
||||
|
||||
- [ ] Проектор подключён в режиме расширения; экран компьютера назначен основным.
|
||||
- [ ] В «Дисплей показа» выбран внешний дисплей, нажато «Открыть показ».
|
||||
- [ ] В «Экран» выбран внешний дисплей, нажато «Открыть показ».
|
||||
- [ ] На проекторе материалы, на компьютере пульт; кириллица и изображение читаются.
|
||||
- [ ] В ОС выбран нужный аудиовыход. Пробный звук слышен физически, mute выключен,
|
||||
громкость основного канала и музыки подходит для помещения.
|
||||
|
||||
+29
-8
@@ -42,21 +42,21 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
||||
Основным назначьте экран компьютера; зеркалирование для раздельных окон не подходит.
|
||||
2. В настройках звука ОС выберите кабельный, USB или HDMI-выход и проверьте громкость.
|
||||
После подключения HDMI система может переключить выход на проектор.
|
||||
3. В приложении, в блоке «Дисплей показа», выберите внешний дисплей.
|
||||
3. В приложении, в блоке «Экран», выберите внешний дисплей.
|
||||
Если список пуст, проверьте кабель, переходник и режим рабочего стола ОС.
|
||||
4. После выбора материалов нажмите «Открыть показ». Проверьте реальное изображение:
|
||||
на проекторе должны быть только материалы, а пульт — на компьютере.
|
||||
Блок «Текущий показ» показывает кадр окна показа с небольшой задержкой.
|
||||
Предпросмотр в блоке «Экран» показывает кадр окна показа с небольшой задержкой.
|
||||
|
||||
Звук воспроизводит окно показа; предпросмотр второго звука не создаёт.
|
||||
Приложение использует аудиовыход ОС и не выбирает устройство самостоятельно.
|
||||
|
||||
## Подготовка игры при доступной сети
|
||||
|
||||
1. В «Игра и сервер» введите адрес сервера без пути, например адрес сайта
|
||||
1. В «Сервер» введите адрес сервера без пути, например адрес сайта
|
||||
с `https://`, email и пароль организатора, автора или администратора.
|
||||
Нажмите «Войти». Пароль вводите только в приложении, не отправляйте в отчёты.
|
||||
2. При необходимости нажмите «Обновить список игр» и выберите игру
|
||||
2. В «Игра» при необходимости нажмите «Обновить список игр» и выберите игру
|
||||
в поле «Выберите игру для подготовки».
|
||||
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 без сигнала и автоматического перехода.
|
||||
- После завершения игры при доступной сети нажмите «Обновить снимок результатов»
|
||||
в «Игра и сервер» (кнопка доступна после подключения управления).
|
||||
в «Игра» (кнопка доступна после подключения управления).
|
||||
Покажите шаг «Результаты» и проверьте дату снимка. Офлайн показывается
|
||||
последний сохранённый снимок, а не обновляемые серверные результаты.
|
||||
Неудачное обновление сохраняет прежний снимок.
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
export type ReorderPlaylistTracksReq = {
|
||||
playlistID: number | undefined;
|
||||
trackIDs: number[] | undefined;
|
||||
};
|
||||
|
||||
export type ReorderPlaylistTracksRsp = {
|
||||
playlist: Playlist | undefined;
|
||||
};
|
||||
|
||||
export type DeletePlaylistTrackReq = {
|
||||
playlistID: number | undefined;
|
||||
trackID: number | undefined;
|
||||
@@ -697,6 +706,7 @@ export type BindScreenReq = {
|
||||
screenID: string | undefined;
|
||||
accessToken: string | undefined;
|
||||
gameID: number | undefined;
|
||||
initializeAudio: boolean | undefined;
|
||||
};
|
||||
|
||||
export type BindScreenRsp = {
|
||||
@@ -1395,6 +1405,7 @@ export interface EveningDetectiveServer {
|
||||
UpdatePlaylist(request: UpdatePlaylistReq): Promise<UpdatePlaylistRsp>;
|
||||
DeletePlaylist(request: DeletePlaylistReq): Promise<DeletePlaylistRsp>;
|
||||
UploadPlaylistTrack(request: UploadPlaylistTrackReq): Promise<UploadPlaylistTrackRsp>;
|
||||
ReorderPlaylistTracks(request: ReorderPlaylistTracksReq): Promise<ReorderPlaylistTracksRsp>;
|
||||
DeletePlaylistTrack(request: DeletePlaylistTrackReq): Promise<DeletePlaylistTrackRsp>;
|
||||
SetScenarioPlaylist(request: SetScenarioPlaylistReq): Promise<SetScenarioPlaylistRsp>;
|
||||
AddScenario(request: AddScenarioReq): Promise<AddScenarioRsp>;
|
||||
@@ -2119,6 +2130,26 @@ export function createEveningDetectiveServerClient(
|
||||
method: "UploadPlaylistTrack",
|
||||
}) 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
|
||||
if (!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.music.desired, 'playing')
|
||||
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 })
|
||||
assert.equal(c.state.channels.main.volume, 80)
|
||||
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'])
|
||||
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',
|
||||
position: 0,
|
||||
duration: null,
|
||||
volume: 35,
|
||||
volume: 100,
|
||||
error: '',
|
||||
playlistId: null,
|
||||
trackIndex: 0,
|
||||
@@ -268,6 +268,10 @@ export class Controller {
|
||||
if (preserve && catalog.key === this.state.catalog.key) {
|
||||
catalog.cover = this.state.catalog.cover
|
||||
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.broadcast()
|
||||
return
|
||||
|
||||
+15
-2
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
app,
|
||||
dialog,
|
||||
BrowserWindow,
|
||||
ipcMain,
|
||||
protocol,
|
||||
@@ -10,8 +11,8 @@ import {
|
||||
powerSaveBlocker,
|
||||
} from 'electron'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { basename, join } from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
import { DiagnosticLog } from './diagnostics.js'
|
||||
import { Controller, parseCommand } from './controller.js'
|
||||
@@ -304,6 +305,18 @@ app
|
||||
},
|
||||
undefined,
|
||||
(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()
|
||||
let previousConnection = online.state.connection
|
||||
|
||||
+120
-4
@@ -34,6 +34,19 @@ async function fixture(t: TestContext, secure = true) {
|
||||
},
|
||||
}
|
||||
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,
|
||||
server: 'https://server.test',
|
||||
introURL: 'https://media.test/intro.wav',
|
||||
@@ -233,7 +246,34 @@ async function fixture(t: TestContext, secure = true) {
|
||||
}
|
||||
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}`)
|
||||
}
|
||||
const store = new PackageStore(join(directory, 'packages'), {
|
||||
@@ -243,7 +283,15 @@ async function fixture(t: TestContext, secure = true) {
|
||||
})
|
||||
await store.initialize()
|
||||
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()
|
||||
shutdown = () => online.close()
|
||||
const login = () =>
|
||||
@@ -721,7 +769,7 @@ test('вход без прав управления не сохраняет то
|
||||
test('назначение звука подтверждается сервером отдельно от локального mute и сбрасывается с сессией', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
assert.equal(f.online.state.soundEnabled, null)
|
||||
assert.equal(f.online.state.soundEnabled, true)
|
||||
f.controller.output(true)
|
||||
f.model.remote.soundEnabled = false
|
||||
await f.online.poll()
|
||||
@@ -743,7 +791,7 @@ test('назначение звука подтверждается сервер
|
||||
await f.online.poll()
|
||||
assert.equal(f.online.state.soundEnabled, false)
|
||||
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.act({ action: 'disconnect' })
|
||||
assert.equal(f.online.state.soundEnabled, null)
|
||||
@@ -1332,3 +1380,71 @@ test('без истории и медиа текст, таймер и музык
|
||||
assert.equal(f.online.snapshot().connection, 'connected')
|
||||
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,
|
||||
canCancel: false,
|
||||
games: [],
|
||||
playlists: [],
|
||||
screenId: null,
|
||||
soundEnabled: null,
|
||||
connection: 'disabled',
|
||||
@@ -336,7 +337,8 @@ export class Online {
|
||||
if (
|
||||
music &&
|
||||
old &&
|
||||
JSON.stringify(old.music?.filenames) !== JSON.stringify(next.music?.filenames)
|
||||
old.music?.trackId === next.music?.trackId &&
|
||||
!this.musicSourceMatches(next)
|
||||
)
|
||||
base.music = null
|
||||
if (!music && old && JSON.stringify(old.resources) !== JSON.stringify(next.resources))
|
||||
@@ -813,6 +815,8 @@ export class Online {
|
||||
private onRequestFailure: NonNullable<
|
||||
import('./screen-package.js').PackageRequest['onRequestFailure']
|
||||
> = () => {},
|
||||
private chooseTrack: () => Promise<{ filename: string; data: Buffer } | null> = async () =>
|
||||
null,
|
||||
) {
|
||||
controller.onMusicEnded = (report) => {
|
||||
if (!this.screen) return false
|
||||
@@ -975,7 +979,9 @@ export class Online {
|
||||
}
|
||||
private async raw(server: string, path: string, init: RequestInit = {}): Promise<Response> {
|
||||
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] : []),
|
||||
])
|
||||
try {
|
||||
@@ -1072,6 +1078,12 @@ export class Online {
|
||||
parsePlan(result.plan)
|
||||
return result
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ApiError &&
|
||||
error.status === 400 &&
|
||||
['DeletePlaylist', 'DeletePlaylistTrack'].includes(meta.method)
|
||||
)
|
||||
error.message = 'Плейлист используется игрой или сценарием; удаление недоступно'
|
||||
throw requestErrorContext(error, meta.method, request.method, path)
|
||||
}
|
||||
})
|
||||
@@ -1101,6 +1113,16 @@ export class Online {
|
||||
private async perform(data: Record<string, unknown>) {
|
||||
const action = data.action as OnlineIntent['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':
|
||||
return this.savePlan(data)
|
||||
case 'plan-refresh':
|
||||
@@ -1115,6 +1137,7 @@ export class Online {
|
||||
this.credentials = null
|
||||
this.state.authenticated = false
|
||||
this.state.games = []
|
||||
this.state.playlists = []
|
||||
await this.persistSecrets()
|
||||
this.state.server = server
|
||||
const result = await this.client(false, undefined, server).Login({ email, password })
|
||||
@@ -1149,6 +1172,7 @@ export class Online {
|
||||
this.credentials = null
|
||||
this.state.authenticated = false
|
||||
this.state.games = []
|
||||
this.state.playlists = []
|
||||
await this.persistSecrets()
|
||||
return
|
||||
case 'games':
|
||||
@@ -1253,7 +1277,16 @@ export class Online {
|
||||
token: token(result.accessToken),
|
||||
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.state.screenId = s.id
|
||||
this.state.connection = 'waiting'
|
||||
@@ -1277,6 +1310,80 @@ export class Online {
|
||||
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() {
|
||||
const result = await this.client().GetGames({})
|
||||
if (!Array.isArray(result.games ?? [])) throw new Error('Некорректный список игр')
|
||||
|
||||
@@ -122,6 +122,13 @@ export function requestErrorContext(
|
||||
AdvanceScreenMusic: 'Следующий трек',
|
||||
Login: 'Вход',
|
||||
Refresh: 'Обновление сессии',
|
||||
GetPlaylists: 'Список плейлистов',
|
||||
CreatePlaylist: 'Создание плейлиста',
|
||||
UpdatePlaylist: 'Название плейлиста',
|
||||
DeletePlaylist: 'Удаление плейлиста',
|
||||
UploadPlaylistTrack: 'Загрузка MP3',
|
||||
DeletePlaylistTrack: 'Удаление трека',
|
||||
ReorderPlaylistTracks: 'Порядок треков',
|
||||
GetMe: 'Данные пользователя',
|
||||
GetGames: 'Список игр',
|
||||
CreateScreenSession: 'Создание экрана',
|
||||
|
||||
+204
-4
@@ -1,6 +1,8 @@
|
||||
const assert = require('node:assert/strict')
|
||||
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')
|
||||
|
||||
module.exports = async function onlineSmoke({
|
||||
@@ -31,6 +33,17 @@ module.exports = async function onlineSmoke({
|
||||
let timerLabel = 'Перерыв'
|
||||
let timerDurationSeconds = 600
|
||||
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 server = createServer(async (req, res) => {
|
||||
const chunks = []
|
||||
@@ -102,8 +115,58 @@ module.exports = async function onlineSmoke({
|
||||
version++
|
||||
return send({})
|
||||
}
|
||||
if (path === '/api/games/6/music')
|
||||
return send(music ? { state: music, gamePlaylistId: 1, playlists: [music.playlist] } : {})
|
||||
if (path === '/api/playlists') {
|
||||
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 (
|
||||
['/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')))
|
||||
}
|
||||
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') {
|
||||
polls++
|
||||
assert.equal(req.headers.authorization, undefined)
|
||||
@@ -188,6 +254,19 @@ module.exports = async function onlineSmoke({
|
||||
),
|
||||
'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
|
||||
for (let refresh = 0; refresh < 5; refresh++) {
|
||||
await poll(
|
||||
@@ -541,6 +620,127 @@ module.exports = async function onlineSmoke({
|
||||
await clickPlan('Сохранить план')
|
||||
await poll(async () => (await state()).catalog.steps.length === 0, 'plan deleted')
|
||||
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' })
|
||||
assert.equal((await evaluate('window.screenBridge.onlineState()')).authenticated, false)
|
||||
console.log('SCREEN_ONLINE_SMOKE_PASS', JSON.stringify({ polls, requests: requests.length }))
|
||||
|
||||
@@ -1,33 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NCard, NInput, NSelect, NSpace, useMessage } from 'naive-ui'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { NAlert, NButton, NCard, NInput, NSelect, NSpace } from 'naive-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
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 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 email = ref('')
|
||||
const password = ref('')
|
||||
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([
|
||||
['draft', 'Черновик'],
|
||||
['run', 'Идёт'],
|
||||
@@ -41,55 +49,9 @@ const connectionLabels = {
|
||||
offline: 'Нет связи; локальный показ продолжается',
|
||||
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>
|
||||
|
||||
<template>
|
||||
<n-card v-if="bridge && isReady" title="Игра и сервер">
|
||||
<n-card v-if="bridge && isReady" title="Сервер">
|
||||
<div class="connection-content">
|
||||
<template v-if="!state.authenticated">
|
||||
<label for="server">Адрес сервера</label>
|
||||
@@ -128,14 +90,27 @@ onUnmounted(() => clearInterval(timer))
|
||||
>Хранилище секретов ОС недоступно. Вход действует до закрытия приложения; материалы
|
||||
сохраняются.</n-alert
|
||||
>
|
||||
<n-space>
|
||||
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'games' })"
|
||||
>Обновить список игр</n-button
|
||||
>
|
||||
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'logout' })"
|
||||
>Выйти</n-button
|
||||
>
|
||||
</n-space>
|
||||
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'logout' })"
|
||||
>Выйти</n-button
|
||||
>
|
||||
</template>
|
||||
<p role="status">{{ connectionLabels[state.connection] }}</p>
|
||||
<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>
|
||||
</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
|
||||
v-model:value="gameId"
|
||||
:options="
|
||||
@@ -182,47 +157,12 @@ onUnmounted(() => clearInterval(timer))
|
||||
>Подключить управление этой игрой</n-button
|
||||
>
|
||||
</template>
|
||||
<p 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 && (!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>
|
||||
<n-button
|
||||
v-if="state.screenId"
|
||||
:disabled="isBusy || state.busy"
|
||||
@click="run({ action: 'results' })"
|
||||
>Обновить снимок результатов</n-button
|
||||
>
|
||||
<p v-if="state.preparation.active" role="status" data-testid="materials-progress">
|
||||
Подготовка материалов: {{ state.preparation.completed }} /
|
||||
{{ state.preparation.total }} файлов; текущая загрузка
|
||||
@@ -236,14 +176,6 @@ onUnmounted(() => clearInterval(timer))
|
||||
>Повторить подготовку</n-button
|
||||
>
|
||||
</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">
|
||||
<p role="status">
|
||||
{{
|
||||
@@ -254,6 +186,7 @@ onUnmounted(() => clearInterval(timer))
|
||||
</p>
|
||||
<n-button v-if="state.canCancel" @click="cancel">Отменить загрузку</n-button>
|
||||
</template>
|
||||
<p v-if="!state.authenticated">Войдите на сервер, чтобы выбрать игру.</p>
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
@@ -23,9 +23,19 @@ import type {
|
||||
} from '../types/screen'
|
||||
|
||||
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 { useScreenTimer } from '../composables/useScreenTimer'
|
||||
|
||||
const {
|
||||
state: onlineState,
|
||||
isReady: isOnlineReady,
|
||||
isBusy: isOnlineBusy,
|
||||
run: runOnline,
|
||||
cancel: cancelOnline,
|
||||
} = useOnlineConnection()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const planStatus = ref<PlanStatus>({ key: null, editable: false, notice: '' })
|
||||
@@ -247,11 +257,15 @@ onUnmounted(() => {
|
||||
<h1>Вечерний детектив · Локальный проигрыватель</h1>
|
||||
<n-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert>
|
||||
<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-name="state?.catalog.name ?? null"
|
||||
/>
|
||||
<n-card v-if="bridge" title="Дисплей показа">
|
||||
<n-card v-if="bridge" title="Экран">
|
||||
<n-space vertical>
|
||||
<n-alert v-if="host.notice" type="warning">{{ host.notice }}</n-alert>
|
||||
<p role="status">
|
||||
@@ -281,6 +295,38 @@ onUnmounted(() => {
|
||||
<n-button v-if="state?.outputOpen" @click="run(() => bridge!.closeOutput())"
|
||||
>Закрыть показ</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-card>
|
||||
<template v-if="state">
|
||||
@@ -377,35 +423,16 @@ onUnmounted(() => {
|
||||
</p>
|
||||
</n-space>
|
||||
</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>
|
||||
<label class="mute"
|
||||
>Выключить весь звук
|
||||
<n-switch
|
||||
:value="state.muted"
|
||||
aria-label="Выключить весь звук"
|
||||
@update:value="send({ action: 'mute', enabled: $event })"
|
||||
/></label>
|
||||
<n-card title="Музыка">
|
||||
<n-card title="План музыки">
|
||||
<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
|
||||
:value="state.channels.music.playlistId"
|
||||
: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 interface MusicPlaylist {
|
||||
id: number
|
||||
name: string
|
||||
tracks: { id: number; name: string }[]
|
||||
}
|
||||
|
||||
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-refresh' }
|
||||
| { action: 'login'; server: string; email: string; password: string }
|
||||
@@ -17,6 +29,7 @@ export interface OnlineState {
|
||||
remembered: boolean
|
||||
busy: boolean
|
||||
canCancel: boolean
|
||||
playlists: MusicPlaylist[]
|
||||
games: { id: number; name: string; status: string }[]
|
||||
soundEnabled: boolean | null
|
||||
screenId: string | null
|
||||
|
||||
Reference in New Issue
Block a user