up
This commit is contained in:
+35
-4
@@ -329,17 +329,42 @@ app.whenReady().then(async () => {
|
|||||||
.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('--intro-waveform')) {
|
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 command({ action: 'step', index: 1 })
|
||||||
await poll(() => inspect('document.querySelector(".intro-waveform")?.getAttribute("aria-valuenow") === "0"'), 'intro waveform starts empty')
|
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-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 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: 'seek', channel: 'main', position: 15 })
|
||||||
await command({ action: 'pause', channel: 'main' })
|
await command({ action: 'pause', channel: 'main' })
|
||||||
await poll(() => inspect('Number(document.querySelector(".intro-waveform").getAttribute("aria-valuenow")) >= 24'), 'intro progress follows seek')
|
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)
|
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())
|
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')
|
const pausedPosition = await inspect('document.querySelector("audio[data-channel=main-audio]").currentTime')
|
||||||
await command({ action: 'resume', channel: 'main' })
|
await command({ action: 'resume', channel: 'main' })
|
||||||
await poll(() => inspect(`document.querySelector('audio[data-channel=main-audio]').currentTime > ${pausedPosition + 0.3}`), 'intro resumes')
|
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 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 command({ action: 'seek', channel: 'main', position: 59.7 })
|
||||||
await poll(async () => (await state()).channels.main.status === 'ended', 'intro audio ends')
|
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 command({ action: 'step', index: 2 })
|
||||||
await poll(() => inspect('!document.querySelector(".intro-recording")'), 'waveform hidden in other modes')
|
await poll(() => inspect('!document.querySelector(".intro-recording")'), 'waveform hidden in other modes')
|
||||||
await command({ action: 'step', index: 1 })
|
await command({ action: 'step', index: 1 })
|
||||||
await poll(() => inspect('document.querySelector(".intro-waveform")?.getAttribute("aria-valuenow") === "0"'), 'return to intro resets progress')
|
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 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 inspect('delete document.querySelector("audio[data-channel=main-audio]").duration')
|
||||||
await evaluate('window.screenBridge.closeOutput()')
|
await evaluate('window.screenBridge.closeOutput()')
|
||||||
await evaluate('window.screenBridge.selectPackage(null)')
|
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 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 command({ action: 'mode', mode: 2 })
|
||||||
await poll(() => inspect('document.querySelector(".intro-waveform")?.getAttribute("aria-valuenow") === "0"'), 'new game starts empty')
|
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')
|
console.log('SCREEN_INTRO_WAVEFORM_PASS')
|
||||||
app.exit(0)
|
app.exit(0)
|
||||||
return
|
return
|
||||||
|
|||||||
+103
-33
@@ -18,7 +18,98 @@ const introPosition = ref(0)
|
|||||||
const introDuration = ref<number | null>(null)
|
const introDuration = ref<number | null>(null)
|
||||||
const introProgress = computed(() => introDuration.value ? introPosition.value / introDuration.value : 0)
|
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 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<number[]>(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() {
|
function resetIntroProgress() {
|
||||||
introPosition.value = 0
|
introPosition.value = 0
|
||||||
introDuration.value = null
|
introDuration.value = null
|
||||||
@@ -110,11 +201,14 @@ function apply(value: State) {
|
|||||||
if (disposed) return
|
if (disposed) return
|
||||||
if (value.revision <= revision) return
|
if (value.revision <= revision) return
|
||||||
revision = value.revision
|
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()
|
resetIntroProgress()
|
||||||
|
clearIntroWaveform()
|
||||||
|
}
|
||||||
state.value = value
|
state.value = value
|
||||||
player?.apply(value)
|
player?.apply(value)
|
||||||
updateIntroProgress()
|
updateIntroProgress()
|
||||||
|
void updateIntroWaveform()
|
||||||
const token = `${value.session}:${value.scene.revision}`
|
const token = `${value.session}:${value.scene.revision}`
|
||||||
if (token !== sceneToken) {
|
if (token !== sceneToken) {
|
||||||
sceneToken = token
|
sceneToken = token
|
||||||
@@ -138,6 +232,7 @@ onMounted(async () => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
disposed = true
|
disposed = true
|
||||||
sceneAbort?.abort()
|
sceneAbort?.abort()
|
||||||
|
clearIntroWaveform()
|
||||||
unsubscribe?.()
|
unsubscribe?.()
|
||||||
player?.destroy()
|
player?.destroy()
|
||||||
})
|
})
|
||||||
@@ -169,7 +264,6 @@ onUnmounted(() => {
|
|||||||
Вступление не задано
|
Вступление не задано
|
||||||
</p>
|
</p>
|
||||||
<section v-else-if="state.scene.mode === 2" class="intro-recording" aria-label="Аудиозапись введения">
|
<section v-else-if="state.scene.mode === 2" class="intro-recording" aria-label="Аудиозапись введения">
|
||||||
<h1>Введение</h1>
|
|
||||||
<div
|
<div
|
||||||
class="intro-waveform"
|
class="intro-waveform"
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
@@ -179,19 +273,10 @@ onUnmounted(() => {
|
|||||||
:aria-valuenow="introDuration === null ? undefined : Math.round(introProgress * 100)"
|
:aria-valuenow="introDuration === null ? undefined : Math.round(introProgress * 100)"
|
||||||
:aria-valuetext="introTime"
|
:aria-valuetext="introTime"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 480 80" preserveAspectRatio="none" aria-hidden="true">
|
<svg viewBox="0 0 768 80" preserveAspectRatio="none" aria-hidden="true">
|
||||||
<defs>
|
<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" />
|
||||||
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p class="intro-time">{{ introTime }}</p>
|
|
||||||
</section>
|
</section>
|
||||||
<p v-else-if="state.scene.mode === 3" class="message">Успехов</p>
|
<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 v-else-if="state.scene.mode === 4 && !state.channels.main.source" class="message">
|
||||||
@@ -217,9 +302,10 @@ onUnmounted(() => {
|
|||||||
@loadedmetadata="updateIntroProgress"
|
@loadedmetadata="updateIntroProgress"
|
||||||
@durationchange="updateIntroProgress"
|
@durationchange="updateIntroProgress"
|
||||||
@seeked="updateIntroProgress"
|
@seeked="updateIntroProgress"
|
||||||
@pause="updateIntroProgress"
|
@playing="introPlaybackChanged"
|
||||||
@ended="updateIntroProgress"
|
@pause="introPlaybackChanged"
|
||||||
@emptied="resetIntroProgress"
|
@ended="introPlaybackChanged"
|
||||||
|
@emptied="resetIntroProgress(); clearIntroWaveform()"
|
||||||
/>
|
/>
|
||||||
<video
|
<video
|
||||||
v-show="state?.scene.mode === 4 && state.channels.main.source?.kind === 'video'"
|
v-show="state?.scene.mode === 4 && state.channels.main.source?.kind === 'video'"
|
||||||
@@ -295,20 +381,10 @@ video {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.intro-recording {
|
.intro-recording {
|
||||||
background: rgb(17 17 17 / 78%);
|
|
||||||
border-radius: clamp(20px, 3vw, 48px);
|
|
||||||
box-sizing: border-box;
|
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
padding: clamp(20px, 4vw, 64px);
|
|
||||||
width: 82vw;
|
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 {
|
.intro-waveform {
|
||||||
height: clamp(48px, 14vh, 160px);
|
height: clamp(48px, 14vh, 160px);
|
||||||
}
|
}
|
||||||
@@ -319,12 +395,6 @@ video {
|
|||||||
width: 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 {
|
.timer {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user