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.
- Ревью: полный diff вручную проверен по Go/frontend стандартам, независимые ревью сервера и приложения учтены; git diff --check во всех трёх репозиториях — PASS. Документация и чек-лист обновлены.
- Ограничения: рабочий сервер не развёртывался; для нового 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 МиБ и порядок треков ↑ / ↓.
«Сохранить порядок» публикует перестановку, «Использовать в игре» назначает плейлист.
«Пуск с начала» и «Повторить с начала» запускают выбранный ресурс с нуля,
«Пауза» сохраняет фактическую позицию, «Продолжить» возобновляет её, «Стоп»
сбрасывает позицию. Перемотка сохраняет паузу/воспроизведение и ограничена реальной
Кнопка «Запуск» меняется на «Пауза» при воспроизведении и «Продолжить» на паузе.
«Сначала» запускает ресурс с нуля, «Стоп» останавливает и сбрасывает позицию. Перемотка сохраняет паузу/воспроизведение и ограничена реальной
длительностью файла. Громкость не вызывает перемотку или повторный пуск.
Следующий трек сохраняет состояние воспроизведения или паузы; по окончании трека
переход выполняется локально. Повтор относится ко всему плейлисту; без повтора
@@ -232,8 +231,7 @@ URL, независимо от исходного имени файла. Лок
Проверки test и smoke запускайте последовательно: обе команды пересобирают
`dist-electron`, из которого Electron загружает preload при открытии окна.
Программные замеры в пульте разделены по каналу и текущему виду медиа; они
не измеряют физический звук/первый кадр. Аппаратная приёмка, остальные ОС,
Программные замеры автоматических проверок не измеряют физический звук/первый кадр. Аппаратная приёмка, остальные ОС,
реальные игровые материалы и p95 на M1 пока не подтверждены. Все восемь визуальных режимов доступны в SCREEN-005; авторизация и получение
серверных команд остаются в SCREEN-006.
+2 -2
View File
@@ -22,7 +22,7 @@
- [ ] В ОС выбран нужный аудиовыход. Пробный звук слышен физически, mute выключен,
громкость основного канала и музыки подходит для помещения.
- [ ] После подключения HDMI аудиовыход проверен повторно.
- [ ] Перед подключением к серверу проверено название в строке «Игра для управления»;
- [ ] Перед подключением к серверу проверено название в строке «Текущая игра»;
подключено управление этой игрой, проверен номер экрана,
связь с сервером и новая команда с сайта. Проверено назначение звука экрану
и отдельно локальное выключение звука.
@@ -51,7 +51,7 @@
## После игры
- [ ] При доступной сети после завершения игры обновить снимок результатов,
- [ ] При доступной сети после завершения игры показать «Результаты»,
проверить таблицу и время снимка.
- [ ] Остановить основной канал и музыку, закрыть показ и приложение.
- [ ] Сохранить сведения о сбоях; не передавать пароли, токены и весь профиль.
+12 -13
View File
@@ -58,7 +58,7 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
Нажмите «Войти». Пароль вводите только в приложении, не отправляйте в отчёты.
2. В «Игра» при необходимости нажмите «Обновить список игр» и выберите игру
в поле «Выберите игру для подготовки».
3. Нажмите «Подключить выбранную игру». Она станет игрой показа, даже если ещё
3. Нажмите «Подключить». Она станет игрой показа, даже если ещё
не подготовлена на этом компьютере. Отдельно скачивать полный пакет не требуется.
4. Приложение получает состояние экрана и музыки раз в секунду, сначала скачивает
нужные сейчас файлы, затем остальные материалы игры и связанные плейлисты.
@@ -122,8 +122,8 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
| Управление | Действие |
| --- | --- |
| «Пуск с начала», «Повторить с начала» | Запускает выбранный ресурс с нуля. |
| «Пауза», «Продолжить» | Сохраняет позицию и возобновляет с неё. |
| «Запуск» / «Пауза» / «Продолжить» | Одна кнопка запускает, приостанавливает или возобновляет ресурс по текущему состоянию. |
| «Сначала» | Запускает выбранный ресурс с нуля. |
| «Стоп» | Останавливает и сбрасывает позицию. |
| «Позиция, секунды» → «Перемотать» | Меняет позицию, сохраняя паузу или воспроизведение. |
| «Громкость, %» | Меняет громкость отдельного канала. Начальное значение — 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 без сигнала и автоматического перехода.
- После завершения игры при доступной сети нажмите «Обновить снимок результатов»
в «Игра» (кнопка доступна после подключения управления).
Покажите шаг «Результаты» и проверьте дату снимка. Офлайн показывается
- Покажите шаг «Результаты»: при подключённом управлении снимок обновляется
автоматически. Проверьте дату снимка. Офлайн показывается
последний сохранённый снимок, а не обновляемые серверные результаты.
Неудачное обновление сохраняет прежний снимок.
«Закрыть показ» останавливает медиа, но **обычное закрытие не ставит таймер на паузу**.
При необходимости сначала нажмите «Пауза таймера». При смене пакета таймер сбрасывается.
При необходимости сначала нажмите «Пауза» в активном шаге таймера. При смене пакета таймер сбрасывается.
## Связь с сервером
Подключите выбранную игру кнопкой в «Игра». Для ранее сохранённой игры
можно использовать «Подключить управление этой игрой». Проверьте название
в строке «Игра для управления» и номер экрана. Подготовка файлов не требует
В «Игра» выберите игру и нажмите «Подключить». Если выбор пуст, эта же кнопка
подключает текущую подготовленную игру того сервера, на котором выполнен вход.
Проверьте название в строке «Текущая игра» и номер экрана. Подготовка файлов не требует
открытого показа; само окно автоматически не открывается.
Приложение постоянно принимает изменения сцены, текста, таймера и музыки.
+4
View File
@@ -1445,6 +1445,10 @@ test('редактор музыки: CRUD, native MP3, порядок, вали
f.online.state.playlists[0]!.tracks.map((t) => t.id),
[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
await assert.rejects(f.online.act({ action: 'playlist-create', name: 'Нет прав' }), /Нет доступа/)
})
+8 -4
View File
@@ -1096,14 +1096,18 @@ export class Online {
return this.snapshot()
}
if (this.busy) throw new Error('Подождите завершения текущего действия')
const isBackgroundRefresh = data.action === 'playlists'
this.state.busy = true
this.state.error = ''
this.state.notice = ''
if (!isBackgroundRefresh) {
this.state.error = ''
this.state.notice = ''
}
try {
await this.perform(data)
} catch (error) {
this.state.error = error instanceof Error ? error.message : 'Действие не выполнено'
throw new Error(this.state.error)
const message = error instanceof Error ? error.message : 'Действие не выполнено'
if (!isBackgroundRefresh || !this.state.error) this.state.error = message
throw new Error(message)
} finally {
this.state.busy = false
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(
() =>
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',
)
@@ -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
for (let refresh = 0; refresh < 5; refresh++) {
await poll(
@@ -312,13 +338,13 @@ module.exports = async function onlineSmoke({
await poll(
() =>
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('Список игр пока пуст')
})()`),
'updated game list rendered',
)
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(
() =>
@@ -361,7 +387,19 @@ module.exports = async function onlineSmoke({
'anywhere',
)
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 pkg = packages.find((p) => p.name === 'HTTP smoke')
assert.ok(pkg)
@@ -444,7 +482,10 @@ module.exports = async function onlineSmoke({
async () => (await state()).channels.main.status === 'playing',
'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(JSON.stringify(await state()).includes('PRIVATE'), false)
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 clickPlan('Пауза')
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 evaluate(`document.querySelector('[aria-label="Поднять шаг 3"]').click()`)
await clickPlan('Сохранить план')
@@ -664,7 +709,10 @@ module.exports = async function onlineSmoke({
'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 clickMusic('Создать плейлист')
await poll(() => library.some((p) => p.name === 'Создан из приложения'), 'playlist created')
@@ -740,9 +788,50 @@ module.exports = async function onlineSmoke({
assert.equal(afterOrder.desired, 'playing')
assert.ok(afterOrder.position >= beforeOrder.position)
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' })
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 }))
} finally {
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')
assert.equal((await state()).channels.main.desired, 'paused')
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 poll(
async () => (await state()).channels.main.status === 'stopped',
+22 -50
View File
@@ -1,5 +1,5 @@
<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 { DEFAULT_SERVER, type OnlineIntent, type OnlineState } from '../types/online'
@@ -9,6 +9,7 @@ const props = defineProps<{
isBusy: boolean
selectedKey: string | null
selectedName: string | null
canConnectCurrent: boolean
}>()
const emit = defineEmits<{ action: [intent: OnlineIntent]; cancel: [] }>()
const bridge = window.screenBridge
@@ -20,6 +21,7 @@ watch(
() => props.state.server,
(value) => {
server.value = value
gameId.value = null
},
{ immediate: true },
)
@@ -27,6 +29,7 @@ watch(
() => props.state.authenticated,
() => {
password.value = ''
if (!props.state.authenticated) gameId.value = null
},
)
function run(intent: OnlineIntent) {
@@ -87,8 +90,7 @@ const connectionLabels = {
<template v-else>
<p>{{ state.server }}</p>
<n-alert v-if="!state.remembered" type="warning"
>Хранилище секретов ОС недоступно. Вход действует до закрытия приложения; материалы
сохраняются.</n-alert
>Вход не сохранён: после перезапуска потребуется войти снова.</n-alert
>
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'logout' })"
>Выйти</n-button
@@ -113,6 +115,7 @@ const connectionLabels = {
>
<n-select
v-model:value="gameId"
clearable
:options="
state.games.map((g) => ({
label: `${g.name} · ${gameStatusLabels.get(g.status) ?? 'Неизвестный статус'}`,
@@ -120,56 +123,28 @@ const connectionLabels = {
}))
"
:disabled="isBusy || state.busy"
placeholder="Выберите игру для подготовки"
placeholder="Выберите игру"
: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
:disabled="gameId === null || isBusy || state.busy"
@click="run({ action: 'connect', gameId: gameId! })"
>Подключить выбранную игру</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
@click="run({ action: 'download', gameId: gameId! })"
>Загрузить материалы</n-button
>
</template>
<n-button
v-if="state.screenId"
:disabled="isBusy || state.busy"
@click="run({ action: 'results' })"
>Обновить снимок результатов</n-button
>
<p v-if="selectedKey" data-testid="control-game">Текущая игра: {{ selectedName }}</p>
<p v-if="state.preparation.active" role="status" data-testid="materials-progress">
Подготовка материалов: {{ state.preparation.completed }} /
{{ state.preparation.total }} файлов; текущая загрузка
{{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ.
Материалы: {{ state.preparation.completed }}/{{ state.preparation.total }} ·
{{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ
</p>
<n-alert v-if="state.preparation.error" type="warning" class="connection-error">
{{ state.preparation.error }}. Прежний показ сохраняется до готовности замены.
{{ state.preparation.error }}
<n-button
:disabled="isBusy || state.busy || state.preparation.active"
@click="run({ action: 'retry-materials' })"
@@ -177,12 +152,9 @@ const connectionLabels = {
>
</n-alert>
<template v-if="state.busy">
<p role="status">
{{
state.progress
? `Файлов: ${state.progress.completedFiles}/${state.progress.totalFiles}, загружено ${(state.progress.downloadedBytes / 1048576).toFixed(1)} МиБ`
: 'Выполняется запрос…'
}}
<p v-if="state.progress" role="status">
Файлов: {{ state.progress.completedFiles }}/{{ state.progress.totalFiles }} ·
{{ (state.progress.downloadedBytes / 1048576).toFixed(1) }} МиБ
</p>
<n-button v-if="state.canCancel" @click="cancel">Отменить загрузку</n-button>
</template>
+28 -79
View File
@@ -28,6 +28,7 @@ import MusicPlanPanel from './MusicPlanPanel.vue'
import { useOnlineConnection } from '../composables/useOnlineConnection'
import ScreenPlanPanel from './ScreenPlanPanel.vue'
import { useScreenTimer } from '../composables/useScreenTimer'
import { playbackControl } from '../utils/playbackControl'
const {
state: onlineState,
@@ -63,12 +64,6 @@ const previewURL = computed(() =>
? preview.value?.dataURL
: null,
)
const sceneLabels = {
idle: 'Окно показа закрыто',
pending: 'Подготовка показа',
ready: 'Показ готов',
error: 'Не удалось подготовить показ',
}
const timerLabels = {
stopped: 'Не запущен',
running: 'Идёт',
@@ -105,15 +100,18 @@ async function refreshPreview() {
}
const channel = 'music' as const
const musicControl = computed(() =>
playbackControl(state.value?.channels.music ?? { desired: 'stopped', status: 'idle' }),
)
const playlist = computed(() =>
state.value?.catalog.playlists.find((p) => p.id === state.value?.channels.music.playlistId),
)
const statusLabels: Record<Playback, string> = {
idle: 'Ресурс не выбран',
pending: 'Подготовка или выполнение команды',
playing: 'Воспроизведение подтверждено',
idle: 'Файл не выбран',
pending: 'Загрузка…',
playing: 'Играет',
paused: 'Пауза',
stopped: 'Готово, остановлено',
stopped: 'Остановлено',
ended: 'Завершено',
error: 'Ошибка',
}
@@ -212,18 +210,6 @@ async function refreshHost() {
if (display.value === null) display.value = host.value.preferredDisplayId
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 () => {
await run(async () => {
if (!bridge) throw new Error('Откройте пульт в приложении Electron')
@@ -254,7 +240,7 @@ onUnmounted(() => {
<template>
<main class="control">
<h1>Вечерний детектив · Локальный проигрыватель</h1>
<h1>Вечерний детектив</h1>
<n-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert>
<connection-panel
:state="onlineState"
@@ -264,22 +250,13 @@ onUnmounted(() => {
@cancel="cancelOnline"
:selected-key="state?.catalog.key ?? 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-space vertical>
<n-alert v-if="host.notice" type="warning">{{ host.notice }}</n-alert>
<p role="status">
{{
host.sleepBlocked
? 'Автоматический сон заблокирован на время показа'
: 'Блокировка сна выключена'
}}
</p>
<p>
Перед игрой выберите аудиовыход в настройках звука ОС, проверьте громкость и выполните
пробный пуск. После подключения HDMI проверьте выход повторно: система может переключить
звук на проектор.
</p>
<n-select
v-model:value="display"
: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
>
<ScreenConnectionPanel
:state="onlineState"
:is-busy="isOnlineBusy"
:output-open="state?.outputOpen ?? false"
@action="runOnline"
/>
<ScreenConnectionPanel :state="onlineState" :is-busy="isOnlineBusy" @action="runOnline" />
<template v-if="state">
<label class="mute"
>Выключить весь звук
@@ -325,14 +297,10 @@ onUnmounted(() => {
: 'Предпросмотр недоступен'
}}
</div>
<p>Кадр реального окна показа, без второго звука.</p>
</template>
</n-space>
</n-card>
<template v-if="state">
<n-alert v-if="!state.outputOpen" type="info"
>Откройте показ, чтобы подготовить медиа и включить воспроизведение.</n-alert
>
<section class="presentation-grid">
<n-card title="План экранов">
<n-space vertical>
@@ -343,6 +311,9 @@ onUnmounted(() => {
:is-editing="isPlanEditing"
:is-busy="isBusy || isPlanSaving"
: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"
:notice="planStatus.key === state.catalog.key ? planStatus.notice : ''"
@edit="editPlan"
@@ -391,23 +362,19 @@ onUnmounted(() => {
<n-alert v-if="state.channels.main.error" type="error">{{
state.channels.main.error
}}</n-alert>
<details>
<summary>Программные замеры запуска</summary>
<p>{{ metrics('main') }}</p>
<p>
Приём команды → подтверждение проигрывателя. Физический звук и первый кадр
проектора здесь не измеряются.
</p>
</details>
</template>
</ScreenPlanPanel>
<p role="status">{{ sceneLabels[state.scene.status] }}</p>
<p v-if="state.timer.durationMs > 0" role="status" class="timer-status">
<p v-if="state.scene.status === 'pending'" role="status">Подготовка показа…</p>
<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] }}
</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-button
v-if="state.scene.status === 'error'"
@@ -464,18 +431,8 @@ onUnmounted(() => {
<n-space>
<n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@click="send({ action: 'play', channel })"
>Пуск с начала</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
@click="send({ action: musicControl.action, channel })"
>{{ musicControl.label }}</n-button
>
<n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@@ -485,7 +442,7 @@ onUnmounted(() => {
<n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@click="send({ action: 'replay', channel })"
>Повторить с начала</n-button
>Сначала</n-button
>
<n-button
:disabled="!state.outputOpen || !state.channels.music.source || isBusy"
@@ -521,14 +478,6 @@ onUnmounted(() => {
<n-alert v-if="state.channels[channel].error" type="error">{{
state.channels[channel].error
}}</n-alert>
<details>
<summary>Программные замеры запуска</summary>
<p>{{ metrics(channel) }}</p>
<p>
Приём команды → подтверждение проигрывателя. Физический звук и первый кадр проектора
здесь не измеряются.
</p>
</details>
</n-space>
</n-card>
</template>
-2
View File
@@ -94,7 +94,6 @@ function remove(trackId?: number) {
<n-space vertical data-testid="music-plan-editor">
<p v-if="!canEdit">Для редактирования библиотеки подключитесь к серверу.</p>
<template v-else>
<n-button :disabled="isDisabled" @click="refresh">Обновить плейлисты</n-button>
<label for="new-playlist-name">Новый плейлист</label>
<n-input
v-model:value="newName"
@@ -138,7 +137,6 @@ function remove(trackId?: number) {
>Использовать в игре</n-button
>
</n-space>
<p>Выбор в редакторе не меняет музыку игры. MP3 — до 64 МиБ.</p>
<p v-if="!order.length">Треков пока нет.</p>
<ol class="track-list">
<li v-for="(id, index) in order" :key="id">
+4 -21
View File
@@ -1,27 +1,14 @@
<script setup lang="ts">
import { NButton, NSpace } from 'naive-ui'
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] }>()
function run(intent: OnlineIntent) {
emit('action', intent)
}
const connectionLabels = {
disabled: 'Управление не подключено',
waiting: 'Ожидание ответа сервера',
connected: 'Связь с сервером есть',
offline: 'Нет связи; локальный показ продолжается',
expired: 'Сессия экрана недействительна; подключите управление заново',
}
</script>
<template>
<p v-if="state.screenId" role="status">
{{ connectionLabels[state.connection]
}}{{ state.screenId ? ` · Экран №${state.screenId}` : '' }}
</p>
<p v-if="state.screenId && !outputOpen">
Связь и загрузка материалов продолжаются; показ откройте вручную.
</p>
<p v-if="state.screenId">Экран №{{ state.screenId }}</p>
<template v-if="state.screenId">
<p role="status" data-testid="sound-assignment">
{{
@@ -32,13 +19,13 @@ const connectionLabels = {
: 'Экран не назначен для звука'
}}
<template v-if="state.soundEnabled !== null && state.connection !== 'connected'">
· Последнее подтверждённое состояние; сейчас назначение не проверяется.
· Последнее состояние
</template>
</p>
<p>Переключатель «Выключить весь звук» управляет локальным звуком отдельно.</p>
</template>
<n-space v-if="state.screenId">
<n-button
v-if="state.soundEnabled !== true"
:disabled="isBusy || state.busy || !state.authenticated"
@click="run({ action: 'sound' })"
>Назначить звук этому экрану</n-button
@@ -47,8 +34,4 @@ const connectionLabels = {
>Отключить управление</n-button
>
</n-space>
<p v-if="state.screenId">
«Отключить управление» прекращает управление приложением; привязка экрана на сервере
сохраняется.
</p>
</template>
+35 -25
View File
@@ -1,15 +1,20 @@
<script setup lang="ts">
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'
defineProps<{
const props = defineProps<{
steps: ScreenStep[]
currentIndex: number | null
draft: ScreenStep[]
isEditing: boolean
isBusy: boolean
canPlayMain: boolean
mainDesired: ChannelState['desired']
mainStatus: ChannelState['status']
timerStatus: TimerState['status']
canEdit: boolean
notice: string
}>()
@@ -24,6 +29,9 @@ const emit = defineEmits<{
updateStep: [index: number, patch: Partial<ScreenStep>]
command: [intent: Intent]
}>()
const mainControl = computed(() =>
playbackControl({ desired: props.mainDesired, status: props.mainStatus }),
)
const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
value: Number(mode) as ScreenMode,
label,
@@ -49,7 +57,7 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
</template>
</n-space>
<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">
<n-space v-for="(step, index) in draft" :key="index" vertical class="plan-step">
<span>Шаг {{ index + 1 }}</span>
@@ -111,7 +119,7 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
</n-space>
</template>
<template v-else>
<p v-if="!steps.length">План экранов ещё не создан. Создайте план, чтобы настроить экраны.</p>
<p v-if="!steps.length">План экранов пуст.</p>
<n-space
v-for="(step, index) in steps"
:key="index"
@@ -136,15 +144,27 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
>{{ currentIndex === index ? 'Показан' : 'Показать' }}</n-button
>
<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="emit('command', { action: 'timer-pause' })"
>Пауза</n-button
>
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-resume' })"
>Продолжить</n-button
<n-button
:disabled="isBusy"
@click="
emit('command', {
action:
timerStatus === 'running'
? 'timer-pause'
: timerStatus === 'paused'
? 'timer-resume'
: 'timer-start',
})
"
>
{{
timerStatus === 'running'
? 'Пауза'
: timerStatus === 'paused'
? 'Продолжить'
: 'Старт'
}}
</n-button>
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-reset' })"
>Сбросить</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)">
<n-button
:disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'play', channel: 'main' })"
>Запуск</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
@click="emit('command', { action: mainControl.action, channel: 'main' })"
>{{ mainControl.label }}</n-button
>
<n-button
:disabled="isBusy || !canPlayMain"
@@ -173,7 +183,7 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
<n-button
:disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'replay', channel: 'main' })"
>Повтор</n-button
>Сначала</n-button
>
</template>
</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: 'Запуск' }
}