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
+2
View File
@@ -90,6 +90,8 @@ function host(): HostInfo {
.filter((d) => d.id !== primary) .filter((d) => d.id !== primary)
.map((d) => ({ .map((d) => ({
id: d.id, id: d.id,
width: d.size.width,
height: d.size.height,
label: `${d.label || 'Дисплей'} — ${d.size.width}×${d.size.height}`, label: `${d.label || 'Дисплей'} — ${d.size.width}×${d.size.height}`,
})), })),
} }
+6 -5
View File
@@ -292,13 +292,13 @@ module.exports = async function onlineSmoke({
await poll( await poll(
() => () =>
evaluate( evaluate(
`document.querySelectorAll('main.control > .n-card').length === 5 && document.querySelectorAll('[data-testid="game-control"] .n-card').length === 0 && document.querySelectorAll('.n-card-header__main').length === 6`, `document.querySelectorAll('main.control > .n-card').length === 4 && document.querySelectorAll('[data-testid="game-control"] .n-card').length === 0 && document.querySelectorAll('.n-card-header__main').length === 6`,
), ),
'six control cards with merged game controls', 'six control cards with merged game controls',
) )
assert.deepEqual( assert.deepEqual(
await evaluate( await evaluate(
`Array.from(document.querySelectorAll('.n-card-header__main')).map(e => (e.querySelector('.server-heading > span:first-child') ?? e).textContent.trim())`, `Array.from(document.querySelectorAll('.n-card-header__main')).map(e => (e.querySelector('.server-heading > span:first-child, .game-heading > span:first-child') ?? e).textContent.trim())`,
), ),
['Сервер', 'Материалы', 'Игра', 'Экран', 'План экранов', 'План музыки'], ['Сервер', 'Материалы', 'Игра', 'Экран', 'План экранов', 'План музыки'],
) )
@@ -461,9 +461,9 @@ module.exports = async function onlineSmoke({
return !content?.querySelector('.game-selection') return !content?.querySelector('.game-selection')
&& !buttons.some(b => b.textContent.trim() === 'Подключиться') && !buttons.some(b => b.textContent.trim() === 'Подключиться')
&& buttons.some(b => b.textContent.trim() === 'Отключиться' && buttons.some(b => b.textContent.trim() === 'Отключиться'
&& b.closest('.n-space')?.textContent.includes('Экран №')) && !!b.closest('.n-card-header__extra'))
})()`), })()`),
'selected game hides selector and connect button, disconnect follows screen name', 'selected game hides selector and connect button, disconnect stays in game header',
) )
assert.equal((await state()).outputOpen, false) assert.equal((await state()).outputOpen, false)
await evaluate(`window.screenBridge.show(${displayId})`) await evaluate(`window.screenBridge.show(${displayId})`)
@@ -475,7 +475,7 @@ module.exports = async function onlineSmoke({
await evaluate(`(() => { await evaluate(`(() => {
const assignment = document.querySelector('[data-testid="sound-assignment"]') const assignment = document.querySelector('[data-testid="sound-assignment"]')
const card = assignment?.closest('.n-card') const card = assignment?.closest('.n-card')
return card?.querySelector('.n-card-header__main')?.textContent.trim() === 'Игра' return card?.querySelector('.game-heading > span:first-child')?.textContent.trim() === 'Игра'
&& ['Назначить звук этому экрану', 'Отключиться'].every(label => && ['Назначить звук этому экрану', 'Отключиться'].every(label =>
!Array.from(document.querySelectorAll('.n-card')).find(c => c.querySelector('.n-card-header__main')?.textContent.trim() === 'Экран')?.textContent.includes(label)) !Array.from(document.querySelectorAll('.n-card')).find(c => c.querySelector('.n-card-header__main')?.textContent.trim() === 'Экран')?.textContent.includes(label))
})()`), })()`),
@@ -649,6 +649,7 @@ module.exports = async function onlineSmoke({
await clickPlan('Редактировать') await clickPlan('Редактировать')
await clickPlan('Добавить шаг') await clickPlan('Добавить шаг')
await setInput('[data-testid="screen-plan"] textarea', 'Текст из редактора') await setInput('[data-testid="screen-plan"] textarea', 'Текст из редактора')
await poll(() => evaluate(`Array.from(document.querySelectorAll('[data-testid="screen-plan"] .step-preview .message')).some(e => e.textContent === 'Текст из редактора')`), 'draft miniature updates before save')
await clickPlan('Добавить шаг') await clickPlan('Добавить шаг')
await evaluate( await evaluate(
`Array.from(document.querySelectorAll('[data-testid="screen-plan"] .n-base-selection')).at(-1).click()`, `Array.from(document.querySelectorAll('[data-testid="screen-plan"] .n-base-selection')).at(-1).click()`,
+96
View File
@@ -0,0 +1,96 @@
const assert = require('node:assert/strict')
const { writeFileSync } = require('node:fs')
const { nativeImage, BrowserWindow } = require('electron')
module.exports = async function previewsSmoke({ control, output, evaluate, inspect, state, command, poll, targetDisplay }) {
const miniature = (index) => `[data-testid="screen-plan"] [data-step-index="${index}"] .step-preview`
const metrics = (root, videoSelector) => `(() => {
const root = document.querySelector(${JSON.stringify(root)});
if (!root) return null;
const bounds = root.getBoundingClientRect();
return ['.background', '.cover', '.message', '.intro-waveform', '.timer h1', '.timer strong', '.results', '.results h1', '.results .empty', '.results table', ${JSON.stringify(videoSelector)}].flatMap(selector => {
const e = root.querySelector(selector); if (!e) return [];
const r = e.getBoundingClientRect(); const s = getComputedStyle(e); if (s.display === 'none') return [];
return [{ selector: selector.includes('video') ? 'video' : selector, text: e.textContent.trim(), x: (r.x-bounds.x)/bounds.width, y: (r.y-bounds.y)/bounds.height, width: r.width/bounds.width, height: r.height/bounds.height, font: s.fontSize, fit: s.objectFit, filter: s.filter }];
});
})()`
const compare = async (index) => {
const actual = await inspect(metrics('.output', 'video'))
const thumbnail = await evaluate(metrics(miniature(index), 'video'))
// Hidden main video remains mounted in the output to preserve playback state.
const expected = actual.filter(item => item.selector !== 'video' || index === 3)
assert.equal(thumbnail.length, expected.length, `step ${index + 1}: ${JSON.stringify({ expected, thumbnail })}`)
for (let i = 0; i < expected.length; i++) {
const a = expected[i], b = thumbnail[i]
for (const key of ['selector', 'text', 'font', 'fit', 'filter']) {
if (a.selector === 'video' && key === 'font') continue
assert.equal(b[key], a[key], `step ${index + 1}: ${key}`)
}
for (const key of ['x', 'y', 'width', 'height'])
assert.ok(Math.abs(a[key] - b[key]) < 0.002, `step ${index + 1}: ${key}: ${a[key]} / ${b[key]}`)
}
}
for (const [width, height] of [[1280, 720], [1024, 768]]) {
targetDisplay.size = { width, height }
output.webContents.enableDeviceEmulation({ screenPosition: 'desktop', screenSize: { width, height }, viewSize: { width, height }, deviceScaleFactor: 1, scale: 1 })
await poll(() => inspect(`innerWidth === ${width} && innerHeight === ${height}`), 'output viewport')
await poll(() => evaluate(`(() => { const r = document.querySelector('${miniature(0)}').getBoundingClientRect(); return Math.abs(r.width/r.height - ${width/height}) < 0.001 && document.querySelector('${miniature(0)} .preview-viewport').style.width === '${width}px' })()`), 'thumbnail viewport follows display')
for (let index = 0; index < 8; index++) {
await command({ action: 'step', index })
await poll(async () => {
const s = await state()
if (s.scene.status === 'error') throw new Error(s.scene.error)
return s.scene.stepIndex === index && s.scene.status === 'ready'
}, `scene ${index + 1}`)
await poll(() => evaluate(`Array.from(document.querySelectorAll('${miniature(index)} img')).every(i => i.complete && i.naturalWidth > 0)`), 'thumbnail images loaded')
if (index === 3) {
await poll(() => evaluate(`document.querySelector('${miniature(index)} video').readyState >= 2`), 'video thumbnail first frame')
assert.ok(await evaluate(`(() => { const v = document.querySelector('${miniature(index)} video'); return v.paused && v.muted && v.currentTime === 0 })()`))
}
await compare(index)
assert.ok(await evaluate('Array.from(document.querySelectorAll(".step-preview video")).every(v => v.paused && v.muted)'))
assert.equal((await state()).channels.main.desired, 'stopped')
const staticScroll = await evaluate(`document.querySelector('${miniature(5)} .results-scroll')?.scrollTop ?? 0`)
assert.equal(staticScroll, 0)
}
await command({ action: 'step', index: 6 })
await evaluate(`document.querySelector('${miniature(6)}').scrollIntoView({ block: 'center' })`)
writeFileSync(`/tmp/screen-previews-${width}.png`, (await control.webContents.capturePage()).toPNG())
writeFileSync(`/tmp/screen-output-${width}.png`, (await output.webContents.capturePage()).toPNG())
}
await evaluate('window.screenBridge.closeOutput()')
await poll(async () => !(await state()).outputOpen, 'output closed')
assert.equal(await evaluate('document.querySelectorAll(".step-preview").length'), 8)
assert.ok(await evaluate('Array.from(document.querySelectorAll(".step-preview video")).every(v => v.paused && v.muted)'))
await evaluate(`window.screenBridge.show(${targetDisplay.id})`)
output = await poll(() => BrowserWindow.getAllWindows().find(w => w.webContents.getURL() === 'screen-app://app/index.html?output=1'), 'reopen after static preview')
output.webContents.enableDeviceEmulation({ screenPosition: 'desktop', screenSize: { width: 1024, height: 768 }, viewSize: { width: 1024, height: 768 }, deviceScaleFactor: 1, scale: 1 })
await poll(async () => (await state()).scene.status === 'ready', 'reopened scene')
// Exercise missing media, long text, portrait cover and different result snapshots.
const original = await state()
let revision = original.revision + 1000
const portrait = nativeImage.createFromBitmap(Buffer.alloc(80 * 160 * 4, 180), { width: 80, height: 160 }).toDataURL()
const cases = [
{ index: 0, catalog: { ...original.catalog, cover: portrait } },
{ index: 7, catalog: { ...original.catalog, steps: original.catalog.steps.map((step, i) => i === 7 ? { ...step, text: 'Длинный многострочный текст\n' + 'Расследование продолжается. '.repeat(12) } : step) } },
{ index: 1, catalog: { ...original.catalog, media: [] } },
{ index: 3, catalog: { ...original.catalog, media: [] } },
{ index: 0, catalog: { ...original.catalog, cover: null } },
{ index: 5, results: { packageKey: original.catalog.key, capturedAt: new Date().toISOString(), questions: [], teams: [] } },
{ index: 5, results: { packageKey: original.catalog.key, capturedAt: new Date().toISOString(), questions: [{ code: 'A', text: 'Вопрос' }], teams: [{ id: 'one', name: 'Детективы', place: 1, totalScore: 5, actionsCount: 1, answers: [{ questionCode: 'A', score: 5 }] }] } },
]
for (const item of cases) {
const catalog = item.catalog ?? original.catalog
const step = catalog.steps[item.index]
const snapshot = { ...original, revision: ++revision, catalog, results: item.results ?? original.results,
scene: { ...original.scene, revision, mode: step.mode, text: step.text, stepIndex: item.index },
channels: { ...original.channels, main: { ...original.channels.main, source: catalog.media.find(m => m.kind === (step.mode === 2 ? 'audio' : 'video')) ?? null } } }
control.webContents.send('state', snapshot)
output.webContents.send('state', snapshot)
await poll(() => inspect(`document.querySelector('.output').dataset.mode === '${step.mode}'`), 'fixture output')
await poll(() => evaluate(`document.querySelector('${miniature(item.index)} .scene-view').dataset.mode === '${step.mode}'`), 'fixture thumbnail')
await new Promise(resolve => setTimeout(resolve, 50))
await compare(item.index)
}
console.log('SCREEN_PREVIEWS_PASS')
}
+6 -1
View File
@@ -317,7 +317,7 @@ app.whenReady().then(async () => {
} }
assert.equal(await evaluate('typeof window.require'), 'undefined') assert.equal(await evaluate('typeof window.require'), 'undefined')
assert.equal((await evaluate('window.screenBridge.packages()')).length, 1) assert.equal((await evaluate('window.screenBridge.packages()')).length, 1)
if (process.argv.includes('--intro-waveform')) if (process.argv.includes('--intro-waveform') || process.argv.includes('--previews'))
await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`) await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`)
await evaluate(`window.screenBridge.show(${targetDisplay.id})`) await evaluate(`window.screenBridge.show(${targetDisplay.id})`)
let output = await poll( let output = await poll(
@@ -328,6 +328,11 @@ app.whenReady().then(async () => {
BrowserWindow.getAllWindows() BrowserWindow.getAllWindows()
.find((w) => w.webContents.getURL() === 'screen-app://app/index.html?output=1') .find((w) => w.webContents.getURL() === 'screen-app://app/index.html?output=1')
.webContents.executeJavaScript(source) .webContents.executeJavaScript(source)
if (process.argv.includes('--previews')) {
await require('./previews-smoke.cjs')({ control, output, evaluate, inspect, state, command, poll, targetDisplay })
app.exit(0)
return
}
if (process.argv.includes('--intro-waveform')) { if (process.argv.includes('--intro-waveform')) {
const selectedDevice = (await evaluate('window.screenBridge.host()')).audioOutputs const selectedDevice = (await evaluate('window.screenBridge.host()')).audioOutputs
.find(device => device.id && !['default', 'communications'].includes(device.id)) .find(device => device.id && !['default', 'communications'].includes(device.id))
+11 -5
View File
@@ -57,6 +57,12 @@ const host = ref<HostInfo>({
preferredDisplayId: null, preferredDisplayId: null,
}) })
const display = ref<number | null>(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 isBusy = ref(false)
const isCheckingSound = ref(false) const isCheckingSound = ref(false)
async function checkSound() { async function checkSound() {
@@ -283,9 +289,9 @@ onUnmounted(() => {
<n-tag <n-tag
role="status" role="status"
data-testid="display-connection-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-tag>
<n-button <n-button
v-if="!state?.outputOpen" v-if="!state?.outputOpen"
@@ -400,9 +406,9 @@ onUnmounted(() => {
</template> </template>
<n-space vertical> <n-space vertical>
<ScreenPlanPanel <ScreenPlanPanel
:game-name="state.catalog.name" :catalog="state.catalog"
:cover="state.catalog.cover" :results="state.results"
:background="state.catalog.background" :viewport="previewViewport"
:steps="state.catalog.steps" :steps="state.catalog.steps"
:current-index="state.scene.stepIndex" :current-index="state.scene.stepIndex"
:draft="planDraft" :draft="planDraft"
+17 -139
View File
@@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue
import type { State } from '../types/screen' import type { State } from '../types/screen'
import { MediaPlayer, waitUntil } from '../utils/media-player' import { MediaPlayer, waitUntil } from '../utils/media-player'
import { useScreenTimer } from '../composables/useScreenTimer' import { useScreenTimer } from '../composables/useScreenTimer'
import ResultsTable from './ResultsTable.vue' import SceneView from './SceneView.vue'
import { formatTimer } from '../utils/presentation' import { formatTimer } from '../utils/presentation'
const audio = ref<HTMLAudioElement | null>(null) const audio = ref<HTMLAudioElement | null>(null)
@@ -131,12 +131,6 @@ let unsubscribe: (() => void) | undefined
let revision = -1 let revision = -1
let sceneToken = '' let sceneToken = ''
let sceneAbort: AbortController | null = null 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) { function waitImage(image: HTMLImageElement, signal: AbortSignal) {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const finish = (error?: string) => { const finish = (error?: string) => {
@@ -240,60 +234,23 @@ onUnmounted(() => {
<template> <template>
<main ref="surface" class="output" :data-mode="state?.scene.mode"> <main ref="surface" class="output" :data-mode="state?.scene.mode">
<img <SceneView
v-if="state?.catalog.background" v-if="state"
:key="`${state.session}:${state.scene.revision}:background`" :catalog="state.catalog"
class="background" :mode="state.scene.mode"
:class="{ failed: imageError }" :text="state.scene.text"
:src="imageURL(state.catalog.background)" :timer-label="state.timer.label"
alt="" :timer-display="timerDisplay"
/> :results="state.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" :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
/> />
<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 <audio
ref="audio" ref="audio"
data-channel="main-audio" data-channel="main-audio"
@@ -328,90 +285,11 @@ onUnmounted(() => {
width: 100vw; width: 100vw;
} }
.scene {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
position: relative;
text-align: center;
width: 100%;
}
.background,
.cover,
video { video {
height: 100%; height: 100%;
inset: 0; inset: 0;
object-fit: contain;
position: absolute; position: absolute;
width: 100%; 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> </style>
+8 -8
View File
@@ -2,7 +2,7 @@
import { nextTick, onUnmounted, ref, watch } from 'vue' import { nextTick, onUnmounted, ref, watch } from 'vue'
import type { ResultsSnapshot } from '../types/screen' 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) const container = ref<HTMLElement | null>(null)
let frame = 0 let frame = 0
let generation = 0 let generation = 0
@@ -12,7 +12,7 @@ watch(
const current = ++generation const current = ++generation
cancelAnimationFrame(frame) cancelAnimationFrame(frame)
await nextTick() 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 element = container.value
const distance = Math.max(0, element.scrollHeight - element.clientHeight) const distance = Math.max(0, element.scrollHeight - element.clientHeight)
const start = performance.now() const start = performance.now()
@@ -70,18 +70,18 @@ onUnmounted(() => {
.results { .results {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 90vh; height: calc(var(--scene-vh, 1vh) * 90);
max-width: 96vw; max-width: calc(var(--scene-vw, 1vw) * 96);
width: 96vw; width: calc(var(--scene-vw, 1vw) * 96);
} }
h1 { h1 {
font-size: clamp(28px, 4vw, 72px); font-size: clamp(28px, calc(var(--scene-vw, 1vw) * 4), 72px);
margin: 0 0 24px; margin: 0 0 24px;
} }
.empty { .empty {
font-size: clamp(24px, 3vw, 48px); font-size: clamp(24px, calc(var(--scene-vw, 1vw) * 3), 48px);
} }
.results-scroll { .results-scroll {
@@ -90,7 +90,7 @@ h1 {
table { table {
border-collapse: collapse; border-collapse: collapse;
font-size: clamp(18px, 2.4vw, 48px); font-size: clamp(18px, calc(var(--scene-vw, 1vw) * 2.4), 48px);
width: 100%; 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 { NAlert, NButton, NInput, NInputNumber, NSelect, NSpace } from 'naive-ui'
import { computed } from 'vue' import { computed } from 'vue'
import { playbackControl } from '../utils/playbackControl' 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 { modeLabels } from '../utils/presentation'
import ScreenStepPreview from './ScreenStepPreview.vue' import ScreenStepPreview from './ScreenStepPreview.vue'
const props = defineProps<{ const props = defineProps<{
gameName: string catalog: Catalog
cover: string | null results: ResultsSnapshot | null
background: string | null viewport: { width: number; height: number }
steps: ScreenStep[] steps: ScreenStep[]
currentIndex: number | null currentIndex: number | null
draft: ScreenStep[] draft: ScreenStep[]
@@ -54,9 +54,9 @@ function timerLabel(index: number) {
<div class="plan-step-preview"> <div class="plan-step-preview">
<ScreenStepPreview <ScreenStepPreview
:step="step" :step="step"
:game-name="gameName" :catalog="catalog"
:cover="cover" :results="results"
:background="background" :viewport="viewport"
/> />
</div> </div>
<div class="plan-edit-controls"> <div class="plan-edit-controls">
@@ -131,9 +131,9 @@ function timerLabel(index: number) {
<div class="plan-step-preview"> <div class="plan-step-preview">
<ScreenStepPreview <ScreenStepPreview
:step="step" :step="step"
:game-name="gameName" :catalog="catalog"
:cover="cover" :results="results"
:background="background" :viewport="viewport"
/> />
</div> </div>
<div class="plan-step-content"> <div class="plan-step-content">
+42 -68
View File
@@ -1,92 +1,66 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive } from 'vue' import { computed } from 'vue'
import type { ScreenStep } from '../types/screen' import type { Catalog, ResultsSnapshot, ScreenStep } from '../types/screen'
import { formatTimer, modeLabels } from '../utils/presentation' import { formatTimer } from '../utils/presentation'
import SceneView from './SceneView.vue'
const props = defineProps<{ const props = defineProps<{
step: ScreenStep step: ScreenStep
gameName: string catalog: Catalog
cover: string | null results: ResultsSnapshot | null
background: string | null viewport: { width: number; height: number }
}>() }>()
const failedImages = reactive(new Set<string>()) const source = computed(() => props.catalog.media.find(media =>
function previewText(step: ScreenStep) { media.kind === (props.step.mode === 2 ? 'audio' : 'video'),
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]
}
}
</script> </script>
<template> <template>
<div class="step-preview" aria-hidden="true"> <div class="step-preview" aria-hidden="true" :style="{ aspectRatio: `${viewport.width} / ${viewport.height}` }">
<img <div class="preview-viewport" :style="{ width: `${viewport.width}px`, height: `${viewport.height}px`, transform: `scale(${160 / viewport.width})` }">
v-if="background && !failedImages.has(background)" <SceneView
:src="background" :catalog="catalog"
class="step-preview-background" :mode="step.mode"
alt="" :text="step.text"
loading="lazy" :timer-label="step.timerLabel"
@error="failedImages.add(background!)" :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"
/> />
<img </SceneView>
v-if="step.mode === 1 && cover && !failedImages.has(cover)" </div>
: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> </div>
</template> </template>
<style scoped> <style scoped>
.step-preview { .step-preview {
align-items: center;
aspect-ratio: 16 / 9;
background: var(--brand-background);
display: flex;
font-size: 10px;
justify-content: center;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
text-align: center;
width: 160px; width: 160px;
} }
.step-preview img { .preview-viewport {
pointer-events: none;
transform-origin: top left;
}
.scene-video {
height: 100%; height: 100%;
object-fit: cover; inset: 0;
object-fit: contain;
position: absolute; position: absolute;
width: 100%; 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> </style>
+2
View File
@@ -124,6 +124,8 @@ export interface Report {
export interface DisplayInfo { export interface DisplayInfo {
id: number id: number
label: string label: string
width: number
height: number
} }
export interface AudioOutputInfo { export interface AudioOutputInfo {
id: string id: string