diff --git a/COMPLETED_TASKS.md b/COMPLETED_TASKS.md index 4c820cb..c77ed06 100644 --- a/COMPLETED_TASKS.md +++ b/COMPLETED_TASKS.md @@ -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 не выполнялись. diff --git a/README.md b/README.md index 09c02cc..8d11269 100644 --- a/README.md +++ b/README.md @@ -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 МиБ и порядок треков ↑ / ↓. +«Сохранить порядок» публикует перестановку, «Использовать в игре» назначает плейлист. «Пуск с начала» и «Повторить с начала» запускают выбранный ресурс с нуля, «Пауза» сохраняет фактическую позицию, «Продолжить» возобновляет её, «Стоп» diff --git a/docs/EVENT_CHECKLIST.md b/docs/EVENT_CHECKLIST.md index 0ff08b0..6169e81 100644 --- a/docs/EVENT_CHECKLIST.md +++ b/docs/EVENT_CHECKLIST.md @@ -7,8 +7,8 @@ ## До выезда - [ ] Выбран архив своей ОС/архитектуры, SHA-256 совпал, приложение запускается. -- [ ] В «Игра и сервер» выполнен вход, нужная игра загружена до завершения. -- [ ] В блоке «Игра и сервер» подключена нужная игра; подготовка материалов завершена. +- [ ] В «Сервер» выполнен вход, в «Игра» нужная игра подготовлена до завершения. +- [ ] В блоке «Игра» подключена нужная игра; подготовка материалов завершена. - [ ] Проверены обложка, шаги, вступление, видео, текст, таймер и нужные плейлисты. - [ ] Пробный показ и перезапуск без интернета подтвердили готовность локальной копии. - [ ] Если нужны результаты, сохранён доступный снимок и проверена его дата. @@ -17,7 +17,7 @@ ## До прихода игроков - [ ] Проектор подключён в режиме расширения; экран компьютера назначен основным. -- [ ] В «Дисплей показа» выбран внешний дисплей, нажато «Открыть показ». +- [ ] В «Экран» выбран внешний дисплей, нажато «Открыть показ». - [ ] На проекторе материалы, на компьютере пульт; кириллица и изображение читаются. - [ ] В ОС выбран нужный аудиовыход. Пробный звук слышен физически, mute выключен, громкость основного канала и музыки подходит для помещения. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index acc43c5..bb52842 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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%. +Дальнейшие изменения сервера применяются как обычно; восстановление связи +без повторного подключения игры громкость не сбрасывает. + Назначение звука экрану меняется отдельно через сайт или «Назначить звук этому экрану». Оно отключает звук остальных экранов по правилам сервера. При отсутствии связи отображается последнее подтверждённое назначение. diff --git a/electron/api/generated/crabs/evening_detective_server/index.ts b/electron/api/generated/crabs/evening_detective_server/index.ts index e91897c..a64f8f9 100644 --- a/electron/api/generated/crabs/evening_detective_server/index.ts +++ b/electron/api/generated/crabs/evening_detective_server/index.ts @@ -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; DeletePlaylist(request: DeletePlaylistReq): Promise; UploadPlaylistTrack(request: UploadPlaylistTrackReq): Promise; + ReorderPlaylistTracks(request: ReorderPlaylistTracksReq): Promise; DeletePlaylistTrack(request: DeletePlaylistTrackReq): Promise; SetScenarioPlaylist(request: SetScenarioPlaylistReq): Promise; AddScenario(request: AddScenarioReq): Promise; @@ -2119,6 +2130,26 @@ export function createEveningDetectiveServerClient( method: "UploadPlaylistTrack", }) as Promise; }, + 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; + }, DeletePlaylistTrack(request) { // eslint-disable-line @typescript-eslint/no-unused-vars if (!request.playlistID) { throw new Error("missing required field request.playlistID"); diff --git a/electron/controller.test.ts b/electron/controller.test.ts index b6d07f1..c95d00e 100644 --- a/electron/controller.test.ts +++ b/electron/controller.test.ts @@ -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) +}) diff --git a/electron/controller.ts b/electron/controller.ts index 31f4a7d..c1dd1c1 100644 --- a/electron/controller.ts +++ b/electron/controller.ts @@ -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 diff --git a/electron/main.ts b/electron/main.ts index 06ff8f8..82382e8 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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 diff --git a/electron/online.test.ts b/electron/online.test.ts index bbe5a05..124d606 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -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[], 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: 'Нет прав' }), /Нет доступа/) +}) diff --git a/electron/online.ts b/electron/online.ts index 1ba0b9b..82b510d 100644 --- a/electron/online.ts +++ b/electron/online.ts @@ -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 { 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) { 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) { + 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('Некорректный список игр') diff --git a/electron/request-error.ts b/electron/request-error.ts index f0854fc..e67cfac 100644 --- a/electron/request-error.ts +++ b/electron/request-error.ts @@ -122,6 +122,13 @@ export function requestErrorContext( AdvanceScreenMusic: 'Следующий трек', Login: 'Вход', Refresh: 'Обновление сессии', + GetPlaylists: 'Список плейлистов', + CreatePlaylist: 'Создание плейлиста', + UpdatePlaylist: 'Название плейлиста', + DeletePlaylist: 'Удаление плейлиста', + UploadPlaylistTrack: 'Загрузка MP3', + DeletePlaylistTrack: 'Удаление трека', + ReorderPlaylistTracks: 'Порядок треков', GetMe: 'Данные пользователя', GetGames: 'Список игр', CreateScreenSession: 'Создание экрана', diff --git a/scripts/online-smoke.cjs b/scripts/online-smoke.cjs index 78d1aee..0644ab2 100644 --- a/scripts/online-smoke.cjs +++ b/scripts/online-smoke.cjs @@ -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 })) diff --git a/src/components/ConnectionPanel.vue b/src/components/ConnectionPanel.vue index fc6fe3f..60b1ff7 100644 --- a/src/components/ConnectionPanel.vue +++ b/src/components/ConnectionPanel.vue @@ -1,33 +1,41 @@ -