This commit is contained in:
2026-10-18 23:40:03 +07:00
parent fc326f46c8
commit d3681d9df5
2 changed files with 136 additions and 2 deletions
+44
View File
@@ -260,6 +260,8 @@ app.whenReady().then(async () => {
}
assert.equal(await evaluate('typeof window.require'), 'undefined')
assert.equal((await evaluate('window.screenBridge.packages()')).length, 1)
if (process.argv.includes('--intro-waveform'))
await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`)
await evaluate(`window.screenBridge.show(${targetDisplay.id})`)
let output = await poll(
() => BrowserWindow.getAllWindows().find((w) => w.webContents.getURL() === 'screen-app://app/index.html?output=1'),
@@ -269,6 +271,48 @@ app.whenReady().then(async () => {
BrowserWindow.getAllWindows()
.find((w) => w.webContents.getURL() === 'screen-app://app/index.html?output=1')
.webContents.executeJavaScript(source)
if (process.argv.includes('--intro-waveform')) {
await command({ action: 'step', index: 1 })
await poll(() => inspect('document.querySelector(".intro-waveform")?.getAttribute("aria-valuenow") === "0"'), 'intro waveform starts empty')
assert.equal(await inspect('document.querySelectorAll(".intro-recording button,.intro-recording input").length'), 0)
await command({ action: 'play', channel: 'main' })
await command({ action: 'seek', channel: 'main', position: 15 })
await command({ action: 'pause', channel: 'main' })
await poll(() => inspect('Number(document.querySelector(".intro-waveform").getAttribute("aria-valuenow")) >= 24'), 'intro progress follows seek')
const pausedTime = await inspect('document.querySelector(".intro-time").textContent')
await delay(400)
assert.equal(await inspect('document.querySelector(".intro-time").textContent'), pausedTime)
writeFileSync('/tmp/screen-intro-waveform.png', (await output.webContents.capturePage()).toPNG())
const pausedPosition = await inspect('document.querySelector("audio[data-channel=main-audio]").currentTime')
await command({ action: 'resume', channel: 'main' })
await poll(() => inspect(`document.querySelector('audio[data-channel=main-audio]').currentTime > ${pausedPosition + 0.3}`), 'intro resumes')
await command({ action: 'pause', channel: 'main' })
output.webContents.enableDeviceEmulation({ screenPosition: 'desktop', screenSize: { width: 320, height: 240 }, viewSize: { width: 320, height: 240 }, deviceScaleFactor: 1, scale: 1 })
await poll(() => inspect('window.innerWidth === 320 && window.innerHeight === 240'), 'small output viewport')
assert.ok(await inspect('(() => { const r = document.querySelector(".intro-recording").getBoundingClientRect(); return r.width <= 320 && r.height <= 240 })()'))
output.webContents.disableDeviceEmulation()
await command({ action: 'replay', channel: 'main' })
await poll(() => inspect('Number(document.querySelector(".intro-waveform").getAttribute("aria-valuenow")) < 5'), 'intro replay resets progress')
await command({ action: 'seek', channel: 'main', position: 59.7 })
await poll(async () => (await state()).channels.main.status === 'ended', 'intro audio ends')
await poll(() => inspect('document.querySelector(".intro-waveform").getAttribute("aria-valuenow") === "100"'), 'intro finishes filled')
await command({ action: 'step', index: 2 })
await poll(() => inspect('!document.querySelector(".intro-recording")'), 'waveform hidden in other modes')
await command({ action: 'step', index: 1 })
await poll(() => inspect('document.querySelector(".intro-waveform")?.getAttribute("aria-valuenow") === "0"'), 'return to intro resets progress')
await inspect('const a = document.querySelector("audio[data-channel=main-audio]"); Object.defineProperty(a, "duration", { value: NaN, configurable: true }); a.dispatchEvent(new Event("durationchange"))')
await poll(() => inspect('document.querySelector(".intro-time").textContent === "00:00 / —"'), 'unknown duration')
await inspect('delete document.querySelector("audio[data-channel=main-audio]").duration')
await evaluate('window.screenBridge.closeOutput()')
await evaluate('window.screenBridge.selectPackage(null)')
await evaluate(`window.screenBridge.show(${targetDisplay.id})`)
await poll(() => BrowserWindow.getAllWindows().some((w) => w.webContents.getURL() === 'screen-app://app/index.html?output=1' && !w.webContents.isLoading()), 'new output ready')
await command({ action: 'mode', mode: 2 })
await poll(() => inspect('document.querySelector(".intro-waveform")?.getAttribute("aria-valuenow") === "0"'), 'new game starts empty')
console.log('SCREEN_INTRO_WAVEFORM_PASS')
app.exit(0)
return
}
await poll(async () => (await state()).channels.main.status === 'stopped', 'fixture prepared')
if (process.platform === 'linux')
assert.equal(app.commandLine.getSwitchValue('ozone-platform'), 'x11')
+92 -2
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue'
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 { formatTimer } from '../utils/presentation'
const audio = ref<HTMLAudioElement | null>(null)
const video = ref<HTMLVideoElement | null>(null)
@@ -13,6 +14,25 @@ const surface = ref<HTMLElement | null>(null)
const state = shallowRef<State | null>(null)
const imageError = ref(false)
const timerDisplay = useScreenTimer(() => state.value)
const introPosition = ref(0)
const introDuration = ref<number | null>(null)
const introProgress = computed(() => introDuration.value ? introPosition.value / introDuration.value : 0)
const introTime = computed(() => `${formatTimer(Math.floor(introPosition.value) * 1000)} / ${introDuration.value === null ? '—' : formatTimer(Math.floor(introDuration.value) * 1000)}`)
const introBars = [24, 38, 18, 46, 58, 32, 68, 44, 26, 54, 72, 40, 60, 30, 48, 64, 36, 76, 52, 28, 44, 62, 34, 56, 70, 42, 22, 50, 66, 38, 58, 74, 46, 30, 54, 40, 68, 48, 26, 60, 36, 72, 52, 32, 46, 62, 28, 18]
function resetIntroProgress() {
introPosition.value = 0
introDuration.value = null
}
function updateIntroProgress() {
const media = audio.value
if (!media || state.value?.scene.mode !== 2 || media.getAttribute('src') !== state.value.channels.main.source?.url) {
resetIntroProgress()
return
}
introDuration.value = Number.isFinite(media.duration) && media.duration > 0 ? media.duration : null
introPosition.value = introDuration.value && Number.isFinite(media.currentTime)
? Math.max(0, Math.min(media.currentTime, introDuration.value)) : 0
}
const bridge = window.screenBridge!
let player: MediaPlayer | undefined
let disposed = false
@@ -90,8 +110,11 @@ function apply(value: State) {
if (disposed) return
if (value.revision <= revision) return
revision = value.revision
if (value.session !== state.value?.session || value.channels.main.source?.url !== state.value?.channels.main.source?.url || value.scene.mode !== 2)
resetIntroProgress()
state.value = value
player?.apply(value)
updateIntroProgress()
const token = `${value.session}:${value.scene.revision}`
if (token !== sceneToken) {
sceneToken = token
@@ -145,6 +168,31 @@ onUnmounted(() => {
<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="Аудиозапись введения">
<h1>Введение</h1>
<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 480 80" preserveAspectRatio="none" aria-hidden="true">
<defs>
<mask id="intro-waveform-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="480" height="80">
<rect v-for="(height, index) in introBars" :key="index" :x="index * 10 + 2" :y="(80 - height) / 2" width="6" :height="height" rx="3" fill="white" />
</mask>
</defs>
<g mask="url(#intro-waveform-mask)" fill="currentColor">
<rect width="480" height="80" opacity="0.25" />
<rect :width="480 * introProgress" height="80" />
</g>
</svg>
</div>
<p class="intro-time">{{ introTime }}</p>
</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">
Видео закрывашки не задано
@@ -161,7 +209,18 @@ onUnmounted(() => {
</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" />
<audio
ref="audio"
data-channel="main-audio"
preload="auto"
@timeupdate="updateIntroProgress"
@loadedmetadata="updateIntroProgress"
@durationchange="updateIntroProgress"
@seeked="updateIntroProgress"
@pause="updateIntroProgress"
@ended="updateIntroProgress"
@emptied="resetIntroProgress"
/>
<video
v-show="state?.scene.mode === 4 && state.channels.main.source?.kind === 'video'"
ref="video"
@@ -229,6 +288,37 @@ video {
white-space: pre-wrap;
}
.intro-recording {
background: rgb(17 17 17 / 78%);
border-radius: clamp(20px, 3vw, 48px);
box-sizing: border-box;
max-width: 1100px;
padding: clamp(20px, 4vw, 64px);
width: 82vw;
}
.intro-recording h1 {
font-size: clamp(24px, min(4vw, 8vh), 64px);
font-weight: 500;
margin: 0 0 clamp(16px, 4vh, 48px);
}
.intro-waveform {
height: clamp(48px, 14vh, 160px);
}
.intro-waveform svg {
display: block;
height: 100%;
width: 100%;
}
.intro-time {
font-size: clamp(18px, min(2.5vw, 5vh), 36px);
font-variant-numeric: tabular-nums;
margin: clamp(16px, 3vh, 32px) 0 0;
}
.timer h1 {
font-size: clamp(28px, 5vw, 80px);
}