add animation

This commit is contained in:
2026-09-10 14:00:00 +07:00
parent 45e274eb22
commit 65fdc4f6ad
40 changed files with 121 additions and 38 deletions
+83
View File
@@ -26,7 +26,12 @@ const binding = ref(false)
const bindSuccess = ref(false)
const introAudioElement = ref<HTMLAudioElement>()
const conclusionVideoElement = ref<HTMLVideoElement>()
const visualizerCanvas = ref<HTMLCanvasElement>()
let appliedCommandVersion = 0
let audioContext: AudioContext | undefined
let analyser: AnalyserNode | undefined
let audioSource: MediaElementAudioSourceNode | undefined
let visualizerFrame = 0
let pollTimer: number | null = null
const bindingFlow = computed(() => Boolean(route.query.screenId && route.query.accessToken))
@@ -95,10 +100,43 @@ async function loadGames() {
}
}
function startVisualizer() {
const audio = introAudioElement.value
const canvas = visualizerCanvas.value
if (!audio || !canvas || audioSource) return
try {
audioContext = new AudioContext()
analyser = audioContext.createAnalyser()
analyser.fftSize = 64
audioSource = audioContext.createMediaElementSource(audio)
audioSource.connect(analyser)
analyser.connect(audioContext.destination)
const data = new Uint8Array(analyser.frequencyBinCount)
const draw = () => {
if (!analyser || !visualizerCanvas.value) return
const context = visualizerCanvas.value.getContext('2d')
if (!context) return
analyser.getByteFrequencyData(data)
context.clearRect(0, 0, canvas.width, canvas.height)
const barWidth = canvas.width / data.length
data.forEach((value, index) => {
const height = (value / 255) * canvas.height
context.fillStyle = `hsl(${160 + index * 3} 70% 60%)`
context.fillRect(index * barWidth, canvas.height - height, Math.max(2, barWidth - 1), height)
})
visualizerFrame = requestAnimationFrame(draw)
}
draw()
} catch {
// Браузер может запретить анализ MediaElement, воспроизведение при этом остаётся доступным.
}
}
watch(state, async (value) => {
if (!value || !value.command || value.stateVersion === appliedCommandVersion) return
appliedCommandVersion = value.stateVersion || 0
await nextTick()
startVisualizer()
const media = screenMode.value === 'SCREEN_MODE_CONCLUSION' ? conclusionVideoElement.value : introAudioElement.value
if (!media) return
media.currentTime = (value.positionMs || 0) / 1000
@@ -166,6 +204,8 @@ onMounted(async () => {
onUnmounted(() => {
if (pollTimer != null) window.clearInterval(pollTimer)
cancelAnimationFrame(visualizerFrame)
void audioContext?.close()
})
</script>
@@ -202,6 +242,12 @@ onUnmounted(() => {
<p v-if="mediaError" class="media-error">{{ mediaError }}</p>
<div v-if="screenMode === 'SCREEN_MODE_INTRO' && state.introAudio" class="screen-media">
<p class="media-title">Введение</p>
<div class="cassette">
<div class="cassette-label">ДИКТОФОН</div>
<canvas ref="visualizerCanvas" width="640" height="120" />
<div class="cassette-reel cassette-reel-left" />
<div class="cassette-reel cassette-reel-right" />
</div>
<audio ref="introAudioElement" :src="introAudio" :muted="!state.soundEnabled" autoplay controls />
</div>
<div v-else-if="screenMode === 'SCREEN_MODE_CONCLUSION' && state.conclusionVideo" class="screen-media">
@@ -330,4 +376,41 @@ onUnmounted(() => {
.media-title {
font-size: 32px;
}
.cassette {
background: #303030;
border: 8px solid #555;
border-radius: 12px;
margin: 0 auto 20px;
max-width: 720px;
padding: 18px;
position: relative;
}
.cassette-label {
color: #d6c58a;
font-family: monospace;
letter-spacing: 4px;
margin-bottom: 10px;
}
.cassette canvas {
background: #161616;
display: block;
height: 120px;
max-width: 100%;
width: 640px;
}
.cassette-reel {
border: 6px solid #aaa;
border-radius: 50%;
bottom: 30px;
height: 42px;
position: absolute;
width: 42px;
}
.cassette-reel-left { left: 34px; }
.cassette-reel-right { right: 34px; }
</style>