up
This commit is contained in:
@@ -4,13 +4,26 @@ import {
|
||||
NButton,
|
||||
NCard,
|
||||
NInputNumber,
|
||||
NInput,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NSwitch,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { Channel, HostInfo, Intent, PackageSummary, Playback, State } from '../types/screen'
|
||||
import type {
|
||||
Channel,
|
||||
HostInfo,
|
||||
Intent,
|
||||
PackageSummary,
|
||||
Playback,
|
||||
PreviewFrame,
|
||||
ScreenMode,
|
||||
State,
|
||||
} from '../types/screen'
|
||||
|
||||
import { modeLabels } from '../utils/presentation'
|
||||
import { useScreenTimer } from '../composables/useScreenTimer'
|
||||
|
||||
const message = useMessage()
|
||||
const bridge = window.screenBridge
|
||||
@@ -20,6 +33,68 @@ const host = ref<HostInfo>({ displays: [] })
|
||||
const display = ref<number | null>(null)
|
||||
const positions = ref<Record<Channel, number | null>>({ main: 10, music: 10 })
|
||||
const isBusy = ref(false)
|
||||
const textDraft = ref('')
|
||||
const timerLabel = ref('Перерыв')
|
||||
const timerSeconds = ref<number | null>(300)
|
||||
const timerDisplay = useScreenTimer(() => state.value)
|
||||
const preview = ref<PreviewFrame | null>(null)
|
||||
const modeOptions = Object.entries(modeLabels).map(([key, label]) => ({
|
||||
label,
|
||||
value: Number(key) as ScreenMode,
|
||||
}))
|
||||
const previewURL = computed(() =>
|
||||
preview.value?.session === state.value?.session &&
|
||||
preview.value?.revision === state.value?.scene.revision
|
||||
? preview.value?.dataURL
|
||||
: null,
|
||||
)
|
||||
const stepOptions = computed(() =>
|
||||
(state.value?.catalog.steps ?? []).map((step, index) => ({
|
||||
label: `${index + 1}. ${modeLabels[step.mode]}${step.mode === 8 ? ` · ${step.text.slice(0, 60)}` : ''}`,
|
||||
value: index,
|
||||
})),
|
||||
)
|
||||
const sceneLabels = {
|
||||
idle: 'Окно показа закрыто',
|
||||
pending: 'Подготовка показа',
|
||||
ready: 'Показ готов',
|
||||
error: 'Не удалось подготовить показ',
|
||||
}
|
||||
const timerLabels = {
|
||||
stopped: 'Не запущен',
|
||||
running: 'Идёт',
|
||||
paused: 'Пауза',
|
||||
finished: 'Завершён',
|
||||
}
|
||||
let previewTimer: ReturnType<typeof setInterval> | undefined
|
||||
let previewBusy = false
|
||||
async function refreshPreview() {
|
||||
if (
|
||||
!bridge ||
|
||||
previewBusy ||
|
||||
!state.value?.outputOpen ||
|
||||
document.visibilityState !== 'visible' ||
|
||||
state.value.scene.status !== 'ready'
|
||||
)
|
||||
return
|
||||
previewBusy = true
|
||||
try {
|
||||
const frame = await bridge.preview()
|
||||
if (
|
||||
frame &&
|
||||
state.value.outputOpen &&
|
||||
frame.session === state.value.session &&
|
||||
frame.revision === state.value.scene.revision &&
|
||||
document.visibilityState === 'visible'
|
||||
)
|
||||
preview.value = frame
|
||||
} catch {
|
||||
/* Показ продолжает работать и при недоступности кадра предпросмотра. */
|
||||
} finally {
|
||||
previewBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
const channels = ['main', 'music'] as const
|
||||
const playlist = computed(() =>
|
||||
state.value?.catalog.playlists.find((p) => p.id === state.value?.channels.music.playlistId),
|
||||
@@ -43,6 +118,22 @@ let session = ''
|
||||
let sequence = 0
|
||||
function acceptState(value: State) {
|
||||
if (state.value && value.revision < state.value.revision) return
|
||||
if (value.session !== state.value?.session || value.scene.text !== state.value?.scene.text)
|
||||
textDraft.value = value.scene.text
|
||||
if (
|
||||
value.session !== state.value?.session ||
|
||||
value.timer.label !== state.value?.timer.label ||
|
||||
value.timer.durationMs !== state.value?.timer.durationMs
|
||||
) {
|
||||
timerLabel.value = value.timer.label
|
||||
timerSeconds.value = value.timer.durationMs / 1000
|
||||
}
|
||||
if (
|
||||
!value.outputOpen ||
|
||||
value.session !== state.value?.session ||
|
||||
value.scene.revision !== state.value?.scene.revision
|
||||
)
|
||||
preview.value = null
|
||||
if (session !== value.session) {
|
||||
session = value.session
|
||||
sequence = value.sequence
|
||||
@@ -100,6 +191,9 @@ onMounted(async () => {
|
||||
packages.value = await bridge.packages()
|
||||
await refreshHost()
|
||||
})
|
||||
previewTimer = setInterval(() => {
|
||||
void refreshPreview()
|
||||
}, 500)
|
||||
let refreshing = false
|
||||
timer = setInterval(() => {
|
||||
if (refreshing) return
|
||||
@@ -113,6 +207,7 @@ onMounted(async () => {
|
||||
})
|
||||
onUnmounted(() => {
|
||||
clearInterval(timer)
|
||||
clearInterval(previewTimer)
|
||||
unsubscribe?.()
|
||||
})
|
||||
</script>
|
||||
@@ -154,6 +249,144 @@ onUnmounted(() => {
|
||||
<n-alert v-if="!state.outputOpen" type="info"
|
||||
>Откройте показ, чтобы подготовить медиа и включить воспроизведение.</n-alert
|
||||
>
|
||||
<section class="presentation-grid">
|
||||
<n-card title="План показа">
|
||||
<n-space vertical>
|
||||
<n-select
|
||||
:value="state.scene.stepIndex"
|
||||
:options="stepOptions"
|
||||
:disabled="isBusy || !state.catalog.steps.length"
|
||||
placeholder="Выберите шаг"
|
||||
:input-props="{ 'aria-label': 'Шаг плана' }"
|
||||
@update:value="send({ action: 'step', index: $event })"
|
||||
/>
|
||||
<p v-if="!state.catalog.steps.length">
|
||||
В пакете нет плана. Используйте переключатель режимов.
|
||||
</p>
|
||||
<n-space>
|
||||
<n-button
|
||||
:disabled="isBusy || state.scene.stepIndex === null || state.scene.stepIndex <= 0"
|
||||
@click="send({ action: 'step', index: (state.scene.stepIndex ?? 0) - 1 })"
|
||||
>Предыдущий шаг</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="
|
||||
isBusy ||
|
||||
!state.catalog.steps.length ||
|
||||
(state.scene.stepIndex ?? -1) >= state.catalog.steps.length - 1
|
||||
"
|
||||
@click="send({ action: 'step', index: (state.scene.stepIndex ?? -1) + 1 })"
|
||||
>Следующий шаг</n-button
|
||||
>
|
||||
</n-space>
|
||||
<label>Режим показа</label>
|
||||
<n-select
|
||||
:value="state.scene.mode"
|
||||
:options="modeOptions"
|
||||
:disabled="isBusy"
|
||||
:input-props="{ 'aria-label': 'Режим показа' }"
|
||||
@update:value="send({ action: 'mode', mode: $event })"
|
||||
/>
|
||||
<p role="status">{{ sceneLabels[state.scene.status] }}</p>
|
||||
<n-alert v-if="state.scene.error" type="error">{{ state.scene.error }}</n-alert>
|
||||
<n-button
|
||||
v-if="state.scene.status === 'error'"
|
||||
@click="send({ action: 'mode', mode: state.scene.mode })"
|
||||
>Повторить подготовку</n-button
|
||||
>
|
||||
<p v-if="state.scene.mode === 6">
|
||||
{{
|
||||
state.results
|
||||
? `Снимок результатов: ${new Date(state.results.capturedAt).toLocaleString('ru-RU')}`
|
||||
: 'Результаты пока не загружены'
|
||||
}}
|
||||
</p>
|
||||
</n-space>
|
||||
</n-card>
|
||||
<n-card title="Текущий показ">
|
||||
<img
|
||||
v-if="previewURL"
|
||||
class="preview"
|
||||
data-testid="preview"
|
||||
:src="previewURL"
|
||||
alt="Предпросмотр текущего показа"
|
||||
/>
|
||||
<div v-else class="preview-empty">
|
||||
{{
|
||||
!state.outputOpen
|
||||
? 'Окно показа закрыто'
|
||||
: state.scene.status === 'pending'
|
||||
? 'Подготовка показа…'
|
||||
: 'Предпросмотр недоступен'
|
||||
}}
|
||||
</div>
|
||||
<p>Кадр реального окна показа, без второго звука.</p>
|
||||
</n-card>
|
||||
</section>
|
||||
<n-card title="Текст и таймер">
|
||||
<n-space vertical>
|
||||
<label for="scene-text">Текст для показа</label>
|
||||
<n-input
|
||||
v-model:value="textDraft"
|
||||
type="textarea"
|
||||
:maxlength="10000"
|
||||
:autosize="{ minRows: 2, maxRows: 6 }"
|
||||
:input-props="{ id: 'scene-text' }"
|
||||
/>
|
||||
<n-button
|
||||
:disabled="isBusy || !textDraft.trim()"
|
||||
@click="send({ action: 'text', text: textDraft })"
|
||||
>Показать текст</n-button
|
||||
>
|
||||
<label for="timer-label">Подпись таймера</label>
|
||||
<n-input
|
||||
v-model:value="timerLabel"
|
||||
:maxlength="200"
|
||||
:input-props="{ id: 'timer-label' }"
|
||||
/>
|
||||
<label for="timer-seconds">Длительность, секунды</label>
|
||||
<n-input-number
|
||||
v-model:value="timerSeconds"
|
||||
:min="1"
|
||||
:max="86400"
|
||||
:precision="0"
|
||||
:input-props="{ id: 'timer-seconds' }"
|
||||
/>
|
||||
<n-button
|
||||
:disabled="isBusy || !timerSeconds"
|
||||
@click="
|
||||
send({ action: 'timer-set', label: timerLabel, durationSeconds: timerSeconds ?? 0 })
|
||||
"
|
||||
>Показать таймер</n-button
|
||||
>
|
||||
<p role="status" class="timer-status">
|
||||
{{ state.timer.label }} · {{ timerDisplay }} · {{ timerLabels[state.timer.status] }}
|
||||
</p>
|
||||
<p v-if="state.scene.mode !== 7 && state.timer.status === 'running'">
|
||||
Таймер продолжает отсчёт в фоне.
|
||||
</p>
|
||||
<n-space>
|
||||
<n-button
|
||||
:disabled="isBusy || state.timer.status === 'running'"
|
||||
@click="send({ action: 'timer-start' })"
|
||||
>Запустить таймер</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="isBusy || state.timer.status !== 'running'"
|
||||
@click="send({ action: 'timer-pause' })"
|
||||
>Пауза таймера</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="isBusy || state.timer.status !== 'paused'"
|
||||
@click="send({ action: 'timer-resume' })"
|
||||
>Продолжить таймер</n-button
|
||||
>
|
||||
<n-button :disabled="isBusy" @click="send({ action: 'timer-reset' })"
|
||||
>Сбросить таймер</n-button
|
||||
>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
<label class="mute"
|
||||
>Выключить весь звук
|
||||
<n-switch
|
||||
@@ -286,10 +519,36 @@ onUnmounted(() => {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin: auto;
|
||||
max-width: 900px;
|
||||
max-width: 1280px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.presentation-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
|
||||
}
|
||||
|
||||
.preview {
|
||||
background: var(--brand-background);
|
||||
display: block;
|
||||
max-height: 50vh;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
align-items: center;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--brand-background);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.timer-status {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
+187
-12
@@ -1,22 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
||||
import type { State } from '../types/screen'
|
||||
import { MediaPlayer } from '../utils/media-player'
|
||||
import { MediaPlayer, waitUntil } from '../utils/media-player'
|
||||
import { useScreenTimer } from '../composables/useScreenTimer'
|
||||
import ResultsTable from './ResultsTable.vue'
|
||||
|
||||
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 showVideo = ref(false)
|
||||
const surface = ref<HTMLElement | null>(null)
|
||||
const state = shallowRef<State | null>(null)
|
||||
const imageError = ref(false)
|
||||
const timerDisplay = useScreenTimer(() => state.value)
|
||||
const bridge = window.screenBridge!
|
||||
let player: MediaPlayer | undefined
|
||||
let unsubscribe: (() => void) | undefined
|
||||
let revision = -1
|
||||
function apply(state: State) {
|
||||
if (state.revision <= revision) return
|
||||
revision = state.revision
|
||||
showVideo.value = state.channels.main.source?.kind === 'video'
|
||||
player?.apply(state)
|
||||
let sceneToken = ''
|
||||
let sceneAbort: AbortController | null = null
|
||||
// Новый адрес исключает повторное использование уже декодированной картинки Chromium.
|
||||
function imageURL(url: string) {
|
||||
const result = new URL(url)
|
||||
result.searchParams.set('scene', `${state.value?.session}:${state.value?.scene.revision}`)
|
||||
return result.href
|
||||
}
|
||||
function waitImage(image: HTMLImageElement, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const finish = (error?: string) => {
|
||||
clearTimeout(timeout)
|
||||
image.removeEventListener('load', loaded)
|
||||
image.removeEventListener('error', failed)
|
||||
signal.removeEventListener('abort', aborted)
|
||||
if (error) reject(new Error(error))
|
||||
else resolve()
|
||||
}
|
||||
const loaded = () =>
|
||||
finish(image.naturalWidth ? undefined : 'Не удалось подготовить изображение')
|
||||
const failed = () => finish('Не удалось подготовить изображение')
|
||||
const aborted = () => finish('Подготовка отменена')
|
||||
const timeout = setTimeout(() => finish('Изображение не подготовлено за 10 секунд'), 10000)
|
||||
image.addEventListener('load', loaded)
|
||||
image.addEventListener('error', failed)
|
||||
signal.addEventListener('abort', aborted, { once: true })
|
||||
if (signal.aborted) aborted()
|
||||
else if (image.complete) loaded()
|
||||
})
|
||||
}
|
||||
async function prepareScene(snapshot: State) {
|
||||
sceneAbort?.abort()
|
||||
const abort = new AbortController()
|
||||
sceneAbort = abort
|
||||
const current = () =>
|
||||
!abort.signal.aborted &&
|
||||
state.value?.session === snapshot.session &&
|
||||
state.value.scene.revision === snapshot.scene.revision
|
||||
imageError.value = false
|
||||
try {
|
||||
await nextTick()
|
||||
if (!current()) return
|
||||
await Promise.all(
|
||||
Array.from(surface.value?.querySelectorAll('img') ?? []).map((image) =>
|
||||
waitImage(image, abort.signal),
|
||||
),
|
||||
)
|
||||
const media =
|
||||
snapshot.scene.mode === 2 ? audio.value : snapshot.scene.mode === 4 ? video.value : null
|
||||
if (media && snapshot.channels.main.source)
|
||||
await waitUntil(media, 'canplay', () => media.readyState >= 3, abort.signal)
|
||||
if (current())
|
||||
bridge.reportScene({
|
||||
session: snapshot.session,
|
||||
revision: snapshot.scene.revision,
|
||||
status: 'ready',
|
||||
})
|
||||
} catch (error) {
|
||||
if (!current()) return
|
||||
imageError.value = true
|
||||
bridge.reportScene({
|
||||
session: snapshot.session,
|
||||
revision: snapshot.scene.revision,
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Не удалось подготовить сцену',
|
||||
})
|
||||
}
|
||||
}
|
||||
function apply(value: State) {
|
||||
if (value.revision <= revision) return
|
||||
revision = value.revision
|
||||
state.value = value
|
||||
player?.apply(value)
|
||||
const token = `${value.session}:${value.scene.revision}`
|
||||
if (token !== sceneToken) {
|
||||
sceneToken = token
|
||||
void prepareScene(value)
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
player = new MediaPlayer(
|
||||
@@ -27,15 +105,61 @@ onMounted(async () => {
|
||||
apply(await bridge.state())
|
||||
})
|
||||
onUnmounted(() => {
|
||||
sceneAbort?.abort()
|
||||
unsubscribe?.()
|
||||
player?.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="output">
|
||||
<main ref="surface" class="output" :data-mode="state?.scene.mode">
|
||||
<img
|
||||
v-if="state?.catalog.background && state.scene.mode !== 1"
|
||||
:key="`${state.session}:${state.scene.revision}:background`"
|
||||
class="background"
|
||||
:class="{ failed: imageError }"
|
||||
:src="imageURL(state.catalog.background)"
|
||||
alt=""
|
||||
/>
|
||||
<div v-if="state" class="scene">
|
||||
<template v-if="state.scene.mode === 1">
|
||||
<img
|
||||
v-if="state.catalog.cover"
|
||||
:key="`${state.session}:${state.scene.revision}:cover`"
|
||||
class="cover"
|
||||
:class="{ failed: imageError }"
|
||||
:src="imageURL(state.catalog.cover)"
|
||||
alt="Обложка дела"
|
||||
/>
|
||||
<h1 v-else class="message">{{ state.catalog.name }}</h1>
|
||||
</template>
|
||||
<p v-else-if="state.scene.mode === 2 && !state.channels.main.source" class="message">
|
||||
Вступление не задано
|
||||
</p>
|
||||
<p v-else-if="state.scene.mode === 3" class="message">Успехов</p>
|
||||
<p v-else-if="state.scene.mode === 4 && !state.channels.main.source" class="message">
|
||||
Видео закрывашки не задано
|
||||
</p>
|
||||
<p v-else-if="state.scene.mode === 5" class="message">Спасибо за игру!</p>
|
||||
<ResultsTable
|
||||
v-else-if="state.scene.mode === 6"
|
||||
:snapshot="state.results"
|
||||
:revision="state.scene.revision"
|
||||
/>
|
||||
<section v-else-if="state.scene.mode === 7" class="timer">
|
||||
<h1>{{ state.timer.label }}</h1>
|
||||
<strong>{{ timerDisplay }}</strong>
|
||||
</section>
|
||||
<p v-else-if="state.scene.mode === 8" class="message text">{{ state.scene.text }}</p>
|
||||
</div>
|
||||
<audio ref="audio" data-channel="main-audio" preload="auto" />
|
||||
<video v-show="showVideo" ref="video" data-channel="main-video" preload="auto" playsinline />
|
||||
<video
|
||||
v-show="state?.scene.mode === 4 && state.channels.main.source?.kind === 'video'"
|
||||
ref="video"
|
||||
data-channel="main-video"
|
||||
preload="auto"
|
||||
playsinline
|
||||
/>
|
||||
<audio ref="musicA" data-channel="music-a" preload="auto" />
|
||||
<audio ref="musicB" data-channel="music-b" preload="auto" />
|
||||
</main>
|
||||
@@ -46,12 +170,63 @@ onUnmounted(() => {
|
||||
background: var(--brand-background);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
video {
|
||||
.scene {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.background,
|
||||
.cover,
|
||||
video {
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.background {
|
||||
filter: brightness(45%);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover,
|
||||
video {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.failed {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: clamp(32px, 7vw, 140px);
|
||||
margin: 0;
|
||||
max-height: 90vh;
|
||||
max-width: 92vw;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.timer h1 {
|
||||
font-size: clamp(28px, 5vw, 80px);
|
||||
}
|
||||
|
||||
.timer strong {
|
||||
font-size: clamp(80px, 20vw, 300px);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import type { ResultsSnapshot } from '../types/screen'
|
||||
|
||||
const props = defineProps<{ snapshot: ResultsSnapshot | null; revision: number }>()
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
let frame = 0
|
||||
let generation = 0
|
||||
watch(
|
||||
() => props.revision,
|
||||
async () => {
|
||||
const current = ++generation
|
||||
cancelAnimationFrame(frame)
|
||||
await nextTick()
|
||||
if (current !== generation || !container.value || !props.snapshot) return
|
||||
const element = container.value
|
||||
const distance = Math.max(0, element.scrollHeight - element.clientHeight)
|
||||
const start = performance.now()
|
||||
element.scrollTop = distance
|
||||
const scroll = (now: number) => {
|
||||
if (current !== generation) return
|
||||
element.scrollTop = Math.max(0, distance - (now - start) * 0.032)
|
||||
if (element.scrollTop > 0) frame = requestAnimationFrame(scroll)
|
||||
}
|
||||
frame = requestAnimationFrame(scroll)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
onUnmounted(() => {
|
||||
generation++
|
||||
cancelAnimationFrame(frame)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="results">
|
||||
<h1>Результаты</h1>
|
||||
<p v-if="!snapshot" class="empty">Результаты пока не загружены</p>
|
||||
<p v-else-if="!snapshot.teams.length" class="empty">Нет результатов команд</p>
|
||||
<div v-else ref="container" class="results-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Место</th>
|
||||
<th>Команда</th>
|
||||
<th v-for="question in snapshot.questions" :key="question.code" :title="question.text">
|
||||
{{ question.code }}
|
||||
</th>
|
||||
<th>Сумма</th>
|
||||
<th>Ходы</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="team in snapshot.teams" :key="team.id">
|
||||
<td>{{ team.place }}</td>
|
||||
<td class="team-name">{{ team.name }}</td>
|
||||
<td v-for="question in snapshot.questions" :key="question.code">
|
||||
{{ team.answers.find((a) => a.questionCode === question.code)?.score ?? 0 }}
|
||||
</td>
|
||||
<td>{{ team.totalScore }}</td>
|
||||
<td>{{ team.actionsCount }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 90vh;
|
||||
max-width: 96vw;
|
||||
width: 96vw;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(28px, 4vw, 72px);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: clamp(24px, 3vw, 48px);
|
||||
}
|
||||
|
||||
.results-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
font-size: clamp(18px, 2.4vw, 48px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
border-bottom: 1px solid rgb(255 255 255 / 30%);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
th {
|
||||
background: rgb(0 0 0 / 70%);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.team-name {
|
||||
overflow-wrap: anywhere;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
import type { State, TimerState } from '../types/screen'
|
||||
import { formatTimer, remainingTime } from '../utils/presentation'
|
||||
|
||||
export function useScreenTimer(state: () => State | null) {
|
||||
const timer = shallowRef<TimerState | null>(null)
|
||||
const now = ref(performance.now())
|
||||
let syncedAt = now.value
|
||||
watch(
|
||||
[() => state()?.session, () => state()?.timer.revision],
|
||||
() => {
|
||||
syncedAt = performance.now()
|
||||
now.value = syncedAt
|
||||
timer.value = state()?.timer ?? null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
const tick = setInterval(() => {
|
||||
if (timer.value?.status === 'running') now.value = performance.now()
|
||||
}, 100)
|
||||
onUnmounted(() => clearInterval(tick))
|
||||
return computed(() =>
|
||||
formatTimer(timer.value ? remainingTime(timer.value, now.value - syncedAt) : 0),
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export type Intent =
|
||||
| { action: 'track'; channel: 'music'; trackIndex: number }
|
||||
| { action: 'next'; channel: 'music' }
|
||||
| { action: 'loop'; channel: 'music'; enabled: boolean }
|
||||
| { action: 'mode'; mode: ScreenMode }
|
||||
| { action: 'step'; index: number }
|
||||
| { action: 'text'; text: string }
|
||||
| { action: 'timer-set'; label: string; durationSeconds: number }
|
||||
| { action: 'timer-start' | 'timer-pause' | 'timer-resume' | 'timer-reset' }
|
||||
export type Command = Intent & { session: string; sequence: number }
|
||||
export interface MediaSource {
|
||||
id: string
|
||||
@@ -23,11 +28,60 @@ export interface Playlist {
|
||||
name: string
|
||||
tracks: { id: number; name: string; source: MediaSource }[]
|
||||
}
|
||||
export type ScreenMode = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
|
||||
export interface ScreenStep {
|
||||
mode: ScreenMode
|
||||
text: string
|
||||
timerLabel: string
|
||||
timerDurationSeconds: number
|
||||
}
|
||||
export interface SceneState {
|
||||
revision: number
|
||||
mode: ScreenMode
|
||||
stepIndex: number | null
|
||||
text: string
|
||||
status: 'idle' | 'pending' | 'ready' | 'error'
|
||||
error: string
|
||||
}
|
||||
export interface TimerState {
|
||||
revision: number
|
||||
label: string
|
||||
durationMs: number
|
||||
remainingMs: number
|
||||
status: 'stopped' | 'running' | 'paused' | 'finished'
|
||||
}
|
||||
export interface SceneReport {
|
||||
session: string
|
||||
revision: number
|
||||
status: 'ready' | 'error'
|
||||
error?: string
|
||||
}
|
||||
export interface PreviewFrame {
|
||||
session: string
|
||||
revision: number
|
||||
dataURL: string
|
||||
}
|
||||
export interface ResultsSnapshot {
|
||||
packageKey: string
|
||||
capturedAt: string
|
||||
questions: { code: string; text: string }[]
|
||||
teams: {
|
||||
id: string
|
||||
name: string
|
||||
place: number
|
||||
totalScore: number
|
||||
actionsCount: number
|
||||
answers: { questionCode: string; score: number }[]
|
||||
}[]
|
||||
}
|
||||
export interface Catalog {
|
||||
key: string | null
|
||||
name: string
|
||||
media: MediaSource[]
|
||||
playlists: Playlist[]
|
||||
cover: string | null
|
||||
background: string | null
|
||||
steps: ScreenStep[]
|
||||
}
|
||||
export interface ChannelState {
|
||||
operation: number
|
||||
@@ -50,6 +104,9 @@ export interface State {
|
||||
outputOpen: boolean
|
||||
catalog: Catalog
|
||||
muted: boolean
|
||||
scene: SceneState
|
||||
timer: TimerState
|
||||
results: ResultsSnapshot | null
|
||||
channels: Record<Channel, ChannelState>
|
||||
samples: { channel: Channel; media: MediaKind; milliseconds: number }[]
|
||||
}
|
||||
@@ -77,6 +134,8 @@ export interface PackageSummary {
|
||||
export interface ScreenBridge {
|
||||
command: (command: Command) => Promise<void>
|
||||
report: (report: Report) => void
|
||||
reportScene: (report: SceneReport) => void
|
||||
preview: () => Promise<PreviewFrame | null>
|
||||
state: () => Promise<State>
|
||||
subscribe: (listener: (state: State) => void) => () => void
|
||||
host: () => Promise<HostInfo>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Channel, ChannelState, Report, State } from '../types/screen.js'
|
||||
|
||||
function waitUntil(
|
||||
export function waitUntil(
|
||||
media: HTMLMediaElement,
|
||||
event: string,
|
||||
ready: () => boolean,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ScreenMode, TimerState } from '../types/screen.js'
|
||||
|
||||
export const modeLabels: Record<ScreenMode, string> = {
|
||||
1: 'Обложка',
|
||||
2: 'Вступление',
|
||||
3: 'Завершение вступления',
|
||||
4: 'Заключительное видео',
|
||||
5: 'Завершение показа',
|
||||
6: 'Результаты',
|
||||
7: 'Таймер',
|
||||
8: 'Текст',
|
||||
}
|
||||
export function remainingTime(timer: TimerState, elapsedMs: number): number {
|
||||
return Math.max(0, timer.remainingMs - (timer.status === 'running' ? Math.max(0, elapsedMs) : 0))
|
||||
}
|
||||
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')}`
|
||||
}
|
||||
Reference in New Issue
Block a user