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