This commit is contained in:
2026-10-18 05:25:23 +07:00
parent f5e645ec00
commit 30999630f0
18 changed files with 1023 additions and 185 deletions
+13
View File
@@ -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 не выполнялись.
+9 -4
View File
@@ -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 МиБ и порядок треков ↑ / ↓.
«Сохранить порядок» публикует перестановку, «Использовать в игре» назначает плейлист.
«Пуск с начала» и «Повторить с начала» запускают выбранный ресурс с нуля,
«Пауза» сохраняет фактическую позицию, «Продолжить» возобновляет её, «Стоп»
+3 -3
View File
@@ -7,8 +7,8 @@
## До выезда
- [ ] Выбран архив своей ОС/архитектуры, SHA-256 совпал, приложение запускается.
- [ ] В «Игра и сервер» выполнен вход, нужная игра загружена до завершения.
- [ ] В блоке «Игра и сервер» подключена нужная игра; подготовка материалов завершена.
- [ ] В «Сервер» выполнен вход, в «Игра» нужная игра подготовлена до завершения.
- [ ] В блоке «Игра» подключена нужная игра; подготовка материалов завершена.
- [ ] Проверены обложка, шаги, вступление, видео, текст, таймер и нужные плейлисты.
- [ ] Пробный показ и перезапуск без интернета подтвердили готовность локальной копии.
- [ ] Если нужны результаты, сохранён доступный снимок и проверена его дата.
@@ -17,7 +17,7 @@
## До прихода игроков
- [ ] Проектор подключён в режиме расширения; экран компьютера назначен основным.
- [ ] В «Дисплей показа» выбран внешний дисплей, нажато «Открыть показ».
- [ ] В «Экран» выбран внешний дисплей, нажато «Открыть показ».
- [ ] На проекторе материалы, на компьютере пульт; кириллица и изображение читаются.
- [ ] В ОС выбран нужный аудиовыход. Пробный звук слышен физически, mute выключен,
громкость основного канала и музыки подходит для помещения.
+29 -8
View File
@@ -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");
+21 -1
View File
@@ -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)
})
+5 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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('Некорректный список игр')
+7
View File
@@ -122,6 +122,13 @@ export function requestErrorContext(
AdvanceScreenMusic: 'Следующий трек',
Login: 'Вход',
Refresh: 'Обновление сессии',
GetPlaylists: 'Список плейлистов',
CreatePlaylist: 'Создание плейлиста',
UpdatePlaylist: 'Название плейлиста',
DeletePlaylist: 'Удаление плейлиста',
UploadPlaylistTrack: 'Загрузка MP3',
DeletePlaylistTrack: 'Удаление трека',
ReorderPlaylistTracks: 'Порядок треков',
GetMe: 'Данные пользователя',
GetGames: 'Список игр',
CreateScreenSession: 'Создание экрана',
+204 -4
View File
@@ -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 }))
+59 -126
View File
@@ -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>
+56 -29
View File
@@ -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 }))"
+202
View File
@@ -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>
+54
View File
@@ -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>
+72
View File
@@ -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 }
}
+13
View File
@@ -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