From bd8f7e49c07b7dd7d3b7ac8ec7f043d066125134 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Mon, 19 Oct 2026 01:59:40 +0700 Subject: [PATCH] up --- scripts/smoke.cjs | 39 +++++++++- src/components/OutputPage.vue | 136 +++++++++++++++++++++++++--------- 2 files changed, 138 insertions(+), 37 deletions(-) diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index 2aee316..f5485ec 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -329,17 +329,42 @@ app.whenReady().then(async () => { .find((w) => w.webContents.getURL() === 'screen-app://app/index.html?output=1') .webContents.executeJavaScript(source) if (process.argv.includes('--intro-waveform')) { + const selectedDevice = (await evaluate('window.screenBridge.host()')).audioOutputs + .find(device => device.id && !['default', 'communications'].includes(device.id)) + if (selectedDevice) + await evaluate(`window.screenBridge.selectAudioOutput(${JSON.stringify(selectedDevice.id)})`) 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) + assert.equal(await inspect('document.querySelectorAll(".intro-waveform rect").length'), 128) + assert.equal(await inspect('document.querySelector(".intro-recording").textContent.trim()'), '') + assert.equal(await inspect('getComputedStyle(document.querySelector(".intro-recording")).backgroundColor'), 'rgba(0, 0, 0, 0)') + const bars = () => inspect('Array.from(document.querySelectorAll(".intro-waveform rect"), r => Number(r.getAttribute("height")))') + const sinkBefore = await inspect('document.querySelector("audio[data-channel=main-audio]").sinkId') await command({ action: 'play', channel: 'main' }) + await poll(async () => (await bars()).some(h => h > 3), 'wave reacts to audio') + const playingBars = JSON.stringify(await bars()) + await poll(async () => JSON.stringify(await bars()) !== playingBars, 'wave changes with audio') + await command({ action: 'mute', enabled: true }) + await poll(async () => (await bars()).every(h => h === 2), 'mute shows silence') + await command({ action: 'mute', enabled: false }) + await poll(async () => (await bars()).some(h => h > 3), 'unmute restores wave') + assert.equal(await inspect('document.querySelector("audio[data-channel=main-audio]").sinkId'), sinkBefore) 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 poll(async () => (await state()).channels.main.status === 'paused', 'intro paused') + const pausedBars = await bars() await delay(400) - assert.equal(await inspect('document.querySelector(".intro-time").textContent'), pausedTime) + assert.deepEqual(await bars(), pausedBars) writeFileSync('/tmp/screen-intro-waveform.png', (await output.webContents.capturePage()).toPNG()) + for (const [width, height] of [[1280, 720], [1024, 768]]) { + output.webContents.enableDeviceEmulation({ screenPosition: 'desktop', screenSize: { width, height }, viewSize: { width, height }, deviceScaleFactor: 1, scale: 1 }) + await poll(() => inspect(`innerWidth === ${width} && innerHeight === ${height}`), 'intro aspect ratio') + assert.ok(await inspect('(() => { const r = document.querySelector(".intro-waveform").getBoundingClientRect(); return Math.abs(r.x + r.width / 2 - innerWidth / 2) < 1 && Math.abs(r.y + r.height / 2 - innerHeight / 2) < 1 })()')) + } + output.webContents.disableDeviceEmulation() + await evaluate('window.screenBridge.selectAudioOutput("")') 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') @@ -352,13 +377,14 @@ app.whenReady().then(async () => { 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 poll(() => inspect('document.querySelector(".intro-waveform").getAttribute("aria-valuenow") === "100"'), 'intro accessible progress reaches end') + assert.ok((await bars()).every(h => h === 2)) 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 poll(() => inspect('document.querySelector(".intro-waveform").getAttribute("aria-valuetext") === "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)') @@ -366,6 +392,11 @@ app.whenReady().then(async () => { 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') + await inspect('window.originalCaptureStream = HTMLMediaElement.prototype.captureStream; HTMLMediaElement.prototype.captureStream = () => { throw new Error("unavailable") }; void 0') + await command({ action: 'play', channel: 'main' }) + await poll(async () => (await state()).channels.main.status === 'playing', 'visualization failure does not stop audio') + await poll(async () => (await bars()).every(h => h === 2), 'visualization failure shows baseline') + await inspect('HTMLMediaElement.prototype.captureStream = window.originalCaptureStream; void 0') console.log('SCREEN_INTRO_WAVEFORM_PASS') app.exit(0) return diff --git a/src/components/OutputPage.vue b/src/components/OutputPage.vue index ad94a28..70ce2f4 100644 --- a/src/components/OutputPage.vue +++ b/src/components/OutputPage.vue @@ -18,7 +18,98 @@ const introPosition = ref(0) const introDuration = ref(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] +const introBars = ref(Array(128).fill(2)) +let waveformFrame = 0 +let waveformRun = 0 +let waveform: { + context: AudioContext + stream: MediaStream + source: MediaStreamAudioSourceNode + analyser: AnalyserNode +} | null = null +function clearIntroWaveform() { + waveformRun++ + cancelAnimationFrame(waveformFrame) + const previous = waveform + waveform = null + if (previous) { + previous.source.disconnect() + previous.analyser.disconnect() + previous.stream.getTracks().forEach(track => track.stop()) + void previous.context.close().catch(() => {}) + } + introBars.value = Array(128).fill(2) +} +async function updateIntroWaveform() { + const run = ++waveformRun + cancelAnimationFrame(waveformFrame) + const media = audio.value + if (disposed || state.value?.scene.mode !== 2 || !media?.getAttribute('src')) { + clearIntroWaveform() + return + } + if (media.ended || state.value.channels.main.desired === 'stopped') { + clearIntroWaveform() + return + } + if (media.paused) { + if (state.value.muted || media.muted || media.volume === 0) introBars.value = Array(128).fill(2) + return + } + try { + if (!waveform) { + const capture = (media as HTMLAudioElement & { captureStream?: () => MediaStream }).captureStream + if (!capture) return + const stream = capture.call(media) + if (!stream.getAudioTracks().length) { + stream.getTracks().forEach(track => track.stop()) + return + } + let context: AudioContext | undefined + try { + context = new AudioContext() + const analyser = context.createAnalyser() + const source = context.createMediaStreamSource(stream) + analyser.fftSize = 2048 + // Анализируем копию потока, не меняя аудиовыход исходного элемента. + source.connect(analyser) + waveform = { context, stream, source, analyser } + } catch (error) { + stream.getTracks().forEach(track => track.stop()) + void context?.close().catch(() => {}) + throw error + } + } + const current = waveform + await current.context.resume() + if (waveform !== current || run !== waveformRun || disposed || media.paused) return + const samples = new Float32Array(current.analyser.fftSize) + let previousFrame = -Infinity + const draw = (now: number) => { + if (waveform !== current || run !== waveformRun || disposed || media.paused) return + if (now - previousFrame >= 1000 / 30) { + previousFrame = now + current.analyser.getFloatTimeDomainData(samples) + const silent = state.value?.muted || media.muted || media.volume === 0 + introBars.value = introBars.value.map((height, index) => { + let sum = 0 + for (let i = index * 16; i < (index + 1) * 16; i++) sum += samples[i]! ** 2 + const level = silent ? 0 : Math.sqrt(sum / 16) + const target = 2 + Math.min(1, level * 3) * 74 + return level < 0.001 ? 2 : height * 0.65 + target * 0.35 + }) + } + waveformFrame = requestAnimationFrame(draw) + } + waveformFrame = requestAnimationFrame(draw) + } catch { + clearIntroWaveform() + } +} +function introPlaybackChanged() { + updateIntroProgress() + void updateIntroWaveform() +} function resetIntroProgress() { introPosition.value = 0 introDuration.value = null @@ -110,11 +201,14 @@ 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) + if (value.session !== state.value?.session || value.channels.main.source?.url !== state.value?.channels.main.source?.url || value.scene.mode !== 2) { resetIntroProgress() + clearIntroWaveform() + } state.value = value player?.apply(value) updateIntroProgress() + void updateIntroWaveform() const token = `${value.session}:${value.scene.revision}` if (token !== sceneToken) { sceneToken = token @@ -138,6 +232,7 @@ onMounted(async () => { onUnmounted(() => { disposed = true sceneAbort?.abort() + clearIntroWaveform() unsubscribe?.() player?.destroy() }) @@ -169,7 +264,6 @@ onUnmounted(() => { Вступление не задано

-

Введение

{ :aria-valuenow="introDuration === null ? undefined : Math.round(introProgress * 100)" :aria-valuetext="introTime" > -
-

{{ introTime }}

Успехов

@@ -217,9 +302,10 @@ onUnmounted(() => { @loadedmetadata="updateIntroProgress" @durationchange="updateIntroProgress" @seeked="updateIntroProgress" - @pause="updateIntroProgress" - @ended="updateIntroProgress" - @emptied="resetIntroProgress" + @playing="introPlaybackChanged" + @pause="introPlaybackChanged" + @ended="introPlaybackChanged" + @emptied="resetIntroProgress(); clearIntroWaveform()" />