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
+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
}
}