This commit is contained in:
2026-10-19 01:59:40 +07:00
parent 6798275187
commit bd8f7e49c0
2 changed files with 138 additions and 37 deletions
+103 -33
View File
@@ -18,7 +18,98 @@ 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]
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() {
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(() => {
Вступление не задано
</p>
<section v-else-if="state.scene.mode === 2" class="intro-recording" aria-label="Аудиозапись введения">
<h1>Введение</h1>
<div
class="intro-waveform"
role="progressbar"
@@ -179,19 +273,10 @@ onUnmounted(() => {
: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 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>
<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">
@@ -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()"
/>
<video
v-show="state?.scene.mode === 4 && state.channels.main.source?.kind === 'video'"
@@ -295,20 +381,10 @@ video {
}
.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);
}
@@ -319,12 +395,6 @@ video {
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 {
align-items: center;
display: flex;