This commit is contained in:
2026-10-19 02:16:57 +07:00
parent bd8f7e49c0
commit 06af1e047d
11 changed files with 405 additions and 238 deletions
+11 -5
View File
@@ -57,6 +57,12 @@ const host = ref<HostInfo>({
preferredDisplayId: null,
})
const display = ref<number | null>(null)
const previewViewport = computed(() => {
const selected = host.value.displays.find(item => item.id === display.value)
return selected && selected.width > 0 && selected.height > 0
? { width: selected.width, height: selected.height }
: { width: 1920, height: 1080 }
})
const isBusy = ref(false)
const isCheckingSound = ref(false)
async function checkSound() {
@@ -283,9 +289,9 @@ onUnmounted(() => {
<n-tag
role="status"
data-testid="display-connection-status"
:type="host.displays.length ? 'success' : 'error'"
:type="state?.outputOpen ? 'success' : 'default'"
>
{{ host.displays.length ? 'Подключён' : 'Не подключён' }}
{{ state?.outputOpen ? 'Показ открыт' : 'Показ закрыт' }}
</n-tag>
<n-button
v-if="!state?.outputOpen"
@@ -400,9 +406,9 @@ onUnmounted(() => {
</template>
<n-space vertical>
<ScreenPlanPanel
:game-name="state.catalog.name"
:cover="state.catalog.cover"
:background="state.catalog.background"
:catalog="state.catalog"
:results="state.results"
:viewport="previewViewport"
:steps="state.catalog.steps"
:current-index="state.scene.stepIndex"
:draft="planDraft"
+18 -140
View File
@@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue
import type { State } from '../types/screen'
import { MediaPlayer, waitUntil } from '../utils/media-player'
import { useScreenTimer } from '../composables/useScreenTimer'
import ResultsTable from './ResultsTable.vue'
import SceneView from './SceneView.vue'
import { formatTimer } from '../utils/presentation'
const audio = ref<HTMLAudioElement | null>(null)
@@ -131,12 +131,6 @@ let unsubscribe: (() => void) | undefined
let revision = -1
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) => {
@@ -240,60 +234,23 @@ onUnmounted(() => {
<template>
<main ref="surface" class="output" :data-mode="state?.scene.mode">
<img
v-if="state?.catalog.background"
:key="`${state.session}:${state.scene.revision}:background`"
class="background"
:class="{ failed: imageError }"
:src="imageURL(state.catalog.background)"
alt=""
<SceneView
v-if="state"
:catalog="state.catalog"
:mode="state.scene.mode"
:text="state.scene.text"
:timer-label="state.timer.label"
:timer-display="timerDisplay"
:results="state.results"
:revision="state.scene.revision"
:image-version="`${state.session}:${state.scene.revision}`"
:image-error="imageError"
:has-main-source="!!state.channels.main.source"
:intro-bars="introBars"
:intro-progress="introDuration === null ? null : introProgress"
:intro-time="introTime"
animated-results
/>
<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>
<section v-else-if="state.scene.mode === 2" class="intro-recording" aria-label="Аудиозапись введения">
<div
class="intro-waveform"
role="progressbar"
aria-label="Прогресс введения"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="introDuration === null ? undefined : Math.round(introProgress * 100)"
:aria-valuetext="introTime"
>
<svg viewBox="0 0 768 80" preserveAspectRatio="none" aria-hidden="true">
<rect v-for="(height, index) in introBars" :key="index" :x="index * 6 + 2" :y="(80 - height) / 2" width="2" :height="height" rx="1" fill="currentColor" />
</svg>
</div>
</section>
<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"
@@ -328,90 +285,11 @@ onUnmounted(() => {
width: 100vw;
}
.scene {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
position: relative;
text-align: center;
width: 100%;
}
.background,
.cover,
video {
height: 100%;
inset: 0;
object-fit: contain;
position: absolute;
width: 100%;
}
.background {
filter: brightness(45%);
object-fit: cover;
}
.cover,
video {
object-fit: contain;
}
.failed {
visibility: hidden;
}
.message,
.timer,
.intro-recording {
text-align: center;
}
.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;
}
.intro-recording {
max-width: 1100px;
width: 82vw;
}
.intro-waveform {
height: clamp(48px, 14vh, 160px);
}
.intro-waveform svg {
display: block;
height: 100%;
width: 100%;
}
.timer {
align-items: center;
display: flex;
flex-direction: column;
gap: clamp(16px, 3vh, 32px);
max-width: 92vw;
overflow-wrap: anywhere;
}
.timer h1 {
font-size: clamp(28px, 5vw, 80px);
margin: 0;
}
.timer strong {
font-size: clamp(80px, 20vw, 300px);
font-variant-numeric: tabular-nums;
line-height: 1;
}
</style>
+8 -8
View File
@@ -2,7 +2,7 @@
import { nextTick, onUnmounted, ref, watch } from 'vue'
import type { ResultsSnapshot } from '../types/screen'
const props = defineProps<{ snapshot: ResultsSnapshot | null; revision: number }>()
const props = defineProps<{ snapshot: ResultsSnapshot | null; revision: number; animated?: boolean }>()
const container = ref<HTMLElement | null>(null)
let frame = 0
let generation = 0
@@ -12,7 +12,7 @@ watch(
const current = ++generation
cancelAnimationFrame(frame)
await nextTick()
if (current !== generation || !container.value || !props.snapshot) return
if (!props.animated || current !== generation || !container.value || !props.snapshot) return
const element = container.value
const distance = Math.max(0, element.scrollHeight - element.clientHeight)
const start = performance.now()
@@ -70,18 +70,18 @@ onUnmounted(() => {
.results {
display: flex;
flex-direction: column;
height: 90vh;
max-width: 96vw;
width: 96vw;
height: calc(var(--scene-vh, 1vh) * 90);
max-width: calc(var(--scene-vw, 1vw) * 96);
width: calc(var(--scene-vw, 1vw) * 96);
}
h1 {
font-size: clamp(28px, 4vw, 72px);
font-size: clamp(28px, calc(var(--scene-vw, 1vw) * 4), 72px);
margin: 0 0 24px;
}
.empty {
font-size: clamp(24px, 3vw, 48px);
font-size: clamp(24px, calc(var(--scene-vw, 1vw) * 3), 48px);
}
.results-scroll {
@@ -90,7 +90,7 @@ h1 {
table {
border-collapse: collapse;
font-size: clamp(18px, 2.4vw, 48px);
font-size: clamp(18px, calc(var(--scene-vw, 1vw) * 2.4), 48px);
width: 100%;
}
+203
View File
@@ -0,0 +1,203 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
import type { Catalog, ResultsSnapshot, ScreenMode } from '../types/screen'
import ResultsTable from './ResultsTable.vue'
const props = withDefaults(defineProps<{
catalog: Catalog
mode: ScreenMode
text: string
timerLabel: string
timerDisplay: string
results: ResultsSnapshot | null
revision?: number
imageVersion?: string
imageError?: boolean
hasMainSource: boolean
introBars?: number[]
introProgress?: number | null
introTime?: string
animatedResults?: boolean
viewport?: { width: number; height: number }
}>(), {
revision: 0,
imageVersion: '',
imageError: false,
introBars: () => Array(128).fill(2),
introProgress: null,
introTime: '',
animatedResults: false,
})
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) {
if (!version) return url
const result = new URL(url)
result.searchParams.set('scene', version)
return result.href
}
</script>
<template>
<div class="scene-view" :data-mode="mode" :style="viewport ? { '--scene-vw': `${viewport.width / 100}px`, '--scene-vh': `${viewport.height / 100}px` } : undefined">
<img
v-if="catalog.background"
:key="`${imageVersion}:background`"
class="background"
:class="{ failed: imageError || failedImages.has(catalog.background!) }"
@error="failedImages.add(catalog.background!)"
:src="imageURL(catalog.background, imageVersion)"
alt=""
/>
<div class="scene">
<template v-if="mode === 1">
<img
v-if="catalog.cover"
:key="`${imageVersion}:cover`"
class="cover"
:class="{ failed: imageError || failedImages.has(catalog.cover!) }"
@error="failedImages.add(catalog.cover!)"
:src="imageURL(catalog.cover, imageVersion)"
alt="Обложка дела"
/>
<h1 v-else class="message">{{ catalog.name }}</h1>
</template>
<p v-else-if="mode === 2 && !hasMainSource" class="message">
Вступление не задано
</p>
<section v-else-if="mode === 2" class="intro-recording" aria-label="Аудиозапись введения">
<div
class="intro-waveform"
role="progressbar"
aria-label="Прогресс введения"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="introProgress === null ? undefined : Math.round(introProgress * 100)"
:aria-valuetext="introTime"
>
<svg viewBox="0 0 768 80" preserveAspectRatio="none" aria-hidden="true">
<rect v-for="(height, index) in introBars" :key="index" :x="index * 6 + 2" :y="(80 - height) / 2" width="2" :height="height" rx="1" fill="currentColor" />
</svg>
</div>
</section>
<p v-else-if="mode === 3" class="message">Успехов</p>
<p v-else-if="mode === 4 && !hasMainSource" class="message">
Видео закрывашки не задано
</p>
<p v-else-if="mode === 5" class="message">Спасибо за игру!</p>
<ResultsTable
v-else-if="mode === 6"
:snapshot="results"
:revision="revision"
:animated="animatedResults"
/>
<section v-else-if="mode === 7" class="timer">
<h1>{{ timerLabel }}</h1>
<strong>{{ timerDisplay }}</strong>
</section>
<p v-else-if="mode === 8" class="message text">{{ text }}</p>
</div>
<slot />
</div>
</template>
<style scoped>
.scene-view {
--scene-vh: 1vh;
--scene-vw: 1vw;
background: var(--brand-background);
color: var(--brand-text);
font-size: 14px;
height: 100%;
line-height: 1.6;
overflow: hidden;
position: relative;
width: 100%;
}
.scene {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
position: relative;
text-align: center;
width: 100%;
}
.background,
.cover {
height: 100%;
inset: 0;
position: absolute;
width: 100%;
}
.background {
filter: brightness(45%);
object-fit: cover;
}
.cover {
object-fit: contain;
}
.failed {
visibility: hidden;
}
.message,
.timer,
.intro-recording {
text-align: center;
}
.message {
font-size: clamp(32px, calc(var(--scene-vw) * 7), 140px);
margin: 0;
max-height: calc(var(--scene-vh) * 90);
max-width: calc(var(--scene-vw) * 92);
overflow: hidden;
overflow-wrap: anywhere;
}
.text {
white-space: pre-wrap;
}
.intro-recording {
max-width: 1100px;
width: calc(var(--scene-vw) * 82);
}
.intro-waveform {
height: clamp(48px, calc(var(--scene-vh) * 14), 160px);
}
.intro-waveform svg {
display: block;
height: 100%;
width: 100%;
}
.timer {
align-items: center;
display: flex;
flex-direction: column;
gap: clamp(16px, calc(var(--scene-vh) * 3), 32px);
max-width: calc(var(--scene-vw) * 92);
overflow-wrap: anywhere;
}
.timer h1 {
font-size: clamp(28px, calc(var(--scene-vw) * 5), 80px);
margin: 0;
}
.timer strong {
font-size: clamp(80px, calc(var(--scene-vw) * 20), 300px);
font-variant-numeric: tabular-nums;
line-height: 1;
}
</style>
+10 -10
View File
@@ -2,14 +2,14 @@
import { NAlert, NButton, NInput, NInputNumber, NSelect, NSpace } from 'naive-ui'
import { computed } from 'vue'
import { playbackControl } from '../utils/playbackControl'
import type { ChannelState, Intent, ScreenMode, ScreenStep, TimerState } from '../types/screen'
import type { Catalog, ChannelState, Intent, ResultsSnapshot, ScreenMode, ScreenStep, TimerState } from '../types/screen'
import { modeLabels } from '../utils/presentation'
import ScreenStepPreview from './ScreenStepPreview.vue'
const props = defineProps<{
gameName: string
cover: string | null
background: string | null
catalog: Catalog
results: ResultsSnapshot | null
viewport: { width: number; height: number }
steps: ScreenStep[]
currentIndex: number | null
draft: ScreenStep[]
@@ -54,9 +54,9 @@ function timerLabel(index: number) {
<div class="plan-step-preview">
<ScreenStepPreview
:step="step"
:game-name="gameName"
:cover="cover"
:background="background"
:catalog="catalog"
:results="results"
:viewport="viewport"
/>
</div>
<div class="plan-edit-controls">
@@ -131,9 +131,9 @@ function timerLabel(index: number) {
<div class="plan-step-preview">
<ScreenStepPreview
:step="step"
:game-name="gameName"
:cover="cover"
:background="background"
:catalog="catalog"
:results="results"
:viewport="viewport"
/>
</div>
<div class="plan-step-content">
+43 -69
View File
@@ -1,92 +1,66 @@
<script setup lang="ts">
import { reactive } from 'vue'
import type { ScreenStep } from '../types/screen'
import { formatTimer, modeLabels } from '../utils/presentation'
import { computed } from 'vue'
import type { Catalog, ResultsSnapshot, ScreenStep } from '../types/screen'
import { formatTimer } from '../utils/presentation'
import SceneView from './SceneView.vue'
const props = defineProps<{
step: ScreenStep
gameName: string
cover: string | null
background: string | null
catalog: Catalog
results: ResultsSnapshot | null
viewport: { width: number; height: number }
}>()
const failedImages = reactive(new Set<string>())
function previewText(step: ScreenStep) {
switch (step.mode) {
case 1:
return props.gameName
case 3:
return 'Успехов'
case 5:
return 'Спасибо за игру!'
case 7:
return `${step.timerLabel} ${formatTimer(step.timerDurationSeconds * 1000)}`
case 8:
return step.text
default:
return modeLabels[step.mode]
}
}
const source = computed(() => props.catalog.media.find(media =>
media.kind === (props.step.mode === 2 ? 'audio' : 'video'),
))
</script>
<template>
<div class="step-preview" aria-hidden="true">
<img
v-if="background && !failedImages.has(background)"
:src="background"
class="step-preview-background"
alt=""
loading="lazy"
@error="failedImages.add(background!)"
/>
<img
v-if="step.mode === 1 && cover && !failedImages.has(cover)"
:src="cover"
class="step-preview-cover"
alt=""
loading="lazy"
@error="failedImages.add(cover!)"
/>
<span v-if="step.mode !== 1 || !cover || failedImages.has(cover)">{{ previewText(step) }}</span>
<div class="step-preview" aria-hidden="true" :style="{ aspectRatio: `${viewport.width} / ${viewport.height}` }">
<div class="preview-viewport" :style="{ width: `${viewport.width}px`, height: `${viewport.height}px`, transform: `scale(${160 / viewport.width})` }">
<SceneView
:catalog="catalog"
:mode="step.mode"
:text="step.text"
:timer-label="step.timerLabel"
:timer-display="formatTimer(step.timerDurationSeconds * 1000)"
:results="results"
:has-main-source="!!source"
:viewport="viewport"
>
<video
v-if="step.mode === 4 && source"
:key="source.url"
class="scene-video"
:src="source.url"
preload="metadata"
muted
playsinline
disablepictureinpicture
tabindex="-1"
/>
</SceneView>
</div>
</div>
</template>
<style scoped>
.step-preview {
align-items: center;
aspect-ratio: 16 / 9;
background: var(--brand-background);
display: flex;
font-size: 10px;
justify-content: center;
overflow: hidden;
position: relative;
text-align: center;
width: 160px;
}
.step-preview img {
.preview-viewport {
pointer-events: none;
transform-origin: top left;
}
.scene-video {
height: 100%;
object-fit: cover;
inset: 0;
object-fit: contain;
position: absolute;
width: 100%;
}
.step-preview img.step-preview-cover {
object-fit: contain;
}
.step-preview-background {
filter: brightness(45%);
}
.step-preview span {
-webkit-box-orient: vertical;
display: -webkit-box;
-webkit-line-clamp: 3;
overflow: hidden;
padding: 4px;
position: relative;
white-space: pre-wrap;
}
</style>
+2
View File
@@ -124,6 +124,8 @@ export interface Report {
export interface DisplayInfo {
id: number
label: string
width: number
height: number
}
export interface AudioOutputInfo {
id: string