up
This commit is contained in:
+15
-4
@@ -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 без сигнала и автоматического перехода.
|
на нуле остаётся 00:00 без сигнала и автоматического перехода.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ test('клиент proto задаёт маршруты, методы и JSON enu
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
await client.SaveScreenPlan({ gameID: 7, ...apiPlan(plan) })
|
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({
|
await client.SendScreenCommand({
|
||||||
gameID: 7,
|
gameID: 7,
|
||||||
command: apiScreenCommand(6),
|
command: apiScreenCommand(6),
|
||||||
|
|||||||
+21
-1
@@ -14,6 +14,8 @@ const modes = [
|
|||||||
'SCREEN_MODE_RESULTS',
|
'SCREEN_MODE_RESULTS',
|
||||||
'SCREEN_MODE_TIMER',
|
'SCREEN_MODE_TIMER',
|
||||||
'SCREEN_MODE_TEXT',
|
'SCREEN_MODE_TEXT',
|
||||||
|
'SCREEN_MODE_QR',
|
||||||
|
'SCREEN_MODE_TEAMS',
|
||||||
] as const satisfies readonly (ScreenMode | undefined)[]
|
] as const satisfies readonly (ScreenMode | undefined)[]
|
||||||
const commands = [
|
const commands = [
|
||||||
'SCREEN_COMMAND_UNSPECIFIED',
|
'SCREEN_COMMAND_UNSPECIFIED',
|
||||||
@@ -40,5 +42,23 @@ export function apiScreenCommand(command: number): ScreenCommand {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
export function apiPlan(plan: ScreenPlan): Pick<SaveScreenPlanReq, 'steps' | 'currentStep'> {
|
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_CONCLUSION"
|
||||||
| "SCREEN_MODE_RESULTS"
|
| "SCREEN_MODE_RESULTS"
|
||||||
| "SCREEN_MODE_TIMER"
|
| "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 =
|
export type ScreenTimerState =
|
||||||
| "SCREEN_TIMER_STATE_UNSPECIFIED"
|
| "SCREEN_TIMER_STATE_UNSPECIFIED"
|
||||||
| "SCREEN_TIMER_STATE_STOPPED"
|
| "SCREEN_TIMER_STATE_STOPPED"
|
||||||
@@ -568,6 +574,8 @@ export type Game = {
|
|||||||
format: string | undefined;
|
format: string | undefined;
|
||||||
location: string | undefined;
|
location: string | undefined;
|
||||||
export: GameExport | undefined;
|
export: GameExport | undefined;
|
||||||
|
wifiName: string | undefined;
|
||||||
|
wifiPassword: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Download ready ZIP through the authenticated streaming HTTP route:
|
// Download ready ZIP through the authenticated streaming HTTP route:
|
||||||
@@ -629,6 +637,8 @@ export type ScreenState = {
|
|||||||
timerState: ScreenTimerState | undefined;
|
timerState: ScreenTimerState | undefined;
|
||||||
text: string | undefined;
|
text: string | undefined;
|
||||||
music: MusicState | undefined;
|
music: MusicState | undefined;
|
||||||
|
qrUrl: string | undefined;
|
||||||
|
timerType: ScreenTimerType | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MusicState = {
|
export type MusicState = {
|
||||||
@@ -760,6 +770,8 @@ export type UpdateGameMapItemRsp = {
|
|||||||
export type SetScreenModeReq = {
|
export type SetScreenModeReq = {
|
||||||
gameID: number | undefined;
|
gameID: number | undefined;
|
||||||
mode: ScreenMode | undefined;
|
mode: ScreenMode | undefined;
|
||||||
|
qrUrl: string | undefined;
|
||||||
|
text: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SetScreenModeRsp = {
|
export type SetScreenModeRsp = {
|
||||||
@@ -769,6 +781,7 @@ export type SetScreenTimerReq = {
|
|||||||
gameID: number | undefined;
|
gameID: number | undefined;
|
||||||
durationSeconds: number | undefined;
|
durationSeconds: number | undefined;
|
||||||
label: string | undefined;
|
label: string | undefined;
|
||||||
|
timerType: ScreenTimerType | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SetScreenTimerRsp = {
|
export type SetScreenTimerRsp = {
|
||||||
@@ -789,6 +802,8 @@ export type ScreenPlanStep = {
|
|||||||
text: string | undefined;
|
text: string | undefined;
|
||||||
timerLabel: string | undefined;
|
timerLabel: string | undefined;
|
||||||
timerDurationSeconds: number | undefined;
|
timerDurationSeconds: number | undefined;
|
||||||
|
qrUrl: string | undefined;
|
||||||
|
timerType: ScreenTimerType | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ScreenPlan = {
|
export type ScreenPlan = {
|
||||||
@@ -823,6 +838,20 @@ export type SetScreenPlanStepRsp = {
|
|||||||
plan: ScreenPlan | undefined;
|
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 = {
|
export type GetScreenResultsReq = {
|
||||||
screenID: string | undefined;
|
screenID: string | undefined;
|
||||||
accessToken: string | undefined;
|
accessToken: string | undefined;
|
||||||
@@ -968,6 +997,8 @@ export type UpdateGameReq = {
|
|||||||
scenarioID: number | undefined;
|
scenarioID: number | undefined;
|
||||||
format: string | undefined;
|
format: string | undefined;
|
||||||
location: string | undefined;
|
location: string | undefined;
|
||||||
|
wifiName?: string;
|
||||||
|
wifiPassword?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateGameRsp = {
|
export type UpdateGameRsp = {
|
||||||
@@ -1467,6 +1498,7 @@ export interface EveningDetectiveServer {
|
|||||||
GetScreenPlan(request: GetScreenPlanReq): Promise<GetScreenPlanRsp>;
|
GetScreenPlan(request: GetScreenPlanReq): Promise<GetScreenPlanRsp>;
|
||||||
SaveScreenPlan(request: SaveScreenPlanReq): Promise<SaveScreenPlanRsp>;
|
SaveScreenPlan(request: SaveScreenPlanReq): Promise<SaveScreenPlanRsp>;
|
||||||
SetScreenPlanStep(request: SetScreenPlanStepReq): Promise<SetScreenPlanStepRsp>;
|
SetScreenPlanStep(request: SetScreenPlanStepReq): Promise<SetScreenPlanStepRsp>;
|
||||||
|
GetScreenTeams(request: GetScreenTeamsReq): Promise<GetScreenTeamsRsp>;
|
||||||
GetScreenResults(request: GetScreenResultsReq): Promise<GetScreenResultsRsp>;
|
GetScreenResults(request: GetScreenResultsReq): Promise<GetScreenResultsRsp>;
|
||||||
DownloadGamePrintMaterials(request: GetGameReq): Promise<googleapi_HttpBody>;
|
DownloadGamePrintMaterials(request: GetGameReq): Promise<googleapi_HttpBody>;
|
||||||
PreviewTeamReceipt(request: TeamReceiptReq): Promise<googleapi_HttpBody>;
|
PreviewTeamReceipt(request: TeamReceiptReq): Promise<googleapi_HttpBody>;
|
||||||
@@ -3159,6 +3191,23 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "SetScreenPlanStep",
|
method: "SetScreenPlanStep",
|
||||||
}) as Promise<SetScreenPlanStepRsp>;
|
}) 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
|
GetScreenResults(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const path = `api/screens/results`; // eslint-disable-line quotes
|
const path = `api/screens/results`; // eslint-disable-line quotes
|
||||||
const body = JSON.stringify(request);
|
const body = JSON.stringify(request);
|
||||||
|
|||||||
+39
-10
@@ -13,7 +13,9 @@ import type {
|
|||||||
TimerState,
|
TimerState,
|
||||||
ScreenStep,
|
ScreenStep,
|
||||||
} from '../src/types/screen.js'
|
} 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 { parseResults } from './results.js'
|
||||||
import type { LocalPackage } from './package-store.js'
|
import type { LocalPackage } from './package-store.js'
|
||||||
|
|
||||||
@@ -36,10 +38,12 @@ export function parseCommand(value: unknown): Command {
|
|||||||
else if (
|
else if (
|
||||||
c.action === 'mode' &&
|
c.action === 'mode' &&
|
||||||
Number.isInteger(c.mode) &&
|
Number.isInteger(c.mode) &&
|
||||||
number(c.mode, 1, 8) &&
|
number(c.mode, 1, 10) &&
|
||||||
[1, 2, 4, 6, 7, 8].includes(c.mode)
|
[1, 2, 4, 6, 7, 8, 9, 10].includes(c.mode)
|
||||||
)
|
)
|
||||||
intent = { action: 'mode', mode: c.mode as ScreenMode }
|
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))
|
else if (c.action === 'step' && Number.isSafeInteger(c.index) && number(c.index, 0))
|
||||||
intent = { action: 'step', index: c.index }
|
intent = { action: 'step', index: c.index }
|
||||||
else if (
|
else if (
|
||||||
@@ -58,8 +62,9 @@ export function parseCommand(value: unknown): Command {
|
|||||||
)
|
)
|
||||||
intent = {
|
intent = {
|
||||||
action: 'timer-set',
|
action: 'timer-set',
|
||||||
label: c.label.trim() || 'Перерыв',
|
label: c.label.trim() || timerLabels[parseTimerType(c.timerType)],
|
||||||
durationSeconds: c.durationSeconds,
|
durationSeconds: c.durationSeconds,
|
||||||
|
timerType: parseTimerType(c.timerType),
|
||||||
}
|
}
|
||||||
else if (
|
else if (
|
||||||
typeof c.action === 'string' &&
|
typeof c.action === 'string' &&
|
||||||
@@ -178,6 +183,7 @@ export class Controller {
|
|||||||
scene: { revision: 0, mode: 1, stepIndex: null, text: '', status: 'idle', error: '' },
|
scene: { revision: 0, mode: 1, stepIndex: null, text: '', status: 'idle', error: '' },
|
||||||
timer: initialTimer(),
|
timer: initialTimer(),
|
||||||
results: null,
|
results: null,
|
||||||
|
teams: null,
|
||||||
channels: { main: initialChannel(), music: initialChannel() },
|
channels: { main: initialChannel(), music: initialChannel() },
|
||||||
samples: [],
|
samples: [],
|
||||||
}
|
}
|
||||||
@@ -301,6 +307,7 @@ export class Controller {
|
|||||||
this.state.timer = initialTimer()
|
this.state.timer = initialTimer()
|
||||||
this.timerDeadline = null
|
this.timerDeadline = null
|
||||||
this.state.results = null
|
this.state.results = null
|
||||||
|
this.state.teams = null
|
||||||
this.state.channels = {
|
this.state.channels = {
|
||||||
main: { ...initialChannel(), source: catalog.media[0] ?? null },
|
main: { ...initialChannel(), source: catalog.media[0] ?? null },
|
||||||
music: initialChannel(),
|
music: initialChannel(),
|
||||||
@@ -372,11 +379,12 @@ export class Controller {
|
|||||||
this.broadcast()
|
this.broadcast()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private configureTimer(label: string, seconds: number) {
|
private configureTimer(label: string, seconds: number, timerType = 0) {
|
||||||
this.timerDeadline = null
|
this.timerDeadline = null
|
||||||
this.state.timer = {
|
this.state.timer = {
|
||||||
revision: this.state.timer.revision + 1,
|
revision: this.state.timer.revision + 1,
|
||||||
label,
|
label,
|
||||||
|
timerType: parseTimerType(timerType),
|
||||||
durationMs: seconds * 1000,
|
durationMs: seconds * 1000,
|
||||||
remainingMs: seconds * 1000,
|
remainingMs: seconds * 1000,
|
||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
@@ -436,10 +444,17 @@ export class Controller {
|
|||||||
markStep(index: number) {
|
markStep(index: number) {
|
||||||
const step = this.state.catalog.steps[index]
|
const step = this.state.catalog.steps[index]
|
||||||
const scene = this.state.scene
|
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 (
|
if (
|
||||||
step.mode === 7 &&
|
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)
|
step.timerDurationSeconds * 1000 !== this.state.timer.durationMs)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -447,6 +462,15 @@ export class Controller {
|
|||||||
scene.stepIndex = index
|
scene.stepIndex = index
|
||||||
this.broadcast()
|
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) {
|
setResults(value: unknown) {
|
||||||
const key = this.state.catalog.key
|
const key = this.state.catalog.key
|
||||||
if (value !== null && !key) throw new Error('Сначала выберите пакет игры')
|
if (value !== null && !key) throw new Error('Сначала выберите пакет игры')
|
||||||
@@ -487,6 +511,10 @@ export class Controller {
|
|||||||
case 'mode':
|
case 'mode':
|
||||||
this.mode(command.mode)
|
this.mode(command.mode)
|
||||||
return true
|
return true
|
||||||
|
case 'qr':
|
||||||
|
this.state.scene.qrUrl = command.qrUrl
|
||||||
|
this.mode(9, null, command.text)
|
||||||
|
return true
|
||||||
case 'text':
|
case 'text':
|
||||||
this.mode(8, null, command.text)
|
this.mode(8, null, command.text)
|
||||||
return true
|
return true
|
||||||
@@ -494,12 +522,13 @@ export class Controller {
|
|||||||
const step = this.state.catalog.steps[command.index]
|
const step = this.state.catalog.steps[command.index]
|
||||||
if (!step) throw new Error('Шаг плана не найден')
|
if (!step) throw new Error('Шаг плана не найден')
|
||||||
if (step.mode === 7)
|
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)
|
this.mode(step.mode, command.index, step.text)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
case 'timer-set':
|
case 'timer-set':
|
||||||
this.configureTimer(command.label, command.durationSeconds)
|
this.configureTimer(command.label, command.durationSeconds, command.timerType)
|
||||||
this.mode(7)
|
this.mode(7)
|
||||||
return true
|
return true
|
||||||
case 'timer-start':
|
case 'timer-start':
|
||||||
@@ -509,7 +538,7 @@ export class Controller {
|
|||||||
this.updateTimer()
|
this.updateTimer()
|
||||||
const t = this.state.timer
|
const t = this.state.timer
|
||||||
if (command.action === 'timer-reset') {
|
if (command.action === 'timer-reset') {
|
||||||
this.configureTimer(t.label, t.durationMs / 1000)
|
this.configureTimer(t.label, t.durationMs / 1000, t.timerType)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (command.action === 'timer-pause') {
|
if (command.action === 'timer-pause') {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -68,6 +68,8 @@ async function fixture(t: TestContext, secure = true) {
|
|||||||
refreshResponse: null as (() => Promise<Response>) | null,
|
refreshResponse: null as (() => Promise<Response>) | null,
|
||||||
advanceCalls: 0,
|
advanceCalls: 0,
|
||||||
resultError: false,
|
resultError: false,
|
||||||
|
teamsError: false,
|
||||||
|
screenTeams: [{ ID: '2', name: 'Ястребы' }, { ID: '1', name: 'Агенты' }],
|
||||||
title: 'Дело',
|
title: 'Дело',
|
||||||
gameStatus: 'finish',
|
gameStatus: 'finish',
|
||||||
gameError: 0,
|
gameError: 0,
|
||||||
@@ -138,6 +140,7 @@ async function fixture(t: TestContext, secure = true) {
|
|||||||
model.advanceCalls++
|
model.advanceCalls++
|
||||||
return json({ state: objectMusic(3, 1) })
|
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 (path === '/api/screens/results') {
|
||||||
if (model.resultError) return json({}, 400)
|
if (model.resultError) return json({}, 400)
|
||||||
return json({
|
return json({
|
||||||
@@ -224,6 +227,8 @@ async function fixture(t: TestContext, secure = true) {
|
|||||||
...model.remote,
|
...model.remote,
|
||||||
mode: step.mode,
|
mode: step.mode,
|
||||||
text: step.text,
|
text: step.text,
|
||||||
|
qrUrl: step.qrUrl,
|
||||||
|
timerType: step.timerType,
|
||||||
command: 0,
|
command: 0,
|
||||||
timerLabel: step.timerLabel,
|
timerLabel: step.timerLabel,
|
||||||
timerDurationSeconds: step.timerDurationSeconds || 600,
|
timerDurationSeconds: step.timerDurationSeconds || 600,
|
||||||
@@ -2187,3 +2192,24 @@ test('статус опрашивается раз в две секунды и
|
|||||||
assert.equal(count(), before + 1)
|
assert.equal(count(), before + 1)
|
||||||
assert.equal(f.online.snapshot().game?.status, 'run')
|
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
@@ -1,6 +1,6 @@
|
|||||||
import { requestErrorContext } from './request-error.js'
|
import { requestErrorContext } from './request-error.js'
|
||||||
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.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 { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js'
|
||||||
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
|
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
import type { Controller } from './controller.js'
|
import type { Controller } from './controller.js'
|
||||||
import { PackageStore, packageKey } from './package-store.js'
|
import { PackageStore, packageKey } from './package-store.js'
|
||||||
import { INCOMPLETE_MATERIALS_WARNING, type ScreenPackage, prepareScreenPackage, resolveMediaURL } from './screen-package.js'
|
import { INCOMPLETE_MATERIALS_WARNING, type ScreenPackage, prepareScreenPackage, resolveMediaURL } from './screen-package.js'
|
||||||
|
import { parseTeams } from './teams.js'
|
||||||
import { parseResults } from './results.js'
|
import { parseResults } from './results.js'
|
||||||
import { parsePlan } from './screen-plan.js'
|
import { parsePlan } from './screen-plan.js'
|
||||||
import {
|
import {
|
||||||
@@ -482,6 +483,8 @@ export class Online {
|
|||||||
private pollBusy = false
|
private pollBusy = false
|
||||||
private pollSession = ''
|
private pollSession = ''
|
||||||
private lastSound: boolean | null = null
|
private lastSound: boolean | null = null
|
||||||
|
private lastTeams = -Infinity
|
||||||
|
private teamsBusy = false
|
||||||
private lastResults = -Infinity
|
private lastResults = -Infinity
|
||||||
private resultsBusy = false
|
private resultsBusy = false
|
||||||
private advanceBusy = false
|
private advanceBusy = false
|
||||||
@@ -607,6 +610,7 @@ export class Online {
|
|||||||
if (intent.action === 'step')
|
if (intent.action === 'step')
|
||||||
mode = this.controller.state.catalog.steps[intent.index]?.mode ?? mode
|
mode = this.controller.state.catalog.steps[intent.index]?.mode ?? mode
|
||||||
if (intent.action === 'text') mode = 8
|
if (intent.action === 'text') mode = 8
|
||||||
|
if (intent.action === 'qr') mode = 9
|
||||||
if (intent.action === 'timer-set') mode = 7
|
if (intent.action === 'timer-set') mode = 7
|
||||||
if (intent.action === 'select') mode = intent.sourceId === 'intro' ? 2 : 4
|
if (intent.action === 'select') mode = intent.sourceId === 'intro' ? 2 : 4
|
||||||
return [
|
return [
|
||||||
@@ -664,7 +668,7 @@ export class Online {
|
|||||||
if (generation !== this.generation || session !== this.controller.state.session) return
|
if (generation !== this.generation || session !== this.controller.state.session) return
|
||||||
if (
|
if (
|
||||||
version !== this.planVersion &&
|
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,
|
intent.action,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -711,10 +715,12 @@ export class Online {
|
|||||||
? 'MUSIC_PLAYBACK_PAUSED'
|
? 'MUSIC_PLAYBACK_PAUSED'
|
||||||
: 'MUSIC_PLAYBACK_STOPPED',
|
: '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({
|
await this.client(true, AbortSignal.timeout(5000)).SetScreenMode({
|
||||||
gameID: pkg.package.game.id,
|
gameID: pkg.package.game.id,
|
||||||
mode: apiScreenMode(localState.scene.mode),
|
mode: apiScreenMode(localState.scene.mode),
|
||||||
|
qrUrl: localState.scene.qrUrl,
|
||||||
|
text: localState.scene.text,
|
||||||
})
|
})
|
||||||
} else if (intent.action === 'text') {
|
} else if (intent.action === 'text') {
|
||||||
const current = parsePlan(
|
const current = parsePlan(
|
||||||
@@ -760,6 +766,7 @@ export class Online {
|
|||||||
gameID: pkg.package.game.id,
|
gameID: pkg.package.game.id,
|
||||||
label: intent.label,
|
label: intent.label,
|
||||||
durationSeconds: intent.durationSeconds,
|
durationSeconds: intent.durationSeconds,
|
||||||
|
timerType: apiTimerType(intent.timerType),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const index = this.controller.state.scene.stepIndex
|
const index = this.controller.state.scene.stepIndex
|
||||||
@@ -821,9 +828,10 @@ export class Online {
|
|||||||
!isMusicCommand &&
|
!isMusicCommand &&
|
||||||
(remote.command !== expectedCommand ||
|
(remote.command !== expectedCommand ||
|
||||||
remote.mode !== scene.mode ||
|
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.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)) ||
|
remote.timer.durationMs !== this.controller.state.timer.durationMs)) ||
|
||||||
(intent.action === 'seek' && remote.position !== intent.position) ||
|
(intent.action === 'seek' && remote.position !== intent.position) ||
|
||||||
(intent.action === 'volume' && remote.volume !== intent.volume) ||
|
(intent.action === 'volume' && remote.volume !== intent.volume) ||
|
||||||
@@ -1098,7 +1106,11 @@ export class Online {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.lastResults = -Infinity
|
this.lastResults = -Infinity
|
||||||
if (key) await this.restoreResults(key)
|
this.lastTeams = -Infinity
|
||||||
|
if (key) {
|
||||||
|
await this.restoreResults(key)
|
||||||
|
await this.restoreTeams(key)
|
||||||
|
}
|
||||||
if (persist) {
|
if (persist) {
|
||||||
await this.saveSelection()
|
await this.saveSelection()
|
||||||
await this.persistSecrets()
|
await this.persistSecrets()
|
||||||
@@ -1462,6 +1474,7 @@ export class Online {
|
|||||||
await this.atomic(`plan-${key}.json`, this.cachedPlan)
|
await this.atomic(`plan-${key}.json`, this.cachedPlan)
|
||||||
this.previous = null
|
this.previous = null
|
||||||
await this.restoreResults(key)
|
await this.restoreResults(key)
|
||||||
|
await this.restoreTeams(key)
|
||||||
if (action === 'select-game') {
|
if (action === 'select-game') {
|
||||||
await this.saveSelection()
|
await this.saveSelection()
|
||||||
await this.persistSecrets()
|
await this.persistSecrets()
|
||||||
@@ -1483,6 +1496,7 @@ export class Online {
|
|||||||
throw new Error('Сначала выберите другой пакет или тестовый режим')
|
throw new Error('Сначала выберите другой пакет или тестовый режим')
|
||||||
await this.packages.remove(key)
|
await this.packages.remove(key)
|
||||||
await rm(join(this.directory, `results-${key}.json`), { force: true })
|
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 })
|
await rm(join(this.directory, `plan-${key}.json`), { force: true })
|
||||||
this.state.packagesRevision++
|
this.state.packagesRevision++
|
||||||
return
|
return
|
||||||
@@ -1504,7 +1518,7 @@ export class Online {
|
|||||||
await this.persistSecrets()
|
await this.persistSecrets()
|
||||||
await this.packages.clear()
|
await this.packages.clear()
|
||||||
for (const name of await readdir(this.directory)) {
|
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 })
|
await rm(join(this.directory, name), { force: true })
|
||||||
}
|
}
|
||||||
this.state.notice = 'Все сохранённые игры удалены'
|
this.state.notice = 'Все сохранённые игры удалены'
|
||||||
@@ -1688,6 +1702,48 @@ export class Online {
|
|||||||
return { id: integer(g.ID, 1), name: text(g.name), status: text(g.status ?? '') }
|
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) {
|
private async restoreResults(key: string) {
|
||||||
try {
|
try {
|
||||||
this.controller.setResults(
|
this.controller.setResults(
|
||||||
@@ -1799,6 +1855,10 @@ export class Online {
|
|||||||
Date.now() - this.lastMaterials >= 5000
|
Date.now() - this.lastMaterials >= 5000
|
||||||
)
|
)
|
||||||
void this.refreshMaterials()
|
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)
|
if (next.mode === 6 && Date.now() - this.lastResults >= 5000)
|
||||||
void this.refreshResults().catch((error) => {
|
void this.refreshResults().catch((error) => {
|
||||||
if (generation === this.generation)
|
if (generation === this.generation)
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ test('не допускает пустой текст и некорректны
|
|||||||
{ action: 'timer-set', label: 'x'.repeat(201), durationSeconds: 10 },
|
{ action: 'timer-set', label: 'x'.repeat(201), durationSeconds: 10 },
|
||||||
{ action: 'mode', mode: 3 },
|
{ action: 'mode', mode: 3 },
|
||||||
{ action: 'mode', mode: 5 },
|
{ action: 'mode', mode: 5 },
|
||||||
{ action: 'mode', mode: 9 },
|
{ action: 'mode', mode: 11 },
|
||||||
{ action: 'mode', mode: NaN },
|
{ action: 'mode', mode: NaN },
|
||||||
{ action: 'step', index: -1 },
|
{ action: 'step', index: -1 },
|
||||||
{ action: ['timer-start'] },
|
{ action: ['timer-start'] },
|
||||||
|
|||||||
@@ -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 { fieldError, ResponseFieldError } from './request-error.js'
|
||||||
import type { Controller } from './controller.js'
|
import type { Controller } from './controller.js'
|
||||||
import type { Intent, ScreenMode } from '../src/types/screen.js'
|
import type { Intent, ScreenMode } from '../src/types/screen.js'
|
||||||
@@ -64,8 +67,10 @@ export interface RemoteState {
|
|||||||
volume: number
|
volume: number
|
||||||
sound: boolean
|
sound: boolean
|
||||||
text: string
|
text: string
|
||||||
|
qrUrl: string
|
||||||
timer: {
|
timer: {
|
||||||
label: string
|
label: string
|
||||||
|
timerType?: TimerType
|
||||||
durationMs: number
|
durationMs: number
|
||||||
remainingMs: number
|
remainingMs: number
|
||||||
status: 'stopped' | 'running' | 'paused' | 'finished'
|
status: 'stopped' | 'running' | 'paused' | 'finished'
|
||||||
@@ -99,11 +104,15 @@ export function parseRemote(value: unknown): RemoteState {
|
|||||||
'RESULTS',
|
'RESULTS',
|
||||||
'TIMER',
|
'TIMER',
|
||||||
'TEXT',
|
'TEXT',
|
||||||
|
'QR',
|
||||||
|
'TEAMS',
|
||||||
],
|
],
|
||||||
'state.mode',
|
'state.mode',
|
||||||
) || 1
|
) || 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, 'Некорректный режим экрана')
|
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 duration = integer(s.timerDurationSeconds ?? 600, 1, 86400, 'state.timerDurationSeconds')
|
||||||
const remaining = integer(
|
const remaining = integer(
|
||||||
s.timerRemainingSeconds ?? 0,
|
s.timerRemainingSeconds ?? 0,
|
||||||
@@ -192,8 +201,10 @@ export function parseRemote(value: unknown): RemoteState {
|
|||||||
volume: integer(s.volume ?? 0, 0, 100, 'state.volume'),
|
volume: integer(s.volume ?? 0, 0, 100, 'state.volume'),
|
||||||
sound: s.soundEnabled === true,
|
sound: s.soundEnabled === true,
|
||||||
text: text(s.text ?? '', 'state.text').slice(0, 10000),
|
text: text(s.text ?? '', 'state.text').slice(0, 10000),
|
||||||
|
qrUrl,
|
||||||
timer: {
|
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,
|
durationMs: duration * 1000,
|
||||||
remainingMs: remaining * 1000,
|
remainingMs: remaining * 1000,
|
||||||
status: (['stopped', 'stopped', 'running', 'paused', 'finished'] as const)[timerState]!,
|
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.command >= 8 && controller.state.scene.mode !== 7) ||
|
||||||
next.mode !== previous.mode ||
|
next.mode !== previous.mode ||
|
||||||
next.text !== previous.text ||
|
next.text !== previous.text ||
|
||||||
|
next.qrUrl !== previous.qrUrl ||
|
||||||
(next.mode === 7 &&
|
(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))
|
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 })
|
else send(controller, { action: 'mode', mode: next.mode })
|
||||||
}
|
}
|
||||||
if (next.mode === 7) controller.synchronizeTimer(next.timer)
|
if (next.mode === 7) controller.synchronizeTimer(next.timer)
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import {
|
|||||||
networkFailure,
|
networkFailure,
|
||||||
type NetworkErrorCode,
|
type NetworkErrorCode,
|
||||||
} from './request-error.js'
|
} 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'
|
import { createHash } from 'node:crypto'
|
||||||
|
|
||||||
export const INCOMPLETE_MATERIALS_WARNING = 'Получен неполный список материалов. Подключите экран к игре для уточнения'
|
export const INCOMPLETE_MATERIALS_WARNING = 'Получен неполный список материалов. Подключите экран к игре для уточнения'
|
||||||
@@ -21,7 +24,7 @@ export interface ScreenPackage {
|
|||||||
intro: string | null
|
intro: string | null
|
||||||
conclusion: 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: {
|
music: {
|
||||||
scenarioPlaylistId: number | null
|
scenarioPlaylistId: number | null
|
||||||
gamePlaylistId: number | null
|
gamePlaylistId: number | null
|
||||||
@@ -118,16 +121,18 @@ const modes = [
|
|||||||
'RESULTS',
|
'RESULTS',
|
||||||
'TIMER',
|
'TIMER',
|
||||||
'TEXT',
|
'TEXT',
|
||||||
|
'QR',
|
||||||
|
'TEAMS',
|
||||||
]
|
]
|
||||||
function mode(value: unknown, field?: string): number {
|
function mode(value: unknown, field?: string): number {
|
||||||
const result =
|
const result =
|
||||||
typeof value === 'string' && value.startsWith('SCREEN_MODE_')
|
typeof value === 'string' && value.startsWith('SCREEN_MODE_')
|
||||||
? modes.indexOf(value.slice('SCREEN_MODE_'.length))
|
? modes.indexOf(value.slice('SCREEN_MODE_'.length))
|
||||||
: integer(value, 0, field)
|
: integer(value, 0, field)
|
||||||
if (result < 1 || result > 8)
|
if (result < 1 || result > 10)
|
||||||
throw fieldError(
|
throw fieldError(
|
||||||
field,
|
field,
|
||||||
'режим экрана 1–8 или enum ScreenMode',
|
'режим экрана 1–10 или enum ScreenMode',
|
||||||
value,
|
value,
|
||||||
'Неизвестный режим показа',
|
'Неизвестный режим показа',
|
||||||
)
|
)
|
||||||
@@ -207,12 +212,17 @@ export function buildScreenPackage(
|
|||||||
const steps = list(plan.steps, 'plan.steps').map((value, index) => {
|
const steps = list(plan.steps, 'plan.steps').map((value, index) => {
|
||||||
const field = `plan.steps[${index}]`
|
const field = `plan.steps[${index}]`
|
||||||
const step = object(value, field)
|
const step = object(value, field)
|
||||||
|
const timerType = parseTimerType(step.timerType)
|
||||||
|
const qrUrl = text(step.qrUrl, `${field}.qrUrl`)
|
||||||
const result = {
|
const result = {
|
||||||
|
...(qrUrl ? { qrUrl } : {}),
|
||||||
|
...(timerType ? { timerType } : {}),
|
||||||
mode: mode(step.mode, `${field}.mode`),
|
mode: mode(step.mode, `${field}.mode`),
|
||||||
text: text(step.text, `${field}.text`),
|
text: text(step.text, `${field}.text`),
|
||||||
timerLabel: text(step.timerLabel, `${field}.timerLabel`),
|
timerLabel: text(step.timerLabel, `${field}.timerLabel`),
|
||||||
timerDurationSeconds: integer(step.timerDurationSeconds, 0, `${field}.timerDurationSeconds`),
|
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())
|
if (result.mode === 8 && !result.text.trim())
|
||||||
throw new ResponseFieldError(
|
throw new ResponseFieldError(
|
||||||
`${field}.text`,
|
`${field}.text`,
|
||||||
@@ -510,6 +520,8 @@ export function parseScreenPackage(value: unknown): ScreenPackage {
|
|||||||
mode: step.mode,
|
mode: step.mode,
|
||||||
text: step.text,
|
text: step.text,
|
||||||
timerLabel: step.timerLabel,
|
timerLabel: step.timerLabel,
|
||||||
|
qrUrl: step.qrUrl,
|
||||||
|
timerType: step.timerType,
|
||||||
timerDurationSeconds: step.timerDurationSeconds,
|
timerDurationSeconds: step.timerDurationSeconds,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
+28
-3
@@ -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 { ResponseFieldError } from './request-error.js'
|
||||||
import type { ScreenMode, ScreenPlan } from '../src/types/screen.js'
|
import type { ScreenMode, ScreenPlan } from '../src/types/screen.js'
|
||||||
import { integer, object, text } from './remote-state.js'
|
import { integer, object, text } from './remote-state.js'
|
||||||
@@ -12,6 +14,8 @@ const modes = [
|
|||||||
'RESULTS',
|
'RESULTS',
|
||||||
'TIMER',
|
'TIMER',
|
||||||
'TEXT',
|
'TEXT',
|
||||||
|
'QR',
|
||||||
|
'TEAMS',
|
||||||
]
|
]
|
||||||
|
|
||||||
export function parsePlan(value: unknown): ScreenPlan {
|
export function parsePlan(value: unknown): ScreenPlan {
|
||||||
@@ -29,13 +33,13 @@ export function parsePlan(value: unknown): ScreenPlan {
|
|||||||
? modes.indexOf(step.mode.slice(12))
|
? modes.indexOf(step.mode.slice(12))
|
||||||
: step.mode,
|
: step.mode,
|
||||||
1,
|
1,
|
||||||
8,
|
10,
|
||||||
`${field}.mode`,
|
`${field}.mode`,
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
throw new ResponseFieldError(
|
throw new ResponseFieldError(
|
||||||
`${field}.mode`,
|
`${field}.mode`,
|
||||||
'режим экрана 1–8 или enum ScreenMode',
|
'режим экрана 1–10 или enum ScreenMode',
|
||||||
step.mode,
|
step.mode,
|
||||||
'Некорректный режим экрана',
|
'Некорректный режим экрана',
|
||||||
)
|
)
|
||||||
@@ -45,6 +49,9 @@ export function parsePlan(value: unknown): ScreenPlan {
|
|||||||
content = mode === 3 ? 'Успехов' : 'Спасибо за игру!'
|
content = mode === 3 ? 'Успехов' : 'Спасибо за игру!'
|
||||||
mode = 8
|
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 label = text(step.timerLabel ?? '', `${field}.timerLabel`).trim()
|
||||||
const seconds = integer(
|
const seconds = integer(
|
||||||
step.timerDurationSeconds ?? 0,
|
step.timerDurationSeconds ?? 0,
|
||||||
@@ -69,8 +76,10 @@ export function parsePlan(value: unknown): ScreenPlan {
|
|||||||
return {
|
return {
|
||||||
mode: mode as ScreenMode,
|
mode: mode as ScreenMode,
|
||||||
text: content,
|
text: content,
|
||||||
timerLabel: mode === 7 ? label || 'Перерыв' : label,
|
timerLabel: mode === 7 ? label || timerLabels[timerType] : label,
|
||||||
timerDurationSeconds: seconds,
|
timerDurationSeconds: seconds,
|
||||||
|
...(qrUrl ? { qrUrl } : {}),
|
||||||
|
...(timerType ? { timerType } : {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return {
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ module.exports = async function previewsSmoke({ control, output, evaluate, inspe
|
|||||||
const root = document.querySelector(${JSON.stringify(root)});
|
const root = document.querySelector(${JSON.stringify(root)});
|
||||||
if (!root) return null;
|
if (!root) return null;
|
||||||
const bounds = root.getBoundingClientRect();
|
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 e = root.querySelector(selector); if (!e) return [];
|
||||||
const r = e.getBoundingClientRect(); const s = getComputedStyle(e); if (s.display === 'none') 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 }];
|
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: [], 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 }] }] } },
|
{ 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) {
|
for (const item of cases) {
|
||||||
const catalog = item.catalog ?? original.catalog
|
const catalog = item.catalog ?? original.catalog
|
||||||
const step = catalog.steps[item.index]
|
const step = catalog.steps[item.index]
|
||||||
const snapshot = { ...original, revision: ++revision, catalog, results: item.results ?? original.results,
|
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, stepIndex: item.index },
|
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 } } }
|
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)
|
control.webContents.send('state', snapshot)
|
||||||
output.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 poll(() => evaluate(`document.querySelector('${miniature(item.index)} .scene-view').dataset.mode === '${step.mode}'`), 'fixture thumbnail')
|
||||||
await new Promise(resolve => setTimeout(resolve, 50))
|
await new Promise(resolve => setTimeout(resolve, 50))
|
||||||
await compare(item.index)
|
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')
|
console.log('SCREEN_PREVIEWS_PASS')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -408,6 +408,7 @@ onUnmounted(() => {
|
|||||||
<ScreenPlanPanel
|
<ScreenPlanPanel
|
||||||
:catalog="state.catalog"
|
:catalog="state.catalog"
|
||||||
:results="state.results"
|
:results="state.results"
|
||||||
|
:team-names="state.teams?.teams.map(team => team.name)"
|
||||||
:viewport="previewViewport"
|
:viewport="previewViewport"
|
||||||
:steps="state.catalog.steps"
|
:steps="state.catalog.steps"
|
||||||
:current-index="state.scene.stepIndex"
|
:current-index="state.scene.stepIndex"
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ const audio = ref<HTMLAudioElement | null>(null)
|
|||||||
const video = ref<HTMLVideoElement | null>(null)
|
const video = ref<HTMLVideoElement | null>(null)
|
||||||
const musicA = ref<HTMLAudioElement | null>(null)
|
const musicA = ref<HTMLAudioElement | null>(null)
|
||||||
const musicB = 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 surface = ref<HTMLElement | null>(null)
|
||||||
const state = shallowRef<State | null>(null)
|
const state = shallowRef<State | null>(null)
|
||||||
const imageError = ref(false)
|
const imageError = ref(false)
|
||||||
@@ -210,6 +214,7 @@ function apply(value: State) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
window.addEventListener('resize', updateViewport)
|
||||||
try {
|
try {
|
||||||
await bridge.prepareAudioOutput()
|
await bridge.prepareAudioOutput()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -224,6 +229,7 @@ onMounted(async () => {
|
|||||||
apply(await bridge.state())
|
apply(await bridge.state())
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', updateViewport)
|
||||||
disposed = true
|
disposed = true
|
||||||
sceneAbort?.abort()
|
sceneAbort?.abort()
|
||||||
clearIntroWaveform()
|
clearIntroWaveform()
|
||||||
@@ -238,7 +244,10 @@ onUnmounted(() => {
|
|||||||
v-if="state"
|
v-if="state"
|
||||||
:catalog="state.catalog"
|
:catalog="state.catalog"
|
||||||
:mode="state.scene.mode"
|
:mode="state.scene.mode"
|
||||||
|
:viewport="viewport"
|
||||||
:text="state.scene.text"
|
:text="state.scene.text"
|
||||||
|
:qr-url="state.scene.qrUrl"
|
||||||
|
:team-names="state.teams?.teams.map(team => team.name)"
|
||||||
:timer-label="state.timer.label"
|
:timer-label="state.timer.label"
|
||||||
:timer-display="timerDisplay"
|
:timer-display="timerDisplay"
|
||||||
:results="state.results"
|
:results="state.results"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<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 type { Catalog, ResultsSnapshot, ScreenMode } from '../types/screen'
|
||||||
import ResultsTable from './ResultsTable.vue'
|
import ResultsTable from './ResultsTable.vue'
|
||||||
|
|
||||||
@@ -7,6 +8,8 @@ const props = withDefaults(defineProps<{
|
|||||||
catalog: Catalog
|
catalog: Catalog
|
||||||
mode: ScreenMode
|
mode: ScreenMode
|
||||||
text: string
|
text: string
|
||||||
|
qrUrl?: string
|
||||||
|
teamNames?: string[]
|
||||||
timerLabel: string
|
timerLabel: string
|
||||||
timerDisplay: string
|
timerDisplay: string
|
||||||
results: ResultsSnapshot | null
|
results: ResultsSnapshot | null
|
||||||
@@ -20,6 +23,7 @@ const props = withDefaults(defineProps<{
|
|||||||
animatedResults?: boolean
|
animatedResults?: boolean
|
||||||
viewport?: { width: number; height: number }
|
viewport?: { width: number; height: number }
|
||||||
}>(), {
|
}>(), {
|
||||||
|
qrUrl: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
imageVersion: '',
|
imageVersion: '',
|
||||||
imageError: false,
|
imageError: false,
|
||||||
@@ -28,6 +32,8 @@ const props = withDefaults(defineProps<{
|
|||||||
introTime: '',
|
introTime: '',
|
||||||
animatedResults: false,
|
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>())
|
const failedImages = reactive(new Set<string>())
|
||||||
watch(() => [props.imageVersion, props.catalog.cover, props.catalog.background], () => failedImages.clear(), { flush: 'sync' })
|
watch(() => [props.imageVersion, props.catalog.cover, props.catalog.background], () => failedImages.clear(), { flush: 'sync' })
|
||||||
function imageURL(url: string, version: string) {
|
function imageURL(url: string, version: string) {
|
||||||
@@ -93,6 +99,17 @@ function imageURL(url: string, version: string) {
|
|||||||
<h1>{{ timerLabel }}</h1>
|
<h1>{{ timerLabel }}</h1>
|
||||||
<strong>{{ timerDisplay }}</strong>
|
<strong>{{ timerDisplay }}</strong>
|
||||||
</section>
|
</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>
|
<p v-else-if="mode === 8" class="message text">{{ text }}</p>
|
||||||
</div>
|
</div>
|
||||||
<slot />
|
<slot />
|
||||||
@@ -198,4 +215,39 @@ function imageURL(url: string, version: string) {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
line-height: 1;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { NAlert, NButton, NInput, NInputNumber, NSelect, NSpace } from 'naive-ui
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { playbackControl } from '../utils/playbackControl'
|
import { playbackControl } from '../utils/playbackControl'
|
||||||
import type { Catalog, ChannelState, Intent, ResultsSnapshot, ScreenMode, ScreenStep, TimerState } from '../types/screen'
|
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'
|
import ScreenStepPreview from './ScreenStepPreview.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
catalog: Catalog
|
catalog: Catalog
|
||||||
results: ResultsSnapshot | null
|
results: ResultsSnapshot | null
|
||||||
|
teamNames?: string[]
|
||||||
viewport: { width: number; height: number }
|
viewport: { width: number; height: number }
|
||||||
steps: ScreenStep[]
|
steps: ScreenStep[]
|
||||||
currentIndex: number | null
|
currentIndex: number | null
|
||||||
@@ -33,6 +34,14 @@ const emit = defineEmits<{
|
|||||||
const mainControl = computed(() =>
|
const mainControl = computed(() =>
|
||||||
playbackControl({ desired: props.mainDesired, status: props.mainStatus }),
|
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]) => ({
|
const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
|
||||||
value: Number(mode) as ScreenMode,
|
value: Number(mode) as ScreenMode,
|
||||||
label,
|
label,
|
||||||
@@ -56,6 +65,7 @@ function timerLabel(index: number) {
|
|||||||
:step="step"
|
:step="step"
|
||||||
:catalog="catalog"
|
:catalog="catalog"
|
||||||
:results="results"
|
:results="results"
|
||||||
|
:team-names="teamNames"
|
||||||
:viewport="viewport"
|
:viewport="viewport"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +95,7 @@ function timerLabel(index: number) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<n-input
|
<n-input
|
||||||
v-if="step.mode === 8"
|
v-if="step.mode === 8 || step.mode === 9"
|
||||||
:value="step.text"
|
:value="step.text"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:disabled="isBusy"
|
:disabled="isBusy"
|
||||||
@@ -93,7 +103,15 @@ function timerLabel(index: number) {
|
|||||||
:input-props="{ 'aria-label': `Текст шага ${index + 1}` }"
|
:input-props="{ 'aria-label': `Текст шага ${index + 1}` }"
|
||||||
@update:value="emit('updateStep', index, { text: $event })"
|
@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>
|
<label :for="`plan-label-${index}`">Надпись таймера</label>
|
||||||
<n-input
|
<n-input
|
||||||
:value="step.timerLabel"
|
:value="step.timerLabel"
|
||||||
@@ -110,7 +128,17 @@ function timerLabel(index: number) {
|
|||||||
:input-props="{ id: `plan-seconds-${index}` }"
|
:input-props="{ id: `plan-seconds-${index}` }"
|
||||||
@update:value="emit('updateStep', index, { timerDurationSeconds: $event ?? 0 })"
|
@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>
|
</div>
|
||||||
<n-space>
|
<n-space>
|
||||||
<n-button size="small" :disabled="isBusy" @click="emit('add')">Добавить шаг</n-button>
|
<n-button size="small" :disabled="isBusy" @click="emit('add')">Добавить шаг</n-button>
|
||||||
@@ -133,6 +161,7 @@ function timerLabel(index: number) {
|
|||||||
:step="step"
|
:step="step"
|
||||||
:catalog="catalog"
|
:catalog="catalog"
|
||||||
:results="results"
|
:results="results"
|
||||||
|
:team-names="teamNames"
|
||||||
:viewport="viewport"
|
:viewport="viewport"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,7 +169,7 @@ function timerLabel(index: number) {
|
|||||||
<div class="plan-step-title">
|
<div class="plan-step-title">
|
||||||
<span
|
<span
|
||||||
>{{ modeLabels[step.mode]
|
>{{ 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"
|
><template v-if="step.mode === 7"
|
||||||
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
|
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
|
||||||
></span
|
></span
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const props = defineProps<{
|
|||||||
step: ScreenStep
|
step: ScreenStep
|
||||||
catalog: Catalog
|
catalog: Catalog
|
||||||
results: ResultsSnapshot | null
|
results: ResultsSnapshot | null
|
||||||
|
teamNames?: string[]
|
||||||
viewport: { width: number; height: number }
|
viewport: { width: number; height: number }
|
||||||
}>()
|
}>()
|
||||||
const source = computed(() => props.catalog.media.find(media =>
|
const source = computed(() => props.catalog.media.find(media =>
|
||||||
@@ -22,6 +23,8 @@ const source = computed(() => props.catalog.media.find(media =>
|
|||||||
:catalog="catalog"
|
:catalog="catalog"
|
||||||
:mode="step.mode"
|
:mode="step.mode"
|
||||||
:text="step.text"
|
:text="step.text"
|
||||||
|
:qr-url="step.qrUrl"
|
||||||
|
:team-names="teamNames"
|
||||||
:timer-label="step.timerLabel"
|
:timer-label="step.timerLabel"
|
||||||
:timer-display="formatTimer(step.timerDurationSeconds * 1000)"
|
:timer-display="formatTimer(step.timerDurationSeconds * 1000)"
|
||||||
:results="results"
|
:results="results"
|
||||||
|
|||||||
+14
-2
@@ -15,7 +15,8 @@ export type Intent =
|
|||||||
| { action: 'mode'; mode: ScreenMode }
|
| { action: 'mode'; mode: ScreenMode }
|
||||||
| { action: 'step'; index: number }
|
| { action: 'step'; index: number }
|
||||||
| { action: 'text'; text: string }
|
| { 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' }
|
| { action: 'timer-start' | 'timer-pause' | 'timer-resume' | 'timer-reset' }
|
||||||
export type Command = Intent & { session: string; sequence: number }
|
export type Command = Intent & { session: string; sequence: number }
|
||||||
export interface MediaSource {
|
export interface MediaSource {
|
||||||
@@ -30,14 +31,18 @@ export interface Playlist {
|
|||||||
name: string
|
name: string
|
||||||
tracks: { id: number; name: string; source: MediaSource }[]
|
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 {
|
export interface ScreenStep {
|
||||||
|
qrUrl?: string
|
||||||
|
timerType?: TimerType
|
||||||
mode: ScreenMode
|
mode: ScreenMode
|
||||||
text: string
|
text: string
|
||||||
timerLabel: string
|
timerLabel: string
|
||||||
timerDurationSeconds: number
|
timerDurationSeconds: number
|
||||||
}
|
}
|
||||||
export interface SceneState {
|
export interface SceneState {
|
||||||
|
qrUrl?: string
|
||||||
revision: number
|
revision: number
|
||||||
mode: ScreenMode
|
mode: ScreenMode
|
||||||
stepIndex: number | null
|
stepIndex: number | null
|
||||||
@@ -46,6 +51,7 @@ export interface SceneState {
|
|||||||
error: string
|
error: string
|
||||||
}
|
}
|
||||||
export interface TimerState {
|
export interface TimerState {
|
||||||
|
timerType?: TimerType
|
||||||
revision: number
|
revision: number
|
||||||
label: string
|
label: string
|
||||||
durationMs: number
|
durationMs: number
|
||||||
@@ -99,7 +105,13 @@ export interface ChannelState {
|
|||||||
trackIndex: number
|
trackIndex: number
|
||||||
loop: boolean
|
loop: boolean
|
||||||
}
|
}
|
||||||
|
export interface TeamsSnapshot {
|
||||||
|
packageKey: string
|
||||||
|
capturedAt: string
|
||||||
|
teams: { id: string; name: string }[]
|
||||||
|
}
|
||||||
export interface State {
|
export interface State {
|
||||||
|
teams: TeamsSnapshot | null
|
||||||
session: string
|
session: string
|
||||||
sequence: number
|
sequence: number
|
||||||
revision: number
|
revision: number
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export const modeLabels: Record<ScreenMode, string> = {
|
|||||||
6: 'Результаты',
|
6: 'Результаты',
|
||||||
7: 'Таймер',
|
7: 'Таймер',
|
||||||
8: 'Текст',
|
8: 'Текст',
|
||||||
|
9: 'QR-код',
|
||||||
|
10: 'Список команд',
|
||||||
}
|
}
|
||||||
export function remainingTime(timer: TimerState, elapsedMs: number): number {
|
export function remainingTime(timer: TimerState, elapsedMs: number): number {
|
||||||
return Math.max(0, timer.remainingMs - (timer.status === 'running' ? Math.max(0, elapsedMs) : 0))
|
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)
|
const seconds = Math.ceil(Math.max(0, milliseconds) / 1000)
|
||||||
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user