up
This commit is contained in:
@@ -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 не выполнялись.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
- [ ] В ОС выбран нужный аудиовыход. Пробный звук слышен физически, mute выключен,
|
- [ ] В ОС выбран нужный аудиовыход. Пробный звук слышен физически, mute выключен,
|
||||||
громкость основного канала и музыки подходит для помещения.
|
громкость основного канала и музыки подходит для помещения.
|
||||||
- [ ] После подключения HDMI аудиовыход проверен повторно.
|
- [ ] После подключения HDMI аудиовыход проверен повторно.
|
||||||
- [ ] Перед подключением к серверу проверено название в строке «Игра для управления»;
|
- [ ] Перед подключением к серверу проверено название в строке «Текущая игра»;
|
||||||
подключено управление этой игрой, проверен номер экрана,
|
подключено управление этой игрой, проверен номер экрана,
|
||||||
связь с сервером и новая команда с сайта. Проверено назначение звука экрану
|
связь с сервером и новая команда с сайта. Проверено назначение звука экрану
|
||||||
и отдельно локальное выключение звука.
|
и отдельно локальное выключение звука.
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
|
|
||||||
## После игры
|
## После игры
|
||||||
|
|
||||||
- [ ] При доступной сети после завершения игры обновить снимок результатов,
|
- [ ] При доступной сети после завершения игры показать «Результаты»,
|
||||||
проверить таблицу и время снимка.
|
проверить таблицу и время снимка.
|
||||||
- [ ] Остановить основной канал и музыку, закрыть показ и приложение.
|
- [ ] Остановить основной канал и музыку, закрыть показ и приложение.
|
||||||
- [ ] Сохранить сведения о сбоях; не передавать пароли, токены и весь профиль.
|
- [ ] Сохранить сведения о сбоях; не передавать пароли, токены и весь профиль.
|
||||||
|
|||||||
+12
-13
@@ -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 без сигнала и автоматического перехода.
|
||||||
- После завершения игры при доступной сети нажмите «Обновить снимок результатов»
|
- Покажите шаг «Результаты»: при подключённом управлении снимок обновляется
|
||||||
в «Игра» (кнопка доступна после подключения управления).
|
автоматически. Проверьте дату снимка. Офлайн показывается
|
||||||
Покажите шаг «Результаты» и проверьте дату снимка. Офлайн показывается
|
|
||||||
последний сохранённый снимок, а не обновляемые серверные результаты.
|
последний сохранённый снимок, а не обновляемые серверные результаты.
|
||||||
Неудачное обновление сохраняет прежний снимок.
|
Неудачное обновление сохраняет прежний снимок.
|
||||||
|
|
||||||
«Закрыть показ» останавливает медиа, но **обычное закрытие не ставит таймер на паузу**.
|
«Закрыть показ» останавливает медиа, но **обычное закрытие не ставит таймер на паузу**.
|
||||||
При необходимости сначала нажмите «Пауза таймера». При смене пакета таймер сбрасывается.
|
При необходимости сначала нажмите «Пауза» в активном шаге таймера. При смене пакета таймер сбрасывается.
|
||||||
|
|
||||||
## Связь с сервером
|
## Связь с сервером
|
||||||
|
|
||||||
Подключите выбранную игру кнопкой в «Игра». Для ранее сохранённой игры
|
В «Игра» выберите игру и нажмите «Подключить». Если выбор пуст, эта же кнопка
|
||||||
можно использовать «Подключить управление этой игрой». Проверьте название
|
подключает текущую подготовленную игру того сервера, на котором выполнен вход.
|
||||||
в строке «Игра для управления» и номер экрана. Подготовка файлов не требует
|
Проверьте название в строке «Текущая игра» и номер экрана. Подготовка файлов не требует
|
||||||
открытого показа; само окно автоматически не открывается.
|
открытого показа; само окно автоматически не открывается.
|
||||||
|
|
||||||
Приложение постоянно принимает изменения сцены, текста, таймера и музыки.
|
Приложение постоянно принимает изменения сцены, текста, таймера и музыки.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: 'Запуск' }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user