This commit is contained in:
2026-10-17 19:52:24 +07:00
parent a386f3d9e5
commit f5dc9c1e5b
17 changed files with 1652 additions and 59 deletions
+261 -2
View File
@@ -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
View File
@@ -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>
+113
View File
@@ -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>