This commit is contained in:
2026-10-23 03:02:55 +07:00
parent e2f8a3302e
commit 1ce8efeef5
21 changed files with 561 additions and 46 deletions
+15 -4
View File
@@ -90,9 +90,9 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
## Проведение показа
В «План экранов» нажмите «Показать» у нужного шага, используйте «Предыдущий шаг» / «Следующий шаг»
для переходов по плану. В редакторе шагов доступны шесть режимов: «Обложка»,
для переходов по плану. В редакторе шагов доступны восемь режимов: «Обложка»,
«Введение», «Закрывашка»,
«Результаты», «Таймер» и «Текст». Переход к шагу или режиму сам не запускает
«Результаты», «Таймер», «Текст», «QR-код» и «Список команд». Переход к шагу или режиму сам не запускает
медиа и таймер. Дождитесь готовности; отправленная команда ещё не означает
успешного воспроизведения — смотрите статус и фактическое изображение/звук.
@@ -159,11 +159,22 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
Используемый игрой или сценарием плейлист и его треки удалить нельзя.
Редактирование требует сети; сохранённая музыка продолжает играть без неё.
### Текст, таймер и результаты
### Текст, QR-код, команды, таймер и результаты
- В редакторе плана добавьте шаг «Текст», заполните текст и сохраните план.
Нажмите «Показать» у этого шага. Переносы строк сохраняются.
- Для шага «Таймер» задайте надпись и длительность в секундах, сохраните план
- Для шага «QR-код» укажите ссылку HTTP/HTTPS (обязательно) и поясняющий текст
(необязательно). Ссылка кодируется в QR; текст показывается рядом. После
сохранения плана этот экран работает без интернета.
- Шаг «Список команд» показывает названия команд текущей игры по алфавиту,
исключая отменённые регистрации. Пока экран открыт, список обновляется каждые
пять секунд. Последний полученный список сохраняется отдельно для каждой игры
и доступен без сети, в том числе после перезапуска приложения. Перед выездом
откройте этот шаг при подключении к серверу, чтобы сохранить актуальный список.
- Для шага «Таймер» выберите тип: «До начала игры», «Перерыв» или «До конца игры».
Надпись подставляется автоматически и доступна для редактирования. Смена типа
сохраняет пользовательскую надпись; стандартную заменяет на надпись нового типа.
Задайте длительность в секундах, сохраните план
и покажите шаг. В активном шаге одна кнопка меняется между «Старт», «Пауза» и «Продолжить»;
рядом доступно «Сбросить». При смене шага отсчёт продолжается в фоне;
на нуле остаётся 00:00 без сигнала и автоматического перехода.
+1 -1
View File
@@ -25,7 +25,7 @@ test('клиент proto задаёт маршруты, методы и JSON enu
],
})
await client.SaveScreenPlan({ gameID: 7, ...apiPlan(plan) })
await client.SetScreenMode({ gameID: 7, mode: apiScreenMode(2) })
await client.SetScreenMode({ gameID: 7, mode: apiScreenMode(2), qrUrl: undefined, text: undefined })
await client.SendScreenCommand({
gameID: 7,
command: apiScreenCommand(6),
+21 -1
View File
@@ -14,6 +14,8 @@ const modes = [
'SCREEN_MODE_RESULTS',
'SCREEN_MODE_TIMER',
'SCREEN_MODE_TEXT',
'SCREEN_MODE_QR',
'SCREEN_MODE_TEAMS',
] as const satisfies readonly (ScreenMode | undefined)[]
const commands = [
'SCREEN_COMMAND_UNSPECIFIED',
@@ -40,5 +42,23 @@ export function apiScreenCommand(command: number): ScreenCommand {
return value
}
export function apiPlan(plan: ScreenPlan): Pick<SaveScreenPlanReq, 'steps' | 'currentStep'> {
return { ...plan, steps: plan.steps.map((step) => ({ ...step, mode: apiScreenMode(step.mode) })) }
return {
...plan,
steps: plan.steps.map((step) => ({
...step,
mode: apiScreenMode(step.mode),
qrUrl: step.qrUrl,
timerType: apiTimerType(step.timerType),
})),
}
}
export function apiTimerType(value = 0) {
return (
[
'SCREEN_TIMER_TYPE_BREAK',
'SCREEN_TIMER_TYPE_BEFORE_GAME',
'SCREEN_TIMER_TYPE_UNTIL_END',
] as const
)[value]!
}
@@ -9,7 +9,13 @@ export type ScreenMode =
| "SCREEN_MODE_CONCLUSION"
| "SCREEN_MODE_RESULTS"
| "SCREEN_MODE_TIMER"
| "SCREEN_MODE_TEXT";
| "SCREEN_MODE_TEXT"
| "SCREEN_MODE_QR"
| "SCREEN_MODE_TEAMS";
export type ScreenTimerType =
| "SCREEN_TIMER_TYPE_BREAK"
| "SCREEN_TIMER_TYPE_BEFORE_GAME"
| "SCREEN_TIMER_TYPE_UNTIL_END";
export type ScreenTimerState =
| "SCREEN_TIMER_STATE_UNSPECIFIED"
| "SCREEN_TIMER_STATE_STOPPED"
@@ -568,6 +574,8 @@ export type Game = {
format: string | undefined;
location: string | undefined;
export: GameExport | undefined;
wifiName: string | undefined;
wifiPassword: string | undefined;
};
// Download ready ZIP through the authenticated streaming HTTP route:
@@ -629,6 +637,8 @@ export type ScreenState = {
timerState: ScreenTimerState | undefined;
text: string | undefined;
music: MusicState | undefined;
qrUrl: string | undefined;
timerType: ScreenTimerType | undefined;
};
export type MusicState = {
@@ -760,6 +770,8 @@ export type UpdateGameMapItemRsp = {
export type SetScreenModeReq = {
gameID: number | undefined;
mode: ScreenMode | undefined;
qrUrl: string | undefined;
text: string | undefined;
};
export type SetScreenModeRsp = {
@@ -769,6 +781,7 @@ export type SetScreenTimerReq = {
gameID: number | undefined;
durationSeconds: number | undefined;
label: string | undefined;
timerType: ScreenTimerType | undefined;
};
export type SetScreenTimerRsp = {
@@ -789,6 +802,8 @@ export type ScreenPlanStep = {
text: string | undefined;
timerLabel: string | undefined;
timerDurationSeconds: number | undefined;
qrUrl: string | undefined;
timerType: ScreenTimerType | undefined;
};
export type ScreenPlan = {
@@ -823,6 +838,20 @@ export type SetScreenPlanStepRsp = {
plan: ScreenPlan | undefined;
};
export type ScreenTeam = {
ID: string | undefined;
name: string | undefined;
};
export type GetScreenTeamsReq = {
screenID: string | undefined;
accessToken: string | undefined;
};
export type GetScreenTeamsRsp = {
teams: ScreenTeam[] | undefined;
};
export type GetScreenResultsReq = {
screenID: string | undefined;
accessToken: string | undefined;
@@ -968,6 +997,8 @@ export type UpdateGameReq = {
scenarioID: number | undefined;
format: string | undefined;
location: string | undefined;
wifiName?: string;
wifiPassword?: string;
};
export type UpdateGameRsp = {
@@ -1467,6 +1498,7 @@ export interface EveningDetectiveServer {
GetScreenPlan(request: GetScreenPlanReq): Promise<GetScreenPlanRsp>;
SaveScreenPlan(request: SaveScreenPlanReq): Promise<SaveScreenPlanRsp>;
SetScreenPlanStep(request: SetScreenPlanStepReq): Promise<SetScreenPlanStepRsp>;
GetScreenTeams(request: GetScreenTeamsReq): Promise<GetScreenTeamsRsp>;
GetScreenResults(request: GetScreenResultsReq): Promise<GetScreenResultsRsp>;
DownloadGamePrintMaterials(request: GetGameReq): Promise<googleapi_HttpBody>;
PreviewTeamReceipt(request: TeamReceiptReq): Promise<googleapi_HttpBody>;
@@ -3159,6 +3191,23 @@ export function createEveningDetectiveServerClient(
method: "SetScreenPlanStep",
}) as Promise<SetScreenPlanStepRsp>;
},
GetScreenTeams(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/screens/teams`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "POST",
body,
}, {
service: "EveningDetectiveServer",
method: "GetScreenTeams",
}) as Promise<GetScreenTeamsRsp>;
},
GetScreenResults(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/screens/results`; // eslint-disable-line quotes
const body = JSON.stringify(request);
+39 -10
View File
@@ -13,7 +13,9 @@ import type {
TimerState,
ScreenStep,
} from '../src/types/screen.js'
import { parsePlan } from './screen-plan.js'
import { timerLabels, validQR } from '../src/utils/presentation.js'
import { parseTeams } from './teams.js'
import { parsePlan, parseTimerType } from './screen-plan.js'
import { parseResults } from './results.js'
import type { LocalPackage } from './package-store.js'
@@ -36,10 +38,12 @@ export function parseCommand(value: unknown): Command {
else if (
c.action === 'mode' &&
Number.isInteger(c.mode) &&
number(c.mode, 1, 8) &&
[1, 2, 4, 6, 7, 8].includes(c.mode)
number(c.mode, 1, 10) &&
[1, 2, 4, 6, 7, 8, 9, 10].includes(c.mode)
)
intent = { action: 'mode', mode: c.mode as ScreenMode }
else if (c.action === 'qr' && typeof c.qrUrl === 'string' && typeof c.text === 'string' && validQR(c.qrUrl, c.text))
intent = { action: 'qr', qrUrl: c.qrUrl, text: c.text }
else if (c.action === 'step' && Number.isSafeInteger(c.index) && number(c.index, 0))
intent = { action: 'step', index: c.index }
else if (
@@ -58,8 +62,9 @@ export function parseCommand(value: unknown): Command {
)
intent = {
action: 'timer-set',
label: c.label.trim() || 'Перерыв',
label: c.label.trim() || timerLabels[parseTimerType(c.timerType)],
durationSeconds: c.durationSeconds,
timerType: parseTimerType(c.timerType),
}
else if (
typeof c.action === 'string' &&
@@ -178,6 +183,7 @@ export class Controller {
scene: { revision: 0, mode: 1, stepIndex: null, text: '', status: 'idle', error: '' },
timer: initialTimer(),
results: null,
teams: null,
channels: { main: initialChannel(), music: initialChannel() },
samples: [],
}
@@ -301,6 +307,7 @@ export class Controller {
this.state.timer = initialTimer()
this.timerDeadline = null
this.state.results = null
this.state.teams = null
this.state.channels = {
main: { ...initialChannel(), source: catalog.media[0] ?? null },
music: initialChannel(),
@@ -372,11 +379,12 @@ export class Controller {
this.broadcast()
}
}
private configureTimer(label: string, seconds: number) {
private configureTimer(label: string, seconds: number, timerType = 0) {
this.timerDeadline = null
this.state.timer = {
revision: this.state.timer.revision + 1,
label,
timerType: parseTimerType(timerType),
durationMs: seconds * 1000,
remainingMs: seconds * 1000,
status: 'stopped',
@@ -436,10 +444,17 @@ export class Controller {
markStep(index: number) {
const step = this.state.catalog.steps[index]
const scene = this.state.scene
if (!step || step.mode !== scene.mode || (step.mode === 8 && step.text !== scene.text)) return
if (
!step ||
step.mode !== scene.mode ||
([8, 9].includes(step.mode) && step.text !== scene.text) ||
(step.mode === 9 && step.qrUrl !== scene.qrUrl)
)
return
if (
step.mode === 7 &&
(step.timerLabel !== this.state.timer.label ||
((step.timerType ?? 0) !== (this.state.timer.timerType ?? 0) ||
step.timerLabel !== this.state.timer.label ||
step.timerDurationSeconds * 1000 !== this.state.timer.durationMs)
)
return
@@ -447,6 +462,15 @@ export class Controller {
scene.stepIndex = index
this.broadcast()
}
setTeams(value: unknown) {
const key = this.state.catalog.key
if (value !== null && !key) throw new Error('Сначала выберите пакет игры')
const snapshot = value === null ? null : parseTeams(value, key!)
const changed = !isDeepStrictEqual(this.state.teams?.teams, snapshot?.teams)
this.state.teams = snapshot
if (changed && this.state.scene.mode === 10) this.invalidateScene()
this.broadcast()
}
setResults(value: unknown) {
const key = this.state.catalog.key
if (value !== null && !key) throw new Error('Сначала выберите пакет игры')
@@ -487,6 +511,10 @@ export class Controller {
case 'mode':
this.mode(command.mode)
return true
case 'qr':
this.state.scene.qrUrl = command.qrUrl
this.mode(9, null, command.text)
return true
case 'text':
this.mode(8, null, command.text)
return true
@@ -494,12 +522,13 @@ export class Controller {
const step = this.state.catalog.steps[command.index]
if (!step) throw new Error('Шаг плана не найден')
if (step.mode === 7)
this.configureTimer(step.timerLabel || 'Перерыв', step.timerDurationSeconds)
this.configureTimer(step.timerLabel || timerLabels[step.timerType ?? 0], step.timerDurationSeconds, step.timerType)
this.state.scene.qrUrl = step.qrUrl ?? ''
this.mode(step.mode, command.index, step.text)
return true
}
case 'timer-set':
this.configureTimer(command.label, command.durationSeconds)
this.configureTimer(command.label, command.durationSeconds, command.timerType)
this.mode(7)
return true
case 'timer-start':
@@ -509,7 +538,7 @@ export class Controller {
this.updateTimer()
const t = this.state.timer
if (command.action === 'timer-reset') {
this.configureTimer(t.label, t.durationMs / 1000)
this.configureTimer(t.label, t.durationMs / 1000, t.timerType)
return true
}
if (command.action === 'timer-pause') {
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { Controller } from './controller.js'
import { parsePlan } from './screen-plan.js'
import { parseRemote, applyRemote } from './remote-state.js'
import { parseTeams } from './teams.js'
import { buildScreenPackage, parseScreenPackage } from './screen-package.js'
import { changeTimerType, validQR } from '../src/utils/presentation.js'
test('QR validation and timer captions preserve custom text', () => {
for (const url of ['', 'javascript:alert(1)', 'https:///missing', 'https://u:p@example.org'])
assert.equal(validQR(url), false)
assert.equal(validQR('https://example.org/?q=1#join'), true)
assert.equal(validQR('https://example.org', 'я'.repeat(5001)), false)
assert.equal(changeTimerType('Перерыв', 0, 1), 'До начала игры')
assert.equal(changeTimerType('До начала игры', 1, 2), 'До конца игры')
assert.equal(changeTimerType('Обсуждение', 0, 2), 'Обсуждение')
assert.equal(changeTimerType('', 2, 0), 'Перерыв')
const c = new Controller(() => {})
c.command({ session: c.state.session, sequence: 1, action: 'timer-set', timerType: 1, label: '', durationSeconds: 60 })
assert.equal(c.state.timer.label, 'До начала игры')
})
test('new screens survive plan/package round trip and local playback', () => {
const plan = parsePlan({
steps: [
{ mode: 'SCREEN_MODE_QR', qrUrl: 'https://example.org/join', text: 'Вступайте' },
{ mode: 'SCREEN_MODE_TEAMS' },
{ mode: 7, timerType: 'SCREEN_TIMER_TYPE_UNTIL_END', timerDurationSeconds: 60 },
],
})
assert.equal(plan.steps[2]!.timerLabel, 'До конца игры')
const pkg = buildScreenPackage('https://example.org', 1, {
game: { game: { ID: 1, name: 'Игра' } },
plan: { plan },
music: {},
})
assert.deepEqual(parseScreenPackage(pkg).steps, pkg.steps)
const c = new Controller(() => {})
c.setPlan(plan.steps)
const command = (index: number) =>
c.command({ session: c.state.session, sequence: c.state.sequence + 1, action: 'step', index })
command(0)
assert.equal(c.state.scene.qrUrl, 'https://example.org/join')
assert.equal(c.state.scene.text, 'Вступайте')
command(1)
assert.equal(c.state.scene.mode, 10)
command(2)
assert.equal(c.state.timer.timerType, 2)
assert.equal(c.state.timer.status, 'stopped')
assert.throws(() => parsePlan({ steps: [{ mode: 9, qrUrl: 'javascript:alert(1)' }] }))
assert.throws(() => parsePlan({ steps: [{ mode: 7, timerDurationSeconds: 60, timerType: 3 }] }))
const legacy = parsePlan({
steps: [{ mode: 7, timerLabel: 'Мой текст', timerDurationSeconds: 60 }],
})
assert.equal(legacy.steps[0]!.timerType ?? 0, 0)
assert.equal(legacy.steps[0]!.timerLabel, 'Мой текст')
})
test('remote QR changes and timer types reach the output controller', () => {
const c = new Controller(() => {})
const previous = parseRemote({ mode: 1, stateVersion: '1' })
const qr = parseRemote({
mode: 9,
stateVersion: '2',
qrUrl: 'https://example.org',
text: 'Текст',
})
applyRemote(c, previous, qr)
assert.equal(c.state.scene.qrUrl, qr.qrUrl)
const timer = parseRemote({
mode: 7,
stateVersion: '3',
timerType: 'SCREEN_TIMER_TYPE_BEFORE_GAME',
})
applyRemote(c, qr, timer)
assert.equal(c.state.timer.timerType, 1)
})
test('team snapshots sort names, strip private fields and reject another game', () => {
const packageKey = 'a'.repeat(64)
const snapshot = {
packageKey,
capturedAt: '2026-10-23T12:00:00Z',
teams: [
{ id: '2', name: 'Ястребы', password: 'SECRET' },
{ id: '1', name: 'Агенты' },
],
}
const parsed = parseTeams(snapshot, packageKey)
assert.deepEqual(
parsed.teams.map((team) => team.name),
['Агенты', 'Ястребы'],
)
assert.equal(JSON.stringify(parsed).includes('SECRET'), false)
assert.throws(() => parseTeams(snapshot, 'b'.repeat(64)))
assert.throws(() =>
parseTeams({ ...snapshot, teams: [snapshot.teams[0], snapshot.teams[0]] }, packageKey),
)
assert.equal(parseTeams({ ...snapshot, teams: [] }, packageKey).teams.length, 0)
})
+26
View File
@@ -68,6 +68,8 @@ async function fixture(t: TestContext, secure = true) {
refreshResponse: null as (() => Promise<Response>) | null,
advanceCalls: 0,
resultError: false,
teamsError: false,
screenTeams: [{ ID: '2', name: 'Ястребы' }, { ID: '1', name: 'Агенты' }],
title: 'Дело',
gameStatus: 'finish',
gameError: 0,
@@ -138,6 +140,7 @@ async function fixture(t: TestContext, secure = true) {
model.advanceCalls++
return json({ state: objectMusic(3, 1) })
}
if (path === '/api/screens/teams') return model.teamsError ? json({}, 500) : json({ teams: model.screenTeams })
if (path === '/api/screens/results') {
if (model.resultError) return json({}, 400)
return json({
@@ -224,6 +227,8 @@ async function fixture(t: TestContext, secure = true) {
...model.remote,
mode: step.mode,
text: step.text,
qrUrl: step.qrUrl,
timerType: step.timerType,
command: 0,
timerLabel: step.timerLabel,
timerDurationSeconds: step.timerDurationSeconds || 600,
@@ -2187,3 +2192,24 @@ test('статус опрашивается раз в две секунды и
assert.equal(count(), before + 1)
assert.equal(f.online.snapshot().game?.status, 'run')
})
test('список команд обновляется, сохраняется для офлайна и не переносится в другую игру', async (t) => {
const f = await fixture(t)
await f.prepare()
await f.online.refreshTeams()
const key = f.controller.state.catalog.key!
assert.deepEqual(f.controller.state.teams?.teams.map(team => team.name), ['Агенты', 'Ястребы'])
f.model.teamsError = true
await assert.rejects(f.online.refreshTeams())
assert.equal(f.controller.state.teams?.teams.length, 2)
await f.online.close()
const c = new Controller(() => {})
const restored = new Online(join(f.directory, 'connection'), f.store, c, f.secrets, f.fetchAPI)
t.after(() => restored.close())
f.model.offline = true
await restored.initialize()
assert.equal(c.state.catalog.key, key)
assert.deepEqual(c.state.teams?.teams.map(team => team.name), ['Агенты', 'Ястребы'])
await restored.select(null)
assert.equal(c.state.teams, null)
})
+67 -7
View File
@@ -1,6 +1,6 @@
import { requestErrorContext } from './request-error.js'
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
import { apiScreenMode, apiScreenCommand, apiPlan } from './api-values.js'
import { apiScreenMode, apiScreenCommand, apiPlan, apiTimerType } from './api-values.js'
import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js'
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
@@ -24,6 +24,7 @@ import type {
import type { Controller } from './controller.js'
import { PackageStore, packageKey } from './package-store.js'
import { INCOMPLETE_MATERIALS_WARNING, type ScreenPackage, prepareScreenPackage, resolveMediaURL } from './screen-package.js'
import { parseTeams } from './teams.js'
import { parseResults } from './results.js'
import { parsePlan } from './screen-plan.js'
import {
@@ -482,6 +483,8 @@ export class Online {
private pollBusy = false
private pollSession = ''
private lastSound: boolean | null = null
private lastTeams = -Infinity
private teamsBusy = false
private lastResults = -Infinity
private resultsBusy = false
private advanceBusy = false
@@ -607,6 +610,7 @@ export class Online {
if (intent.action === 'step')
mode = this.controller.state.catalog.steps[intent.index]?.mode ?? mode
if (intent.action === 'text') mode = 8
if (intent.action === 'qr') mode = 9
if (intent.action === 'timer-set') mode = 7
if (intent.action === 'select') mode = intent.sourceId === 'intro' ? 2 : 4
return [
@@ -664,7 +668,7 @@ export class Online {
if (generation !== this.generation || session !== this.controller.state.session) return
if (
version !== this.planVersion &&
['step', 'mode', 'text', 'select', 'timer-set', 'playlist', 'track', 'next'].includes(
['step', 'mode', 'text', 'qr', 'select', 'timer-set', 'playlist', 'track', 'next'].includes(
intent.action,
)
)
@@ -711,10 +715,12 @@ export class Online {
? 'MUSIC_PLAYBACK_PAUSED'
: 'MUSIC_PLAYBACK_STOPPED',
})
} else if (intent.action === 'mode' || intent.action === 'select') {
} else if (intent.action === 'mode' || intent.action === 'select' || intent.action === 'qr') {
await this.client(true, AbortSignal.timeout(5000)).SetScreenMode({
gameID: pkg.package.game.id,
mode: apiScreenMode(localState.scene.mode),
qrUrl: localState.scene.qrUrl,
text: localState.scene.text,
})
} else if (intent.action === 'text') {
const current = parsePlan(
@@ -760,6 +766,7 @@ export class Online {
gameID: pkg.package.game.id,
label: intent.label,
durationSeconds: intent.durationSeconds,
timerType: apiTimerType(intent.timerType),
})
} else {
const index = this.controller.state.scene.stepIndex
@@ -821,9 +828,10 @@ export class Online {
!isMusicCommand &&
(remote.command !== expectedCommand ||
remote.mode !== scene.mode ||
(remote.mode === 8 && remote.text !== scene.text) ||
([8, 9].includes(remote.mode) && remote.text !== scene.text) ||
(remote.mode === 9 && remote.qrUrl !== scene.qrUrl) ||
(remote.mode === 7 &&
(remote.timer.label !== this.controller.state.timer.label ||
(remote.timer.timerType !== (this.controller.state.timer.timerType ?? 0) || remote.timer.label !== this.controller.state.timer.label ||
remote.timer.durationMs !== this.controller.state.timer.durationMs)) ||
(intent.action === 'seek' && remote.position !== intent.position) ||
(intent.action === 'volume' && remote.volume !== intent.volume) ||
@@ -1098,7 +1106,11 @@ export class Online {
}
}
this.lastResults = -Infinity
if (key) await this.restoreResults(key)
this.lastTeams = -Infinity
if (key) {
await this.restoreResults(key)
await this.restoreTeams(key)
}
if (persist) {
await this.saveSelection()
await this.persistSecrets()
@@ -1462,6 +1474,7 @@ export class Online {
await this.atomic(`plan-${key}.json`, this.cachedPlan)
this.previous = null
await this.restoreResults(key)
await this.restoreTeams(key)
if (action === 'select-game') {
await this.saveSelection()
await this.persistSecrets()
@@ -1483,6 +1496,7 @@ export class Online {
throw new Error('Сначала выберите другой пакет или тестовый режим')
await this.packages.remove(key)
await rm(join(this.directory, `results-${key}.json`), { force: true })
await rm(join(this.directory, `teams-${key}.json`), { force: true })
await rm(join(this.directory, `plan-${key}.json`), { force: true })
this.state.packagesRevision++
return
@@ -1504,7 +1518,7 @@ export class Online {
await this.persistSecrets()
await this.packages.clear()
for (const name of await readdir(this.directory)) {
if (/^(plan|results)-[a-f0-9]{64}\.json(?:\.tmp)?$/.test(name))
if (/^(plan|results|teams)-[a-f0-9]{64}\.json(?:\.tmp)?$/.test(name))
await rm(join(this.directory, name), { force: true })
}
this.state.notice = 'Все сохранённые игры удалены'
@@ -1688,6 +1702,48 @@ export class Online {
return { id: integer(g.ID, 1), name: text(g.name), status: text(g.status ?? '') }
})
}
private async restoreTeams(key: string) {
try {
const snapshot = parseTeams(
JSON.parse(await readFile(join(this.directory, `teams-${key}.json`), 'utf8')),
key,
)
if (this.controller.state.catalog.key === key) this.controller.setTeams(snapshot)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT')
this.state.error = 'Сохранённый список команд недоступен; подключитесь к игре'
}
}
async refreshTeams() {
if (this.teamsBusy) return
const screen = this.screen
const key = this.controller.state.catalog.key
if (!screen || !key) return
const generation = this.generation
this.teamsBusy = true
this.lastTeams = Date.now()
try {
const result = await this.client(false, AbortSignal.timeout(5000)).GetScreenTeams({
screenID: screen.id,
accessToken: screen.token,
})
if (generation !== this.generation || key !== this.controller.state.catalog.key) return
if (!Array.isArray(result.teams ?? [])) throw new Error('Некорректный список команд')
const snapshot = parseTeams(
{
packageKey: key,
capturedAt: new Date().toISOString(),
teams: (result.teams ?? []).map((team) => ({ id: team.ID, name: team.name })),
},
key,
)
await this.atomic(`teams-${key}.json`, JSON.stringify(snapshot))
if (generation === this.generation && key === this.controller.state.catalog.key)
this.controller.setTeams(snapshot)
} finally {
this.teamsBusy = false
}
}
private async restoreResults(key: string) {
try {
this.controller.setResults(
@@ -1799,6 +1855,10 @@ export class Online {
Date.now() - this.lastMaterials >= 5000
)
void this.refreshMaterials()
if ((next.mode === 10 || this.controller.state.scene.mode === 10) && Date.now() - this.lastTeams >= 5000)
void this.refreshTeams().catch(error => {
if (generation === this.generation) this.state.error = error instanceof Error ? error.message : 'Список команд недоступен'
})
if (next.mode === 6 && Date.now() - this.lastResults >= 5000)
void this.refreshResults().catch((error) => {
if (generation === this.generation)
+1 -1
View File
@@ -213,7 +213,7 @@ test('не допускает пустой текст и некорректны
{ action: 'timer-set', label: 'x'.repeat(201), durationSeconds: 10 },
{ action: 'mode', mode: 3 },
{ action: 'mode', mode: 5 },
{ action: 'mode', mode: 9 },
{ action: 'mode', mode: 11 },
{ action: 'mode', mode: NaN },
{ action: 'step', index: -1 },
{ action: ['timer-start'] },
+17 -4
View File
@@ -1,3 +1,6 @@
import { parseTimerType } from './screen-plan.js'
import { timerLabels, validQR } from '../src/utils/presentation.js'
import type { TimerType } from '../src/types/screen.js'
import { fieldError, ResponseFieldError } from './request-error.js'
import type { Controller } from './controller.js'
import type { Intent, ScreenMode } from '../src/types/screen.js'
@@ -64,8 +67,10 @@ export interface RemoteState {
volume: number
sound: boolean
text: string
qrUrl: string
timer: {
label: string
timerType?: TimerType
durationMs: number
remainingMs: number
status: 'stopped' | 'running' | 'paused' | 'finished'
@@ -99,11 +104,15 @@ export function parseRemote(value: unknown): RemoteState {
'RESULTS',
'TIMER',
'TEXT',
'QR',
'TEAMS',
],
'state.mode',
) || 1
if (![1, 2, 4, 6, 7, 8].includes(mode))
if (![1, 2, 4, 6, 7, 8, 9, 10].includes(mode))
throw fieldError('state.mode', 'актуальный режим экрана', s.mode, 'Некорректный режим экрана')
const qrUrl = text(s.qrUrl ?? '', 'state.qrUrl')
if (mode === 9 && !validQR(qrUrl, text(s.text ?? ''))) throw new Error('Некорректные параметры QR-кода')
const duration = integer(s.timerDurationSeconds ?? 600, 1, 86400, 'state.timerDurationSeconds')
const remaining = integer(
s.timerRemainingSeconds ?? 0,
@@ -192,8 +201,10 @@ export function parseRemote(value: unknown): RemoteState {
volume: integer(s.volume ?? 0, 0, 100, 'state.volume'),
sound: s.soundEnabled === true,
text: text(s.text ?? '', 'state.text').slice(0, 10000),
qrUrl,
timer: {
label: text(s.timerLabel ?? 'Перерыв', 'state.timerLabel').slice(0, 200),
timerType: parseTimerType(s.timerType),
label: text(s.timerLabel ?? timerLabels[parseTimerType(s.timerType)], 'state.timerLabel').slice(0, 200),
durationMs: duration * 1000,
remainingMs: remaining * 1000,
status: (['stopped', 'stopped', 'running', 'paused', 'finished'] as const)[timerState]!,
@@ -237,11 +248,13 @@ function applyRemoteState(controller: Controller, previous: RemoteState, next: R
(next.command >= 8 && controller.state.scene.mode !== 7) ||
next.mode !== previous.mode ||
next.text !== previous.text ||
next.qrUrl !== previous.qrUrl ||
(next.mode === 7 &&
(next.timer.label !== previous.timer.label ||
(next.timer.timerType !== previous.timer.timerType || next.timer.label !== previous.timer.label ||
next.timer.durationMs !== previous.timer.durationMs))
) {
if (next.mode === 8 && next.text.trim()) send(controller, { action: 'text', text: next.text })
if (next.mode === 9) send(controller, { action: 'qr', qrUrl: next.qrUrl, text: next.text })
else if (next.mode === 8 && next.text.trim()) send(controller, { action: 'text', text: next.text })
else send(controller, { action: 'mode', mode: next.mode })
}
if (next.mode === 7) controller.synchronizeTimer(next.timer)
+15 -3
View File
@@ -6,6 +6,9 @@ import {
networkFailure,
type NetworkErrorCode,
} from './request-error.js'
import { parseTimerType } from './screen-plan.js'
import { validQR } from '../src/utils/presentation.js'
import type { TimerType } from '../src/types/screen.js'
import { createHash } from 'node:crypto'
export const INCOMPLETE_MATERIALS_WARNING = 'Получен неполный список материалов. Подключите экран к игре для уточнения'
@@ -21,7 +24,7 @@ export interface ScreenPackage {
intro: string | null
conclusion: string | null
}
steps: { mode: number; text: string; timerLabel: string; timerDurationSeconds: number }[]
steps: { mode: number; text: string; timerLabel: string; timerDurationSeconds: number; qrUrl?: string; timerType?: TimerType }[]
music: {
scenarioPlaylistId: number | null
gamePlaylistId: number | null
@@ -118,16 +121,18 @@ const modes = [
'RESULTS',
'TIMER',
'TEXT',
'QR',
'TEAMS',
]
function mode(value: unknown, field?: string): number {
const result =
typeof value === 'string' && value.startsWith('SCREEN_MODE_')
? modes.indexOf(value.slice('SCREEN_MODE_'.length))
: integer(value, 0, field)
if (result < 1 || result > 8)
if (result < 1 || result > 10)
throw fieldError(
field,
'режим экрана 1–8 или enum ScreenMode',
'режим экрана 1–10 или enum ScreenMode',
value,
'Неизвестный режим показа',
)
@@ -207,12 +212,17 @@ export function buildScreenPackage(
const steps = list(plan.steps, 'plan.steps').map((value, index) => {
const field = `plan.steps[${index}]`
const step = object(value, field)
const timerType = parseTimerType(step.timerType)
const qrUrl = text(step.qrUrl, `${field}.qrUrl`)
const result = {
...(qrUrl ? { qrUrl } : {}),
...(timerType ? { timerType } : {}),
mode: mode(step.mode, `${field}.mode`),
text: text(step.text, `${field}.text`),
timerLabel: text(step.timerLabel, `${field}.timerLabel`),
timerDurationSeconds: integer(step.timerDurationSeconds, 0, `${field}.timerDurationSeconds`),
}
if (result.mode === 9 && !validQR(qrUrl, result.text)) throw new Error('Некорректные параметры QR-кода')
if (result.mode === 8 && !result.text.trim())
throw new ResponseFieldError(
`${field}.text`,
@@ -510,6 +520,8 @@ export function parseScreenPackage(value: unknown): ScreenPackage {
mode: step.mode,
text: step.text,
timerLabel: step.timerLabel,
qrUrl: step.qrUrl,
timerType: step.timerType,
timerDurationSeconds: step.timerDurationSeconds,
}
}),
+28 -3
View File
@@ -1,3 +1,5 @@
import { timerLabels, validQR } from '../src/utils/presentation.js'
import type { TimerType } from '../src/types/screen.js'
import { ResponseFieldError } from './request-error.js'
import type { ScreenMode, ScreenPlan } from '../src/types/screen.js'
import { integer, object, text } from './remote-state.js'
@@ -12,6 +14,8 @@ const modes = [
'RESULTS',
'TIMER',
'TEXT',
'QR',
'TEAMS',
]
export function parsePlan(value: unknown): ScreenPlan {
@@ -29,13 +33,13 @@ export function parsePlan(value: unknown): ScreenPlan {
? modes.indexOf(step.mode.slice(12))
: step.mode,
1,
8,
10,
`${field}.mode`,
)
} catch {
throw new ResponseFieldError(
`${field}.mode`,
'режим экрана 1–8 или enum ScreenMode',
'режим экрана 1–10 или enum ScreenMode',
step.mode,
'Некорректный режим экрана',
)
@@ -45,6 +49,9 @@ export function parsePlan(value: unknown): ScreenPlan {
content = mode === 3 ? 'Успехов' : 'Спасибо за игру!'
mode = 8
}
const timerType = parseTimerType(step.timerType)
const qrUrl = text(step.qrUrl ?? '', `${field}.qrUrl`)
if (mode === 9 && !validQR(qrUrl, content)) throw new Error('Укажите ссылку HTTP/HTTPS до 2048 байт и текст до 10000 байт')
const label = text(step.timerLabel ?? '', `${field}.timerLabel`).trim()
const seconds = integer(
step.timerDurationSeconds ?? 0,
@@ -69,8 +76,10 @@ export function parsePlan(value: unknown): ScreenPlan {
return {
mode: mode as ScreenMode,
text: content,
timerLabel: mode === 7 ? label || 'Перерыв' : label,
timerLabel: mode === 7 ? label || timerLabels[timerType] : label,
timerDurationSeconds: seconds,
...(qrUrl ? { qrUrl } : {}),
...(timerType ? { timerType } : {}),
}
})
return {
@@ -83,3 +92,19 @@ export function parsePlan(value: unknown): ScreenPlan {
),
}
}
export function parseTimerType(value: unknown): TimerType {
const names = [
'SCREEN_TIMER_TYPE_BREAK',
'SCREEN_TIMER_TYPE_BEFORE_GAME',
'SCREEN_TIMER_TYPE_UNTIL_END',
]
return integer(
typeof value === 'string' && value.startsWith('SCREEN_TIMER_TYPE_')
? names.indexOf(value)
: (value ?? 0),
0,
2,
'timerType',
) as TimerType
}
+23
View File
@@ -0,0 +1,23 @@
import type { TeamsSnapshot } from '../src/types/screen.js'
import { object, text } from './remote-state.js'
export function parseTeams(value: unknown, packageKey: string): TeamsSnapshot {
const data = object(value)
if (
!/^[a-f0-9]{64}$/.test(packageKey) ||
data.packageKey !== packageKey ||
!Array.isArray(data.teams)
)
throw new Error('Некорректный список команд игры')
const captured = Date.parse(text(data.capturedAt))
if (!Number.isFinite(captured)) throw new Error('Некорректная дата списка команд')
const teams = data.teams
.map((value) => {
const team = object(value)
return { id: text(team.id), name: text(team.name) }
})
.sort((a, b) => a.name.localeCompare(b.name, 'ru') || a.id.localeCompare(b.id))
if (teams.some((team) => !team.id) || new Set(teams.map((team) => team.id)).size !== teams.length)
throw new Error('Повтор или отсутствие команды')
return { packageKey, capturedAt: new Date(captured).toISOString(), teams }
}
+19 -3
View File
@@ -8,7 +8,7 @@ module.exports = async function previewsSmoke({ control, output, evaluate, inspe
const root = document.querySelector(${JSON.stringify(root)});
if (!root) return null;
const bounds = root.getBoundingClientRect();
return ['.background', '.cover', '.message', '.intro-waveform', '.timer h1', '.timer strong', '.results', '.results h1', '.results .empty', '.results table', ${JSON.stringify(videoSelector)}].flatMap(selector => {
return ['.background', '.cover', '.message', '.intro-waveform', '.timer h1', '.timer strong', '.qr-scene', '.qr-caption', '.teams-scene', '.team-names', '.results', '.results h1', '.results .empty', '.results table', ${JSON.stringify(videoSelector)}].flatMap(selector => {
const e = root.querySelector(selector); if (!e) return [];
const r = e.getBoundingClientRect(); const s = getComputedStyle(e); if (s.display === 'none') return [];
return [{ selector: selector.includes('video') ? 'video' : selector, text: e.textContent.trim(), x: (r.x-bounds.x)/bounds.width, y: (r.y-bounds.y)/bounds.height, width: r.width/bounds.width, height: r.height/bounds.height, font: s.fontSize, fit: s.objectFit, filter: s.filter }];
@@ -79,11 +79,17 @@ module.exports = async function previewsSmoke({ control, output, evaluate, inspe
{ index: 5, results: { packageKey: original.catalog.key, capturedAt: new Date().toISOString(), questions: [], teams: [] } },
{ index: 5, results: { packageKey: original.catalog.key, capturedAt: new Date().toISOString(), questions: [{ code: 'A', text: 'Вопрос' }], teams: [{ id: 'one', name: 'Детективы', place: 1, totalScore: 5, actionsCount: 1, answers: [{ questionCode: 'A', score: 5 }] }] } },
]
for (const step of [
{ mode: 9, qrUrl: 'https://example.org/join?game=7', text: 'Присоединяйтесь\nк расследованию' },
{ mode: 9, qrUrl: 'https://example.org/join', text: '' },
{ mode: 10, text: '' },
]) cases.push({ index: 7, catalog: { ...original.catalog, steps: original.catalog.steps.map((old, i) => i === 7 ? { ...old, ...step } : old) }, teams: { packageKey: original.catalog.key, capturedAt: new Date().toISOString(), teams: Array.from({ length: 32 }, (_, i) => ({ id: String(i), name: `Команда ${i + 1} — Длинное название` })) } })
cases.push({ index: 7, catalog: { ...original.catalog, steps: original.catalog.steps.map((old, i) => i === 7 ? { ...old, mode: 10 } : old) }, teams: { packageKey: original.catalog.key, capturedAt: new Date().toISOString(), teams: [] } })
for (const item of cases) {
const catalog = item.catalog ?? original.catalog
const step = catalog.steps[item.index]
const snapshot = { ...original, revision: ++revision, catalog, results: item.results ?? original.results,
scene: { ...original.scene, revision, mode: step.mode, text: step.text, stepIndex: item.index },
const snapshot = { ...original, revision: ++revision, catalog, results: item.results ?? original.results, teams: item.teams ?? original.teams,
scene: { ...original.scene, revision, mode: step.mode, text: step.text, qrUrl: step.qrUrl, stepIndex: item.index },
channels: { ...original.channels, main: { ...original.channels.main, source: catalog.media.find(m => m.kind === (step.mode === 2 ? 'audio' : 'video')) ?? null } } }
control.webContents.send('state', snapshot)
output.webContents.send('state', snapshot)
@@ -91,6 +97,16 @@ module.exports = async function previewsSmoke({ control, output, evaluate, inspe
await poll(() => evaluate(`document.querySelector('${miniature(item.index)} .scene-view').dataset.mode === '${step.mode}'`), 'fixture thumbnail')
await new Promise(resolve => setTimeout(resolve, 50))
await compare(item.index)
if (step.mode === 9) {
await poll(() => inspect("!!document.querySelector('.qr-scene canvas')"), 'QR canvas')
assert.ok(await inspect("(() => { const c = document.querySelector('.qr-scene canvas'), r = c.getBoundingClientRect(); return r.width > 200 && r.height > 200 && r.bottom <= innerHeight && r.right <= innerWidth })()"))
if (step.text) writeFileSync('/tmp/ed054-qr-output.png', (await output.webContents.capturePage()).toPNG())
}
if (step.mode === 10) {
assert.equal(await inspect("document.querySelectorAll('.team-names p').length"), item.teams.teams.length)
assert.ok(await inspect("Array.from(document.querySelectorAll('.team-names p')).every(e => { const r = e.getBoundingClientRect(); return r.top >= 0 && r.left >= 0 && r.bottom <= innerHeight && r.right <= innerWidth })"))
if (item.teams.teams.length) writeFileSync('/tmp/ed054-teams-output.png', (await output.webContents.capturePage()).toPNG())
}
}
console.log('SCREEN_PREVIEWS_PASS')
}
+1
View File
@@ -408,6 +408,7 @@ onUnmounted(() => {
<ScreenPlanPanel
:catalog="state.catalog"
:results="state.results"
:team-names="state.teams?.teams.map(team => team.name)"
:viewport="previewViewport"
:steps="state.catalog.steps"
:current-index="state.scene.stepIndex"
+9
View File
@@ -10,6 +10,10 @@ const audio = ref<HTMLAudioElement | null>(null)
const video = ref<HTMLVideoElement | null>(null)
const musicA = ref<HTMLAudioElement | null>(null)
const musicB = ref<HTMLAudioElement | null>(null)
const viewport = ref({ width: window.innerWidth, height: window.innerHeight })
function updateViewport() {
viewport.value = { width: window.innerWidth, height: window.innerHeight }
}
const surface = ref<HTMLElement | null>(null)
const state = shallowRef<State | null>(null)
const imageError = ref(false)
@@ -210,6 +214,7 @@ function apply(value: State) {
}
}
onMounted(async () => {
window.addEventListener('resize', updateViewport)
try {
await bridge.prepareAudioOutput()
} catch {
@@ -224,6 +229,7 @@ onMounted(async () => {
apply(await bridge.state())
})
onUnmounted(() => {
window.removeEventListener('resize', updateViewport)
disposed = true
sceneAbort?.abort()
clearIntroWaveform()
@@ -238,7 +244,10 @@ onUnmounted(() => {
v-if="state"
:catalog="state.catalog"
:mode="state.scene.mode"
:viewport="viewport"
:text="state.scene.text"
:qr-url="state.scene.qrUrl"
:team-names="state.teams?.teams.map(team => team.name)"
:timer-label="state.timer.label"
:timer-display="timerDisplay"
:results="state.results"
+53 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
import { NQrCode } from 'naive-ui'
import { computed, reactive, watch } from 'vue'
import type { Catalog, ResultsSnapshot, ScreenMode } from '../types/screen'
import ResultsTable from './ResultsTable.vue'
@@ -7,6 +8,8 @@ const props = withDefaults(defineProps<{
catalog: Catalog
mode: ScreenMode
text: string
qrUrl?: string
teamNames?: string[]
timerLabel: string
timerDisplay: string
results: ResultsSnapshot | null
@@ -20,6 +23,7 @@ const props = withDefaults(defineProps<{
animatedResults?: boolean
viewport?: { width: number; height: number }
}>(), {
qrUrl: '',
revision: 0,
imageVersion: '',
imageError: false,
@@ -28,6 +32,8 @@ const props = withDefaults(defineProps<{
introTime: '',
animatedResults: false,
})
const teamColumns = computed(() => Math.max(1, Math.ceil(Math.sqrt((props.teamNames?.length ?? 0) / 4))))
const teamRows = computed(() => Math.max(1, Math.ceil((props.teamNames?.length ?? 0) / teamColumns.value)))
const failedImages = reactive(new Set<string>())
watch(() => [props.imageVersion, props.catalog.cover, props.catalog.background], () => failedImages.clear(), { flush: 'sync' })
function imageURL(url: string, version: string) {
@@ -93,6 +99,17 @@ function imageURL(url: string, version: string) {
<h1>{{ timerLabel }}</h1>
<strong>{{ timerDisplay }}</strong>
</section>
<section v-else-if="mode === 9" class="qr-scene" aria-label="QR-код">
<NQrCode v-if="qrUrl" :value="qrUrl" :size="Math.max(64, Math.round((viewport?.height ?? 720) * 0.6))" :padding="24" />
<p v-if="text" class="qr-caption">{{ text }}</p>
</section>
<section v-else-if="mode === 10" class="teams-scene" aria-label="Список команд">
<p v-if="!teamNames" class="message">Список команд ещё не загружен</p>
<div v-else-if="teamNames.length" class="team-names" :style="{ gridTemplateColumns: `repeat(${teamColumns}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${teamRows}, minmax(0, 1fr))`, fontSize: `min(calc(var(--scene-vh) * ${40 / teamRows}), calc(var(--scene-vw) * ${5 / teamColumns}))` }">
<p v-for="(name, index) in teamNames" :key="index">{{ name }}</p>
</div>
<p v-else class="message">Команды пока не зарегистрированы</p>
</section>
<p v-else-if="mode === 8" class="message text">{{ text }}</p>
</div>
<slot />
@@ -198,4 +215,39 @@ function imageURL(url: string, version: string) {
font-variant-numeric: tabular-nums;
line-height: 1;
}
.qr-scene {
align-items: center;
display: flex;
gap: calc(var(--scene-vw) * 4);
justify-content: center;
max-width: 92%;
}
.qr-caption {
font-size: calc(var(--scene-vw) * 3.5);
max-height: calc(var(--scene-vh) * 85);
max-width: calc(var(--scene-vw) * 40);
overflow: hidden;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.teams-scene {
height: 90%;
width: 92%;
}
.team-names {
align-items: center;
display: grid;
gap: calc(var(--scene-vw) * 1.5);
grid-auto-flow: column;
height: 100%;
}
.team-names p {
margin: 0;
overflow-wrap: anywhere;
}
</style>
+34 -5
View File
@@ -3,12 +3,13 @@ import { NAlert, NButton, NInput, NInputNumber, NSelect, NSpace } from 'naive-ui
import { computed } from 'vue'
import { playbackControl } from '../utils/playbackControl'
import type { Catalog, ChannelState, Intent, ResultsSnapshot, ScreenMode, ScreenStep, TimerState } from '../types/screen'
import { modeLabels } from '../utils/presentation'
import { changeTimerType, timerLabels, modeLabels } from '../utils/presentation'
import ScreenStepPreview from './ScreenStepPreview.vue'
const props = defineProps<{
catalog: Catalog
results: ResultsSnapshot | null
teamNames?: string[]
viewport: { width: number; height: number }
steps: ScreenStep[]
currentIndex: number | null
@@ -33,6 +34,14 @@ const emit = defineEmits<{
const mainControl = computed(() =>
playbackControl({ desired: props.mainDesired, status: props.mainStatus }),
)
const timerTypeOptions = timerLabels.map((label, value) => ({ label, value }))
function updateTimerType(index: number, value: 0 | 1 | 2) {
const step = props.draft[index]!
emit('updateStep', index, {
timerType: value,
timerLabel: changeTimerType(step.timerLabel, step.timerType, value),
})
}
const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
value: Number(mode) as ScreenMode,
label,
@@ -56,6 +65,7 @@ function timerLabel(index: number) {
:step="step"
:catalog="catalog"
:results="results"
:team-names="teamNames"
:viewport="viewport"
/>
</div>
@@ -85,7 +95,7 @@ function timerLabel(index: number) {
</div>
</div>
<n-input
v-if="step.mode === 8"
v-if="step.mode === 8 || step.mode === 9"
:value="step.text"
type="textarea"
:disabled="isBusy"
@@ -93,7 +103,15 @@ function timerLabel(index: number) {
:input-props="{ 'aria-label': `Текст шага ${index + 1}` }"
@update:value="emit('updateStep', index, { text: $event })"
/>
<template v-if="step.mode === 7">
<div v-if="step.mode === 7">
<label :for="`plan-type-${index}`">Тип таймера</label>
<n-select
:value="step.timerType ?? 0"
:options="timerTypeOptions"
:disabled="isBusy"
:input-props="{ id: `plan-type-${index}` }"
@update:value="updateTimerType(index, $event)"
/>
<label :for="`plan-label-${index}`">Надпись таймера</label>
<n-input
:value="step.timerLabel"
@@ -110,7 +128,17 @@ function timerLabel(index: number) {
:input-props="{ id: `plan-seconds-${index}` }"
@update:value="emit('updateStep', index, { timerDurationSeconds: $event ?? 0 })"
/>
</template>
</div>
<div v-if="step.mode === 9">
<label :for="`plan-qr-${index}`">Ссылка для QR-кода (обязательно)</label>
<n-input
:value="step.qrUrl ?? ''"
:disabled="isBusy"
:input-props="{ id: `plan-qr-${index}` }"
placeholder="https://example.com"
@update:value="emit('updateStep', index, { qrUrl: $event })"
/>
</div>
</div>
<n-space>
<n-button size="small" :disabled="isBusy" @click="emit('add')">Добавить шаг</n-button>
@@ -133,6 +161,7 @@ function timerLabel(index: number) {
:step="step"
:catalog="catalog"
:results="results"
:team-names="teamNames"
:viewport="viewport"
/>
</div>
@@ -140,7 +169,7 @@ function timerLabel(index: number) {
<div class="plan-step-title">
<span
>{{ modeLabels[step.mode]
}}<template v-if="step.mode === 8">: {{ step.text }}</template
}}<template v-if="step.mode === 8 || step.mode === 9">: {{ step.text }}</template
><template v-if="step.mode === 7"
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
></span
+3
View File
@@ -8,6 +8,7 @@ const props = defineProps<{
step: ScreenStep
catalog: Catalog
results: ResultsSnapshot | null
teamNames?: string[]
viewport: { width: number; height: number }
}>()
const source = computed(() => props.catalog.media.find(media =>
@@ -22,6 +23,8 @@ const source = computed(() => props.catalog.media.find(media =>
:catalog="catalog"
:mode="step.mode"
:text="step.text"
:qr-url="step.qrUrl"
:team-names="teamNames"
:timer-label="step.timerLabel"
:timer-display="formatTimer(step.timerDurationSeconds * 1000)"
:results="results"
+14 -2
View File
@@ -15,7 +15,8 @@ export type Intent =
| { action: 'mode'; mode: ScreenMode }
| { action: 'step'; index: number }
| { action: 'text'; text: string }
| { action: 'timer-set'; label: string; durationSeconds: number }
| { action: 'qr'; qrUrl: string; text: string }
| { action: 'timer-set'; label: string; durationSeconds: number; timerType?: TimerType }
| { action: 'timer-start' | 'timer-pause' | 'timer-resume' | 'timer-reset' }
export type Command = Intent & { session: string; sequence: number }
export interface MediaSource {
@@ -30,14 +31,18 @@ export interface Playlist {
name: string
tracks: { id: number; name: string; source: MediaSource }[]
}
export type ScreenMode = 1 | 2 | 4 | 6 | 7 | 8
export type TimerType = 0 | 1 | 2
export type ScreenMode = 1 | 2 | 4 | 6 | 7 | 8 | 9 | 10
export interface ScreenStep {
qrUrl?: string
timerType?: TimerType
mode: ScreenMode
text: string
timerLabel: string
timerDurationSeconds: number
}
export interface SceneState {
qrUrl?: string
revision: number
mode: ScreenMode
stepIndex: number | null
@@ -46,6 +51,7 @@ export interface SceneState {
error: string
}
export interface TimerState {
timerType?: TimerType
revision: number
label: string
durationMs: number
@@ -99,7 +105,13 @@ export interface ChannelState {
trackIndex: number
loop: boolean
}
export interface TeamsSnapshot {
packageKey: string
capturedAt: string
teams: { id: string; name: string }[]
}
export interface State {
teams: TeamsSnapshot | null
session: string
sequence: number
revision: number
+24
View File
@@ -7,6 +7,8 @@ export const modeLabels: Record<ScreenMode, string> = {
6: 'Результаты',
7: 'Таймер',
8: 'Текст',
9: 'QR-код',
10: 'Список команд',
}
export function remainingTime(timer: TimerState, elapsedMs: number): number {
return Math.max(0, timer.remainingMs - (timer.status === 'running' ? Math.max(0, elapsedMs) : 0))
@@ -15,3 +17,25 @@ export function formatTimer(milliseconds: number): string {
const seconds = Math.ceil(Math.max(0, milliseconds) / 1000)
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
}
export const timerLabels = ['Перерыв', 'До начала игры', 'До конца игры'] as const
export function changeTimerType(label: string, previous = 0, next = 0): string {
return !label.trim() || label === timerLabels[previous] ? timerLabels[next]! : label
}
export function validQR(link: string, caption = ''): boolean {
try {
const url = new URL(link)
return (
/^https?:\/\/[^/\\\s?#]/.test(link) &&
['http:', 'https:'].includes(url.protocol) &&
!!url.hostname &&
!url.username &&
!url.password &&
link === link.trim() &&
new TextEncoder().encode(link).length <= 2048 &&
new TextEncoder().encode(caption).length <= 10000
)
} catch {
return false
}
}