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 @@
-
-
+
@@ -128,14 +90,27 @@ onUnmounted(() => clearInterval(timer))
>Хранилище секретов ОС недоступно. Вход действует до закрытия приложения; материалы
сохраняются.
-
- Обновить список игр
- Выйти
-
+ Выйти
+
+