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