up
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user