This commit is contained in:
2026-10-18 12:42:04 +07:00
parent 30999630f0
commit b05d55b3fa
15 changed files with 261 additions and 208 deletions
+11
View File
@@ -456,3 +456,14 @@
- Проверка: 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. - Проверка: 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. Документация и чек-лист обновлены. - Ревью: полный diff вручную проверен по Go/frontend стандартам, независимые ревью сервера и приложения учтены; git diff --check во всех трёх репозиториях — PASS. Документация и чек-лист обновлены.
- Ограничения: рабочий сервер не развёртывался; для нового API нужен обновлённый бэкенд. Установочные архивы и аппаратная матрица не пересобирались/не проверялись. Коммиты и push не выполнялись. - Ограничения: рабочий сервер не развёртывался; для нового API нужен обновлённый бэкенд. Установочные архивы и аппаратная матрица не пересобирались/не проверялись. Коммиты и push не выполнялись.
## SCREEN-023 — Сократить интерфейс пульта
- Статус: `DONE` (2026-10-18).
- План: сохранить пять блоков и раскрытые редакторы; убрать пояснения, технические статусы и дубли команд, объединить подключение и кнопки воспроизведения по состоянию.
- Границы: только приложение экрана; сервер, API и начальную громкость не менять.
- Проверка: тесты, type-check, lint, lint:css, build и обновлённый Electron smoke.
- Результат: сохранены пять блоков и раскрытые редакторы; удалены постоянные инструкции, технические замеры, повторные статусы и ручные действия, уже выполняемые автоматически. Подключение выбранной/текущей подготовленной игры объединено. Для медиа одна кнопка запуска/паузы/продолжения, рядом «Стоп» и «Сначала»; для таймера одна кнопка состояния и сброс. Текущая локальная игра видна и после выхода.
- Ошибки и подтверждения сохранены; фоновое обновление библиотеки больше не стирает подробную ошибку пользовательского действия. Описание интерфейса обновлено в руководстве и чек-листе.
- Проверка: 127 тестов, type-check, ESLint, Stylelint, build и Electron GUI smoke — PASS. GUI проверяет пять блоков, отсутствие удалённых элементов, подключение обеих целей одной кнопкой, запуск/паузу/продолжение/повтор медиа, управление таймером, автоматические результаты, CRUD и порядок музыки, подробные ошибки и сохранение названия игры после выхода.
- Ревью: весь diff проверен вручную по frontend-стандарту; git diff --check — PASS. Изменения только в evening_detective_screen; сервер, API, зависимости и громкость не менялись. Установочные архивы не пересобирались, аппаратная матрица не проверялась; commit/push не выполнялись.
+4 -6
View File
@@ -84,7 +84,7 @@ npm run dev
В блоке «Сервер» доступны адрес и вход; в «Игра» — выбор игры и подготовка материалов. В блоке «Сервер» доступны адрес и вход; в «Игра» — выбор игры и подготовка материалов.
Для постоянной связи войдите, выберите игру в серверном списке и нажмите Для постоянной связи войдите, выберите игру в серверном списке и нажмите
«Подключить выбранную игру». Готовый пакет заранее не требуется: сначала сохраняются «Подключить». Готовый пакет заранее не требуется: сначала сохраняются
файлы текущего показа и трека, затем вся игра и связанные плейлисты. файлы текущего показа и трека, затем вся игра и связанные плейлисты.
Опрос продолжается при закрытом окне. Окно показа открывается вручную; при открытии Опрос продолжается при закрытом окне. Окно показа открывается вручную; при открытии
и восстановлении сети принимается актуальное состояние сервера, включая пуск медиа. и восстановлении сети принимается актуальное состояние сервера, включая пуск медиа.
@@ -200,9 +200,8 @@ URL, независимо от исходного имени файла. Лок
В «План музыки» доступны плейлисты, загрузка MP3 до 64 МиБ и порядок треков ↑ / ↓. В «План музыки» доступны плейлисты, загрузка MP3 до 64 МиБ и порядок треков ↑ / ↓.
«Сохранить порядок» публикует перестановку, «Использовать в игре» назначает плейлист. «Сохранить порядок» публикует перестановку, «Использовать в игре» назначает плейлист.
«Пуск с начала» и «Повторить с начала» запускают выбранный ресурс с нуля, Кнопка «Запуск» меняется на «Пауза» при воспроизведении и «Продолжить» на паузе.
«Пауза» сохраняет фактическую позицию, «Продолжить» возобновляет её, «Стоп» «Сначала» запускает ресурс с нуля, «Стоп» останавливает и сбрасывает позицию. Перемотка сохраняет паузу/воспроизведение и ограничена реальной
сбрасывает позицию. Перемотка сохраняет паузу/воспроизведение и ограничена реальной
длительностью файла. Громкость не вызывает перемотку или повторный пуск. длительностью файла. Громкость не вызывает перемотку или повторный пуск.
Следующий трек сохраняет состояние воспроизведения или паузы; по окончании трека Следующий трек сохраняет состояние воспроизведения или паузы; по окончании трека
переход выполняется локально. Повтор относится ко всему плейлисту; без повтора переход выполняется локально. Повтор относится ко всему плейлисту; без повтора
@@ -232,8 +231,7 @@ URL, независимо от исходного имени файла. Лок
Проверки test и smoke запускайте последовательно: обе команды пересобирают Проверки test и smoke запускайте последовательно: обе команды пересобирают
`dist-electron`, из которого Electron загружает preload при открытии окна. `dist-electron`, из которого Electron загружает preload при открытии окна.
Программные замеры в пульте разделены по каналу и текущему виду медиа; они Программные замеры автоматических проверок не измеряют физический звук/первый кадр. Аппаратная приёмка, остальные ОС,
не измеряют физический звук/первый кадр. Аппаратная приёмка, остальные ОС,
реальные игровые материалы и p95 на M1 пока не подтверждены. Все восемь визуальных режимов доступны в SCREEN-005; авторизация и получение реальные игровые материалы и p95 на M1 пока не подтверждены. Все восемь визуальных режимов доступны в SCREEN-005; авторизация и получение
серверных команд остаются в SCREEN-006. серверных команд остаются в SCREEN-006.
+2 -2
View File
@@ -22,7 +22,7 @@
- [ ] В ОС выбран нужный аудиовыход. Пробный звук слышен физически, mute выключен, - [ ] В ОС выбран нужный аудиовыход. Пробный звук слышен физически, mute выключен,
громкость основного канала и музыки подходит для помещения. громкость основного канала и музыки подходит для помещения.
- [ ] После подключения HDMI аудиовыход проверен повторно. - [ ] После подключения HDMI аудиовыход проверен повторно.
- [ ] Перед подключением к серверу проверено название в строке «Игра для управления»; - [ ] Перед подключением к серверу проверено название в строке «Текущая игра»;
подключено управление этой игрой, проверен номер экрана, подключено управление этой игрой, проверен номер экрана,
связь с сервером и новая команда с сайта. Проверено назначение звука экрану связь с сервером и новая команда с сайта. Проверено назначение звука экрану
и отдельно локальное выключение звука. и отдельно локальное выключение звука.
@@ -51,7 +51,7 @@
## После игры ## После игры
- [ ] При доступной сети после завершения игры обновить снимок результатов, - [ ] При доступной сети после завершения игры показать «Результаты»,
проверить таблицу и время снимка. проверить таблицу и время снимка.
- [ ] Остановить основной канал и музыку, закрыть показ и приложение. - [ ] Остановить основной канал и музыку, закрыть показ и приложение.
- [ ] Сохранить сведения о сбоях; не передавать пароли, токены и весь профиль. - [ ] Сохранить сведения о сбоях; не передавать пароли, токены и весь профиль.
+12 -13
View File
@@ -58,7 +58,7 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
Нажмите «Войти». Пароль вводите только в приложении, не отправляйте в отчёты. Нажмите «Войти». Пароль вводите только в приложении, не отправляйте в отчёты.
2. В «Игра» при необходимости нажмите «Обновить список игр» и выберите игру 2. В «Игра» при необходимости нажмите «Обновить список игр» и выберите игру
в поле «Выберите игру для подготовки». в поле «Выберите игру для подготовки».
3. Нажмите «Подключить выбранную игру». Она станет игрой показа, даже если ещё 3. Нажмите «Подключить». Она станет игрой показа, даже если ещё
не подготовлена на этом компьютере. Отдельно скачивать полный пакет не требуется. не подготовлена на этом компьютере. Отдельно скачивать полный пакет не требуется.
4. Приложение получает состояние экрана и музыки раз в секунду, сначала скачивает 4. Приложение получает состояние экрана и музыки раз в секунду, сначала скачивает
нужные сейчас файлы, затем остальные материалы игры и связанные плейлисты. нужные сейчас файлы, затем остальные материалы игры и связанные плейлисты.
@@ -122,8 +122,8 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
| Управление | Действие | | Управление | Действие |
| --- | --- | | --- | --- |
| «Пуск с начала», «Повторить с начала» | Запускает выбранный ресурс с нуля. | | «Запуск» / «Пауза» / «Продолжить» | Одна кнопка запускает, приостанавливает или возобновляет ресурс по текущему состоянию. |
| «Пауза», «Продолжить» | Сохраняет позицию и возобновляет с неё. | | «Сначала» | Запускает выбранный ресурс с нуля. |
| «Стоп» | Останавливает и сбрасывает позицию. | | «Стоп» | Останавливает и сбрасывает позицию. |
| «Позиция, секунды» → «Перемотать» | Меняет позицию, сохраняя паузу или воспроизведение. | | «Позиция, секунды» → «Перемотать» | Меняет позицию, сохраняя паузу или воспроизведение. |
| «Громкость, %» | Меняет громкость отдельного канала. Начальное значение — 100%. | | «Громкость, %» | Меняет громкость отдельного канала. Начальное значение — 100%. |
@@ -132,7 +132,7 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
| «Повторять плейлист» | После последнего трека возвращается к первому. | | «Повторять плейлист» | После последнего трека возвращается к первому. |
Вступление и видео управляются в активном шаге «Введение» или «Закрывашка»: Вступление и видео управляются в активном шаге «Введение» или «Закрывашка»:
«Запуск», «Пауза», «Продолжить», «Стоп», «Повтор», громкость и перемотка. кнопка запуска/паузы/продолжения, «Стоп», «Сначала», громкость и перемотка.
Здесь же видны позиция, длительность, статус и ошибка воспроизведения. Здесь же видны позиция, длительность, статус и ошибка воспроизведения.
Файл определяется режимом шага; отдельного выбора медиа нет. Файл определяется режимом шага; отдельного выбора медиа нет.
Кнопки воспроизведения и перемотка доступны при открытом показе и выбранном файле. Кнопки воспроизведения и перемотка доступны при открытом показе и выбранном файле.
@@ -161,23 +161,22 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
- В редакторе плана добавьте шаг «Текст», заполните текст и сохраните план. - В редакторе плана добавьте шаг «Текст», заполните текст и сохраните план.
Нажмите «Показать» у этого шага. Переносы строк сохраняются. Нажмите «Показать» у этого шага. Переносы строк сохраняются.
- Для шага «Таймер» задайте надпись и длительность в секундах, сохраните план - Для шага «Таймер» задайте надпись и длительность в секундах, сохраните план
и покажите шаг. В активном шаге доступны «Старт», «Пауза», «Продолжить» и покажите шаг. В активном шаге одна кнопка меняется между «Старт», «Пауза» и «Продолжить»;
и «Сбросить». При смене шага отсчёт продолжается в фоне; рядом доступно «Сбросить». При смене шага отсчёт продолжается в фоне;
на нуле остаётся 00:00 без сигнала и автоматического перехода. на нуле остаётся 00:00 без сигнала и автоматического перехода.
- После завершения игры при доступной сети нажмите «Обновить снимок результатов» - Покажите шаг «Результаты»: при подключённом управлении снимок обновляется
в «Игра» (кнопка доступна после подключения управления). автоматически. Проверьте дату снимка. Офлайн показывается
Покажите шаг «Результаты» и проверьте дату снимка. Офлайн показывается
последний сохранённый снимок, а не обновляемые серверные результаты. последний сохранённый снимок, а не обновляемые серверные результаты.
Неудачное обновление сохраняет прежний снимок. Неудачное обновление сохраняет прежний снимок.
«Закрыть показ» останавливает медиа, но **обычное закрытие не ставит таймер на паузу**. «Закрыть показ» останавливает медиа, но **обычное закрытие не ставит таймер на паузу**.
При необходимости сначала нажмите «Пауза таймера». При смене пакета таймер сбрасывается. При необходимости сначала нажмите «Пауза» в активном шаге таймера. При смене пакета таймер сбрасывается.
## Связь с сервером ## Связь с сервером
Подключите выбранную игру кнопкой в «Игра». Для ранее сохранённой игры В «Игра» выберите игру и нажмите «Подключить». Если выбор пуст, эта же кнопка
можно использовать «Подключить управление этой игрой». Проверьте название подключает текущую подготовленную игру того сервера, на котором выполнен вход.
в строке «Игра для управления» и номер экрана. Подготовка файлов не требует Проверьте название в строке «Текущая игра» и номер экрана. Подготовка файлов не требует
открытого показа; само окно автоматически не открывается. открытого показа; само окно автоматически не открывается.
Приложение постоянно принимает изменения сцены, текста, таймера и музыки. Приложение постоянно принимает изменения сцены, текста, таймера и музыки.
+4
View File
@@ -1445,6 +1445,10 @@ test('редактор музыки: CRUD, native MP3, порядок, вали
f.online.state.playlists[0]!.tracks.map((t) => t.id), f.online.state.playlists[0]!.tracks.map((t) => t.id),
[2, 1], [2, 1],
) )
const conflict = f.online.state.error
f.model.playlistError = 0
await f.online.act({ action: 'playlists' })
assert.equal(f.online.state.error, conflict, 'background refresh preserves the action error')
f.model.playlistError = 403 f.model.playlistError = 403
await assert.rejects(f.online.act({ action: 'playlist-create', name: 'Нет прав' }), /Нет доступа/) await assert.rejects(f.online.act({ action: 'playlist-create', name: 'Нет прав' }), /Нет доступа/)
}) })
+8 -4
View File
@@ -1096,14 +1096,18 @@ export class Online {
return this.snapshot() return this.snapshot()
} }
if (this.busy) throw new Error('Подождите завершения текущего действия') if (this.busy) throw new Error('Подождите завершения текущего действия')
const isBackgroundRefresh = data.action === 'playlists'
this.state.busy = true this.state.busy = true
this.state.error = '' if (!isBackgroundRefresh) {
this.state.notice = '' this.state.error = ''
this.state.notice = ''
}
try { try {
await this.perform(data) await this.perform(data)
} catch (error) { } catch (error) {
this.state.error = error instanceof Error ? error.message : 'Действие не выполнено' const message = error instanceof Error ? error.message : 'Действие не выполнено'
throw new Error(this.state.error) if (!isBackgroundRefresh || !this.state.error) this.state.error = message
throw new Error(message)
} finally { } finally {
this.state.busy = false this.state.busy = false
this.state.progress = null this.state.progress = null
+17
View File
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { playbackControl } from '../src/utils/playbackControl.js'
import type { ChannelState } from '../src/types/screen.js'
for (const [desired, status, action, label] of [
['stopped', 'stopped', 'play', 'Запуск'],
['playing', 'playing', 'pause', 'Пауза'],
['playing', 'pending', 'pause', 'Пауза'],
['paused', 'paused', 'resume', 'Продолжить'],
['playing', 'error', 'play', 'Запуск'],
['playing', 'ended', 'play', 'Запуск'],
] satisfies [ChannelState['desired'], ChannelState['status'], string, string][]) {
test(`кнопка проигрывателя ${desired}/${status}: ${action}`, () => {
assert.deepEqual(playbackControl({ desired, status }), { action, label })
})
}
+95 -6
View File
@@ -250,7 +250,7 @@ module.exports = async function onlineSmoke({
await poll( await poll(
() => () =>
evaluate( evaluate(
`Array.from(document.querySelectorAll('.n-base-selection')).some(e => e.textContent.includes('Выберите игру для подготовки'))`, `Array.from(document.querySelectorAll('.n-base-selection')).some(e => e.textContent.includes('Выберите игру'))`,
), ),
'server game selector', 'server game selector',
) )
@@ -267,6 +267,32 @@ module.exports = async function onlineSmoke({
), ),
['Сервер', 'Игра', 'Экран', 'План экранов', 'План музыки'], ['Сервер', 'Игра', 'Экран', 'План экранов', 'План музыки'],
) )
assert.equal(
await evaluate(`document.querySelectorAll('details').length`),
0,
'no developer metrics',
)
assert.equal(
await evaluate(
`document.body.textContent.includes('Автоматический сон') || document.body.textContent.includes('HDMI')`,
),
false,
'no permanent technical instructions',
)
for (const label of [
'Проверить изменения',
'Обновить снимок результатов',
'Подключить выбранную игру',
'Подключить управление этой игрой',
'Повторить с начала',
])
assert.equal(
await evaluate(
`Array.from(document.querySelectorAll('button')).some(b => b.textContent.trim() === ${JSON.stringify(label)})`,
),
false,
'removed button: ' + label,
)
varyGames = true varyGames = true
for (let refresh = 0; refresh < 5; refresh++) { for (let refresh = 0; refresh < 5; refresh++) {
await poll( await poll(
@@ -312,13 +338,13 @@ module.exports = async function onlineSmoke({
await poll( await poll(
() => () =>
evaluate(`(() => { evaluate(`(() => {
const select = Array.from(document.querySelectorAll('.n-base-selection')).find(e => e.textContent.includes('Выберите игру для подготовки')) const select = Array.from(document.querySelectorAll('.n-base-selection')).find(e => e.textContent.includes('Выберите игру'))
return !!select && !select.classList.contains('n-base-selection--disabled') && !document.body.textContent.includes('Список игр пока пуст') return !!select && !select.classList.contains('n-base-selection--disabled') && !document.body.textContent.includes('Список игр пока пуст')
})()`), })()`),
'updated game list rendered', 'updated game list rendered',
) )
await evaluate( await evaluate(
`Array.from(document.querySelectorAll('.n-base-selection')).find(e => e.textContent.includes('Выберите игру для подготовки')).click()`, `Array.from(document.querySelectorAll('.n-base-selection')).find(e => e.textContent.includes('Выберите игру')).click()`,
) )
await poll( await poll(
() => () =>
@@ -361,7 +387,19 @@ module.exports = async function onlineSmoke({
'anywhere', 'anywhere',
) )
invalidStory = false invalidStory = false
await act({ action: 'connect', gameId: 6 }) await poll(
() =>
evaluate(`(() => {
const button = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Подключить')
if (!button || button.disabled) return false
button.click(); return true
})()`),
'single connect button',
)
await poll(
async () => !!(await evaluate('window.screenBridge.onlineState()')).screenId,
'selected game connected',
)
const packages = await evaluate('window.screenBridge.packages()') const packages = await evaluate('window.screenBridge.packages()')
const pkg = packages.find((p) => p.name === 'HTTP smoke') const pkg = packages.find((p) => p.name === 'HTTP smoke')
assert.ok(pkg) assert.ok(pkg)
@@ -444,7 +482,10 @@ module.exports = async function onlineSmoke({
async () => (await state()).channels.main.status === 'playing', async () => (await state()).channels.main.status === 'playing',
'new command after incident', 'new command after incident',
) )
await act({ action: 'results' }) remoteMode = 6
currentCommand = 0
version++
await poll(async () => !!(await state()).results, 'results refresh automatically')
assert.equal((await state()).results.teams[0].name, 'Детективы') assert.equal((await state()).results.teams[0].name, 'Детективы')
assert.equal(JSON.stringify(await state()).includes('PRIVATE'), false) assert.equal(JSON.stringify(await state()).includes('PRIVATE'), false)
for (const method of [ for (const method of [
@@ -592,6 +633,10 @@ module.exports = async function onlineSmoke({
await poll(async () => (await state()).timer.status === 'running', 'plan timer start') await poll(async () => (await state()).timer.status === 'running', 'plan timer start')
await clickPlan('Пауза') await clickPlan('Пауза')
await poll(async () => (await state()).timer.status === 'paused', 'plan timer pause') await poll(async () => (await state()).timer.status === 'paused', 'plan timer pause')
await clickPlan('Продолжить')
await poll(async () => (await state()).timer.status === 'running', 'plan timer resumes')
await clickPlan('Сбросить')
await poll(async () => (await state()).timer.status === 'stopped', 'plan timer resets')
await clickPlan('Редактировать') await clickPlan('Редактировать')
await evaluate(`document.querySelector('[aria-label="Поднять шаг 3"]').click()`) await evaluate(`document.querySelector('[aria-label="Поднять шаг 3"]').click()`)
await clickPlan('Сохранить план') await clickPlan('Сохранить план')
@@ -664,7 +709,10 @@ module.exports = async function onlineSmoke({
'delete confirmation ' + title, 'delete confirmation ' + title,
) )
} }
await clickMusic('Обновить плейлисты') await poll(
async () => (await evaluate('window.screenBridge.onlineState()')).playlists.length > 0,
'library loaded automatically',
)
await setInput('#new-playlist-name', 'Создан из приложения') await setInput('#new-playlist-name', 'Создан из приложения')
await clickMusic('Создать плейлист') await clickMusic('Создать плейлист')
await poll(() => library.some((p) => p.name === 'Создан из приложения'), 'playlist created') await poll(() => library.some((p) => p.name === 'Создан из приложения'), 'playlist created')
@@ -740,9 +788,50 @@ module.exports = async function onlineSmoke({
assert.equal(afterOrder.desired, 'playing') assert.equal(afterOrder.desired, 'playing')
assert.ok(afterOrder.position >= beforeOrder.position) assert.ok(afterOrder.position >= beforeOrder.position)
console.log('SCREEN_MUSIC_PLAN_SMOKE_PASS') console.log('SCREEN_MUSIC_PLAN_SMOKE_PASS')
await act({ action: 'disconnect' })
await poll(
() =>
evaluate(`(() => {
const select = document.querySelector('.connection-content .n-base-selection')
select?.dispatchEvent(new MouseEvent('mouseenter'))
const clear = select?.querySelector('.n-base-clear__clear')
if (!clear) return false
clear.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
clear.click(); return true
})()`),
'clear game selection',
)
await poll(
() =>
evaluate(
`document.querySelector('.connection-content .n-base-selection')?.textContent.includes('Выберите игру')`,
),
'server selection cleared',
)
await poll(
() =>
evaluate(`(() => {
const button = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Подключить')
if (!button || button.disabled) return false
button.click(); return true
})()`),
'connect current prepared game',
)
await poll(
async () => (await evaluate('window.screenBridge.onlineState()')).connection === 'connected',
'current game reconnected',
)
assert.equal((await state()).catalog.name, 'HTTP smoke')
await act({ action: 'logout' }) await act({ action: 'logout' })
assert.equal((await evaluate('window.screenBridge.onlineState()')).authenticated, false) assert.equal((await evaluate('window.screenBridge.onlineState()')).authenticated, false)
await poll(
() =>
evaluate(
`document.querySelector('[data-testid="control-game"]')?.textContent.includes('HTTP smoke')`,
),
'current local game stays visible after logout',
)
console.log('SCREEN_ONLINE_SMOKE_PASS', JSON.stringify({ polls, requests: requests.length })) console.log('SCREEN_ONLINE_SMOKE_PASS', JSON.stringify({ polls, requests: requests.length }))
} finally { } finally {
server.closeAllConnections() server.closeAllConnections()
+7
View File
@@ -442,6 +442,13 @@ app.whenReady().then(async () => {
await poll(async () => (await state()).channels.main.volume === 42, 'plan media volume') await poll(async () => (await state()).channels.main.volume === 42, 'plan media volume')
assert.equal((await state()).channels.main.desired, 'paused') assert.equal((await state()).channels.main.desired, 'paused')
assert.equal((await state()).channels.music.desired, 'playing') assert.equal((await state()).channels.music.desired, 'playing')
await clickMedia('Продолжить')
await poll(
async () => (await state()).channels.main.status === 'playing',
'plan media resumes',
)
await clickMedia('Сначала')
await poll(async () => (await state()).channels.main.position < 0.5, 'plan media restarts')
await clickMedia('Стоп') await clickMedia('Стоп')
await poll( await poll(
async () => (await state()).channels.main.status === 'stopped', async () => (await state()).channels.main.status === 'stopped',
+22 -50
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { NAlert, NButton, NCard, NInput, NSelect, NSpace } from 'naive-ui' import { NAlert, NButton, NCard, NInput, NSelect } from 'naive-ui'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { DEFAULT_SERVER, type OnlineIntent, type OnlineState } from '../types/online' import { DEFAULT_SERVER, type OnlineIntent, type OnlineState } from '../types/online'
@@ -9,6 +9,7 @@ const props = defineProps<{
isBusy: boolean isBusy: boolean
selectedKey: string | null selectedKey: string | null
selectedName: string | null selectedName: string | null
canConnectCurrent: boolean
}>() }>()
const emit = defineEmits<{ action: [intent: OnlineIntent]; cancel: [] }>() const emit = defineEmits<{ action: [intent: OnlineIntent]; cancel: [] }>()
const bridge = window.screenBridge const bridge = window.screenBridge
@@ -20,6 +21,7 @@ watch(
() => props.state.server, () => props.state.server,
(value) => { (value) => {
server.value = value server.value = value
gameId.value = null
}, },
{ immediate: true }, { immediate: true },
) )
@@ -27,6 +29,7 @@ watch(
() => props.state.authenticated, () => props.state.authenticated,
() => { () => {
password.value = '' password.value = ''
if (!props.state.authenticated) gameId.value = null
}, },
) )
function run(intent: OnlineIntent) { function run(intent: OnlineIntent) {
@@ -87,8 +90,7 @@ const connectionLabels = {
<template v-else> <template v-else>
<p>{{ state.server }}</p> <p>{{ state.server }}</p>
<n-alert v-if="!state.remembered" type="warning" <n-alert v-if="!state.remembered" type="warning"
>Хранилище секретов ОС недоступно. Вход действует до закрытия приложения; материалы >Вход не сохранён: после перезапуска потребуется войти снова.</n-alert
сохраняются.</n-alert
> >
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'logout' })" <n-button :disabled="isBusy || state.busy" @click="run({ action: 'logout' })"
>Выйти</n-button >Выйти</n-button
@@ -113,6 +115,7 @@ const connectionLabels = {
> >
<n-select <n-select
v-model:value="gameId" v-model:value="gameId"
clearable
:options=" :options="
state.games.map((g) => ({ state.games.map((g) => ({
label: `${g.name} · ${gameStatusLabels.get(g.status) ?? 'Неизвестный статус'}`, label: `${g.name} · ${gameStatusLabels.get(g.status) ?? 'Неизвестный статус'}`,
@@ -120,56 +123,28 @@ const connectionLabels = {
})) }))
" "
:disabled="isBusy || state.busy" :disabled="isBusy || state.busy"
placeholder="Выберите игру для подготовки" placeholder="Выберите игру"
:input-props="{ 'aria-label': 'Игра на сервере' }" :input-props="{ 'aria-label': 'Игра на сервере' }"
/> />
<p v-if="!state.games.length">Список игр пока пуст. Обновите его при доступном сервере.</p> <p v-if="!state.games.length">Нет игр.</p>
<n-button
:disabled="(gameId === null && !canConnectCurrent) || isBusy || state.busy"
@click="run(gameId === null ? { action: 'connect' } : { action: 'connect', gameId })"
>Подключить</n-button
>
<n-button <n-button
:disabled="gameId === null || isBusy || state.busy" :disabled="gameId === null || isBusy || state.busy"
@click="run({ action: 'connect', gameId: gameId! })" @click="run({ action: 'download', gameId: gameId! })"
>Подключить выбранную игру</n-button >Загрузить материалы</n-button
>
<n-space>
<n-button
:disabled="gameId === null || isBusy || state.busy"
@click="run({ action: 'check', gameId: gameId! })"
>Проверить изменения</n-button
>
<n-button
:disabled="gameId === null || isBusy || state.busy"
@click="run({ action: 'download', gameId: gameId! })"
>Загрузить материалы</n-button
>
</n-space>
<p>
Новые материалы загружаются автоматически. Для принудительного обновления файлов по
прежним адресам закройте показ и загрузите материалы повторно.
</p>
<p>
Игру можно подключить без предварительной загрузки: недостающие файлы сохранятся
автоматически.
</p>
<p v-if="selectedKey" data-testid="control-game">Игра для управления: {{ selectedName }}</p>
<n-button
v-if="selectedKey"
:disabled="isBusy || state.busy"
@click="run({ action: 'connect' })"
>Подключить управление этой игрой</n-button
> >
</template> </template>
<n-button <p v-if="selectedKey" data-testid="control-game">Текущая игра: {{ selectedName }}</p>
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"> <p v-if="state.preparation.active" role="status" data-testid="materials-progress">
Подготовка материалов: {{ state.preparation.completed }} / Материалы: {{ state.preparation.completed }}/{{ state.preparation.total }} ·
{{ state.preparation.total }} файлов; текущая загрузка {{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ
{{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ.
</p> </p>
<n-alert v-if="state.preparation.error" type="warning" class="connection-error"> <n-alert v-if="state.preparation.error" type="warning" class="connection-error">
{{ state.preparation.error }}. Прежний показ сохраняется до готовности замены. {{ state.preparation.error }}
<n-button <n-button
:disabled="isBusy || state.busy || state.preparation.active" :disabled="isBusy || state.busy || state.preparation.active"
@click="run({ action: 'retry-materials' })" @click="run({ action: 'retry-materials' })"
@@ -177,12 +152,9 @@ const connectionLabels = {
> >
</n-alert> </n-alert>
<template v-if="state.busy"> <template v-if="state.busy">
<p role="status"> <p v-if="state.progress" role="status">
{{ Файлов: {{ state.progress.completedFiles }}/{{ state.progress.totalFiles }} ·
state.progress {{ (state.progress.downloadedBytes / 1048576).toFixed(1) }} МиБ
? `Файлов: ${state.progress.completedFiles}/${state.progress.totalFiles}, загружено ${(state.progress.downloadedBytes / 1048576).toFixed(1)} МиБ`
: 'Выполняется запрос…'
}}
</p> </p>
<n-button v-if="state.canCancel" @click="cancel">Отменить загрузку</n-button> <n-button v-if="state.canCancel" @click="cancel">Отменить загрузку</n-button>
</template> </template>
+28 -79
View File
@@ -28,6 +28,7 @@ import MusicPlanPanel from './MusicPlanPanel.vue'
import { useOnlineConnection } from '../composables/useOnlineConnection' import { useOnlineConnection } from '../composables/useOnlineConnection'
import ScreenPlanPanel from './ScreenPlanPanel.vue' import ScreenPlanPanel from './ScreenPlanPanel.vue'
import { useScreenTimer } from '../composables/useScreenTimer' import { useScreenTimer } from '../composables/useScreenTimer'
import { playbackControl } from '../utils/playbackControl'
const { const {
state: onlineState, state: onlineState,
@@ -63,12 +64,6 @@ const previewURL = computed(() =>
? preview.value?.dataURL ? preview.value?.dataURL
: null, : null,
) )
const sceneLabels = {
idle: 'Окно показа закрыто',
pending: 'Подготовка показа',
ready: 'Показ готов',
error: 'Не удалось подготовить показ',
}
const timerLabels = { const timerLabels = {
stopped: 'Не запущен', stopped: 'Не запущен',
running: 'Идёт', running: 'Идёт',
@@ -105,15 +100,18 @@ async function refreshPreview() {
} }
const channel = 'music' as const const channel = 'music' as const
const musicControl = computed(() =>
playbackControl(state.value?.channels.music ?? { desired: 'stopped', status: 'idle' }),
)
const playlist = computed(() => const playlist = computed(() =>
state.value?.catalog.playlists.find((p) => p.id === state.value?.channels.music.playlistId), state.value?.catalog.playlists.find((p) => p.id === state.value?.channels.music.playlistId),
) )
const statusLabels: Record<Playback, string> = { const statusLabels: Record<Playback, string> = {
idle: 'Ресурс не выбран', idle: 'Файл не выбран',
pending: 'Подготовка или выполнение команды', pending: 'Загрузка…',
playing: 'Воспроизведение подтверждено', playing: 'Играет',
paused: 'Пауза', paused: 'Пауза',
stopped: 'Готово, остановлено', stopped: 'Остановлено',
ended: 'Завершено', ended: 'Завершено',
error: 'Ошибка', error: 'Ошибка',
} }
@@ -212,18 +210,6 @@ async function refreshHost() {
if (display.value === null) display.value = host.value.preferredDisplayId if (display.value === null) display.value = host.value.preferredDisplayId
if (!host.value.displays.some((d) => d.id === display.value)) display.value = null if (!host.value.displays.some((d) => d.id === display.value)) display.value = null
} }
function metrics(channel: Channel) {
const values = (state.value?.samples ?? [])
.filter(
(sample) =>
sample.channel === channel && sample.media === state.value?.channels[channel].source?.kind,
)
.map((sample) => sample.milliseconds)
.sort((a, b) => a - b)
return values.length
? `n=${values.length}, p95 ${values[Math.ceil(values.length * 0.95) - 1]!.toFixed(1)} мс`
: 'Замеров пока нет'
}
onMounted(async () => { onMounted(async () => {
await run(async () => { await run(async () => {
if (!bridge) throw new Error('Откройте пульт в приложении Electron') if (!bridge) throw new Error('Откройте пульт в приложении Electron')
@@ -254,7 +240,7 @@ onUnmounted(() => {
<template> <template>
<main class="control"> <main class="control">
<h1>Вечерний детектив · Локальный проигрыватель</h1> <h1>Вечерний детектив</h1>
<n-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert> <n-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert>
<connection-panel <connection-panel
:state="onlineState" :state="onlineState"
@@ -264,22 +250,13 @@ onUnmounted(() => {
@cancel="cancelOnline" @cancel="cancelOnline"
:selected-key="state?.catalog.key ?? null" :selected-key="state?.catalog.key ?? null"
:selected-name="state?.catalog.name ?? null" :selected-name="state?.catalog.name ?? null"
:can-connect-current="
!!state?.catalog.key && planStatus.key === state.catalog.key && planStatus.editable
"
/> />
<n-card v-if="bridge" title="Экран"> <n-card v-if="bridge" title="Экран">
<n-space vertical> <n-space vertical>
<n-alert v-if="host.notice" type="warning">{{ host.notice }}</n-alert> <n-alert v-if="host.notice" type="warning">{{ host.notice }}</n-alert>
<p role="status">
{{
host.sleepBlocked
? 'Автоматический сон заблокирован на время показа'
: 'Блокировка сна выключена'
}}
</p>
<p>
Перед игрой выберите аудиовыход в настройках звука ОС, проверьте громкость и выполните
пробный пуск. После подключения HDMI проверьте выход повторно: система может переключить
звук на проектор.
</p>
<n-select <n-select
v-model:value="display" v-model:value="display"
:options="host.displays.map((d) => ({ label: d.label, value: d.id }))" :options="host.displays.map((d) => ({ label: d.label, value: d.id }))"
@@ -295,12 +272,7 @@ onUnmounted(() => {
<n-button v-if="state?.outputOpen" @click="run(() => bridge!.closeOutput())" <n-button v-if="state?.outputOpen" @click="run(() => bridge!.closeOutput())"
>Закрыть показ</n-button >Закрыть показ</n-button
> >
<ScreenConnectionPanel <ScreenConnectionPanel :state="onlineState" :is-busy="isOnlineBusy" @action="runOnline" />
:state="onlineState"
:is-busy="isOnlineBusy"
:output-open="state?.outputOpen ?? false"
@action="runOnline"
/>
<template v-if="state"> <template v-if="state">
<label class="mute" <label class="mute"
>Выключить весь звук >Выключить весь звук
@@ -325,14 +297,10 @@ onUnmounted(() => {
: 'Предпросмотр недоступен' : 'Предпросмотр недоступен'
}} }}
</div> </div>
<p>Кадр реального окна показа, без второго звука.</p>
</template> </template>
</n-space> </n-space>
</n-card> </n-card>
<template v-if="state"> <template v-if="state">
<n-alert v-if="!state.outputOpen" type="info"
>Откройте показ, чтобы подготовить медиа и включить воспроизведение.</n-alert
>
<section class="presentation-grid"> <section class="presentation-grid">
<n-card title="План экранов"> <n-card title="План экранов">
<n-space vertical> <n-space vertical>
@@ -343,6 +311,9 @@ onUnmounted(() => {
:is-editing="isPlanEditing" :is-editing="isPlanEditing"
:is-busy="isBusy || isPlanSaving" :is-busy="isBusy || isPlanSaving"
:can-play-main="state.outputOpen && !!state.channels.main.source" :can-play-main="state.outputOpen && !!state.channels.main.source"
:main-desired="state.channels.main.desired"
:main-status="state.channels.main.status"
:timer-status="state.timer.status"
:can-edit="planStatus.key === state.catalog.key && planStatus.editable" :can-edit="planStatus.key === state.catalog.key && planStatus.editable"
:notice="planStatus.key === state.catalog.key ? planStatus.notice : ''" :notice="planStatus.key === state.catalog.key ? planStatus.notice : ''"
@edit="editPlan" @edit="editPlan"
@@ -391,23 +362,19 @@ onUnmounted(() => {
<n-alert v-if="state.channels.main.error" type="error">{{ <n-alert v-if="state.channels.main.error" type="error">{{
state.channels.main.error state.channels.main.error
}}</n-alert> }}</n-alert>
<details>
<summary>Программные замеры запуска</summary>
<p>{{ metrics('main') }}</p>
<p>
Приём команды → подтверждение проигрывателя. Физический звук и первый кадр
проектора здесь не измеряются.
</p>
</details>
</template> </template>
</ScreenPlanPanel> </ScreenPlanPanel>
<p role="status">{{ sceneLabels[state.scene.status] }}</p> <p v-if="state.scene.status === 'pending'" role="status">Подготовка показа…</p>
<p v-if="state.timer.durationMs > 0" role="status" class="timer-status"> <p
v-if="
state.timer.durationMs > 0 &&
(state.scene.mode === 7 || state.timer.status === 'running')
"
role="status"
class="timer-status"
>
{{ state.timer.label }} · {{ timerDisplay }} · {{ timerLabels[state.timer.status] }} {{ state.timer.label }} · {{ timerDisplay }} · {{ timerLabels[state.timer.status] }}
</p> </p>
<p v-if="state.scene.mode !== 7 && state.timer.status === 'running'">
Таймер продолжает отсчёт в фоне.
</p>
<n-alert v-if="state.scene.error" type="error">{{ state.scene.error }}</n-alert> <n-alert v-if="state.scene.error" type="error">{{ state.scene.error }}</n-alert>
<n-button <n-button
v-if="state.scene.status === 'error'" v-if="state.scene.status === 'error'"
@@ -464,18 +431,8 @@ onUnmounted(() => {
<n-space> <n-space>
<n-button <n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy" :disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@click="send({ action: 'play', channel })" @click="send({ action: musicControl.action, channel })"
>Пуск с начала</n-button >{{ musicControl.label }}</n-button
>
<n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@click="send({ action: 'pause', channel })"
>Пауза</n-button
>
<n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@click="send({ action: 'resume', channel })"
>Продолжить</n-button
> >
<n-button <n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy" :disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@@ -485,7 +442,7 @@ onUnmounted(() => {
<n-button <n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy" :disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@click="send({ action: 'replay', channel })" @click="send({ action: 'replay', channel })"
>Повторить с начала</n-button >Сначала</n-button
> >
<n-button <n-button
:disabled="!state.outputOpen || !state.channels.music.source || isBusy" :disabled="!state.outputOpen || !state.channels.music.source || isBusy"
@@ -521,14 +478,6 @@ onUnmounted(() => {
<n-alert v-if="state.channels[channel].error" type="error">{{ <n-alert v-if="state.channels[channel].error" type="error">{{
state.channels[channel].error state.channels[channel].error
}}</n-alert> }}</n-alert>
<details>
<summary>Программные замеры запуска</summary>
<p>{{ metrics(channel) }}</p>
<p>
Приём команды → подтверждение проигрывателя. Физический звук и первый кадр проектора
здесь не измеряются.
</p>
</details>
</n-space> </n-space>
</n-card> </n-card>
</template> </template>
-2
View File
@@ -94,7 +94,6 @@ function remove(trackId?: number) {
<n-space vertical data-testid="music-plan-editor"> <n-space vertical data-testid="music-plan-editor">
<p v-if="!canEdit">Для редактирования библиотеки подключитесь к серверу.</p> <p v-if="!canEdit">Для редактирования библиотеки подключитесь к серверу.</p>
<template v-else> <template v-else>
<n-button :disabled="isDisabled" @click="refresh">Обновить плейлисты</n-button>
<label for="new-playlist-name">Новый плейлист</label> <label for="new-playlist-name">Новый плейлист</label>
<n-input <n-input
v-model:value="newName" v-model:value="newName"
@@ -138,7 +137,6 @@ function remove(trackId?: number) {
>Использовать в игре</n-button >Использовать в игре</n-button
> >
</n-space> </n-space>
<p>Выбор в редакторе не меняет музыку игры. MP3 — до 64 МиБ.</p>
<p v-if="!order.length">Треков пока нет.</p> <p v-if="!order.length">Треков пока нет.</p>
<ol class="track-list"> <ol class="track-list">
<li v-for="(id, index) in order" :key="id"> <li v-for="(id, index) in order" :key="id">
+4 -21
View File
@@ -1,27 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton, NSpace } from 'naive-ui' import { NButton, NSpace } from 'naive-ui'
import type { OnlineIntent, OnlineState } from '../types/online' import type { OnlineIntent, OnlineState } from '../types/online'
defineProps<{ state: OnlineState; isBusy: boolean; outputOpen: boolean }>() defineProps<{ state: OnlineState; isBusy: boolean }>()
const emit = defineEmits<{ action: [intent: OnlineIntent] }>() const emit = defineEmits<{ action: [intent: OnlineIntent] }>()
function run(intent: OnlineIntent) { function run(intent: OnlineIntent) {
emit('action', intent) emit('action', intent)
} }
const connectionLabels = {
disabled: 'Управление не подключено',
waiting: 'Ожидание ответа сервера',
connected: 'Связь с сервером есть',
offline: 'Нет связи; локальный показ продолжается',
expired: 'Сессия экрана недействительна; подключите управление заново',
}
</script> </script>
<template> <template>
<p v-if="state.screenId" role="status"> <p v-if="state.screenId">Экран №{{ state.screenId }}</p>
{{ connectionLabels[state.connection]
}}{{ state.screenId ? ` · Экран №${state.screenId}` : '' }}
</p>
<p v-if="state.screenId && !outputOpen">
Связь и загрузка материалов продолжаются; показ откройте вручную.
</p>
<template v-if="state.screenId"> <template v-if="state.screenId">
<p role="status" data-testid="sound-assignment"> <p role="status" data-testid="sound-assignment">
{{ {{
@@ -32,13 +19,13 @@ const connectionLabels = {
: 'Экран не назначен для звука' : 'Экран не назначен для звука'
}} }}
<template v-if="state.soundEnabled !== null && state.connection !== 'connected'"> <template v-if="state.soundEnabled !== null && state.connection !== 'connected'">
· Последнее подтверждённое состояние; сейчас назначение не проверяется. · Последнее состояние
</template> </template>
</p> </p>
<p>Переключатель «Выключить весь звук» управляет локальным звуком отдельно.</p>
</template> </template>
<n-space v-if="state.screenId"> <n-space v-if="state.screenId">
<n-button <n-button
v-if="state.soundEnabled !== true"
:disabled="isBusy || state.busy || !state.authenticated" :disabled="isBusy || state.busy || !state.authenticated"
@click="run({ action: 'sound' })" @click="run({ action: 'sound' })"
>Назначить звук этому экрану</n-button >Назначить звук этому экрану</n-button
@@ -47,8 +34,4 @@ const connectionLabels = {
>Отключить управление</n-button >Отключить управление</n-button
> >
</n-space> </n-space>
<p v-if="state.screenId">
«Отключить управление» прекращает управление приложением; привязка экрана на сервере
сохраняется.
</p>
</template> </template>
+35 -25
View File
@@ -1,15 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { NAlert, NButton, NInput, NInputNumber, NSelect, NSpace } from 'naive-ui' import { NAlert, NButton, NInput, NInputNumber, NSelect, NSpace } from 'naive-ui'
import type { Intent, ScreenMode, ScreenStep } from '../types/screen' import { computed } from 'vue'
import { playbackControl } from '../utils/playbackControl'
import type { ChannelState, Intent, ScreenMode, ScreenStep, TimerState } from '../types/screen'
import { modeLabels } from '../utils/presentation' import { modeLabels } from '../utils/presentation'
defineProps<{ const props = defineProps<{
steps: ScreenStep[] steps: ScreenStep[]
currentIndex: number | null currentIndex: number | null
draft: ScreenStep[] draft: ScreenStep[]
isEditing: boolean isEditing: boolean
isBusy: boolean isBusy: boolean
canPlayMain: boolean canPlayMain: boolean
mainDesired: ChannelState['desired']
mainStatus: ChannelState['status']
timerStatus: TimerState['status']
canEdit: boolean canEdit: boolean
notice: string notice: string
}>() }>()
@@ -24,6 +29,9 @@ const emit = defineEmits<{
updateStep: [index: number, patch: Partial<ScreenStep>] updateStep: [index: number, patch: Partial<ScreenStep>]
command: [intent: Intent] command: [intent: Intent]
}>() }>()
const mainControl = computed(() =>
playbackControl({ desired: props.mainDesired, status: props.mainStatus }),
)
const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
value: Number(mode) as ScreenMode, value: Number(mode) as ScreenMode,
label, label,
@@ -49,7 +57,7 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
</template> </template>
</n-space> </n-space>
<n-alert v-if="notice" type="warning" role="status" class="plan-notice">{{ notice }}</n-alert> <n-alert v-if="notice" type="warning" role="status" class="plan-notice">{{ notice }}</n-alert>
<p v-if="!canEdit">Для редактирования войдите на сервер выбранной подготовленной игры.</p> <p v-if="!canEdit">Для редактирования войдите на сервер игры.</p>
<template v-if="isEditing"> <template v-if="isEditing">
<n-space v-for="(step, index) in draft" :key="index" vertical class="plan-step"> <n-space v-for="(step, index) in draft" :key="index" vertical class="plan-step">
<span>Шаг {{ index + 1 }}</span> <span>Шаг {{ index + 1 }}</span>
@@ -111,7 +119,7 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
</n-space> </n-space>
</template> </template>
<template v-else> <template v-else>
<p v-if="!steps.length">План экранов ещё не создан. Создайте план, чтобы настроить экраны.</p> <p v-if="!steps.length">План экранов пуст.</p>
<n-space <n-space
v-for="(step, index) in steps" v-for="(step, index) in steps"
:key="index" :key="index"
@@ -136,15 +144,27 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
>{{ currentIndex === index ? 'Показан' : 'Показать' }}</n-button >{{ currentIndex === index ? 'Показан' : 'Показать' }}</n-button
> >
<template v-if="currentIndex === index && step.mode === 7"> <template v-if="currentIndex === index && step.mode === 7">
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-start' })" <n-button
>Старт</n-button :disabled="isBusy"
> @click="
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-pause' })" emit('command', {
>Пауза</n-button action:
> timerStatus === 'running'
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-resume' })" ? 'timer-pause'
>Продолжить</n-button : timerStatus === 'paused'
? 'timer-resume'
: 'timer-start',
})
"
> >
{{
timerStatus === 'running'
? 'Пауза'
: timerStatus === 'paused'
? 'Продолжить'
: 'Старт'
}}
</n-button>
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-reset' })" <n-button :disabled="isBusy" @click="emit('command', { action: 'timer-reset' })"
>Сбросить</n-button >Сбросить</n-button
> >
@@ -152,18 +172,8 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
<template v-if="currentIndex === index && (step.mode === 2 || step.mode === 4)"> <template v-if="currentIndex === index && (step.mode === 2 || step.mode === 4)">
<n-button <n-button
:disabled="isBusy || !canPlayMain" :disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'play', channel: 'main' })" @click="emit('command', { action: mainControl.action, channel: 'main' })"
>Запуск</n-button >{{ mainControl.label }}</n-button
>
<n-button
:disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'pause', channel: 'main' })"
>Пауза</n-button
>
<n-button
:disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'resume', channel: 'main' })"
>Продолжить</n-button
> >
<n-button <n-button
:disabled="isBusy || !canPlayMain" :disabled="isBusy || !canPlayMain"
@@ -173,7 +183,7 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
<n-button <n-button
:disabled="isBusy || !canPlayMain" :disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'replay', channel: 'main' })" @click="emit('command', { action: 'replay', channel: 'main' })"
>Повтор</n-button >Сначала</n-button
> >
</template> </template>
</n-space> </n-space>
+12
View File
@@ -0,0 +1,12 @@
import type { ChannelState } from '../types/screen.js'
export function playbackControl(channel: Pick<ChannelState, 'desired' | 'status'>): {
action: 'play' | 'pause' | 'resume'
label: string
} {
if (channel.status === 'error' || channel.status === 'ended')
return { action: 'play', label: 'Запуск' }
if (channel.desired === 'playing') return { action: 'pause', label: 'Пауза' }
if (channel.desired === 'paused') return { action: 'resume', label: 'Продолжить' }
return { action: 'play', label: 'Запуск' }
}