Add background narration track with sound toggle

Click-to-play M4A narration, looping with fade in and out, behind a
bottom-left Sound on/off button.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
David 2026-11-03 22:39:28 +02:00
parent d57194b8dd
commit aebcf40fa9
3 changed files with 62 additions and 0 deletions

Binary file not shown.

View file

@ -1,4 +1,5 @@
import { Nav } from '@/components/Nav'
import { SoundToggle } from '@/components/SoundToggle'
import { ScrollScene } from '@/components/ScrollScene'
import { Intro } from '@/components/Intro'
import { Services, WhyUs } from '@/components/Services'
@ -13,6 +14,7 @@ export default function App() {
Skip the fly-through
</a>
<Nav />
<SoundToggle />
<main>
<ScrollScene />
<Intro />

View file

@ -0,0 +1,60 @@
import { useEffect, useRef, useState } from 'react'
import { Volume2, VolumeX } from 'lucide-react'
const SRC = '/audio/we-build-tomorrow.m4a'
const VOLUME = 0.6
// Background narration. Browsers block sound until the visitor asks for it,
// so it starts on click, loops, and fades in and out rather than cutting.
export function SoundToggle() {
const audio = useRef<HTMLAudioElement | null>(null)
const fade = useRef<number | undefined>(undefined)
const [on, setOn] = useState(false)
const ramp = (to: number, done?: () => void) => {
const a = audio.current
if (!a) return
window.clearInterval(fade.current)
fade.current = window.setInterval(() => {
const step = to > a.volume ? 0.05 : -0.05
a.volume = Math.min(1, Math.max(0, a.volume + step))
if (Math.abs(a.volume - to) < 0.051) {
a.volume = to
window.clearInterval(fade.current)
done?.()
}
}, 40)
}
useEffect(() => () => window.clearInterval(fade.current), [])
const toggle = () => {
if (!audio.current) {
const a = new Audio(SRC)
a.loop = true
a.volume = 0
audio.current = a
}
const a = audio.current
if (on) {
ramp(0, () => a.pause())
setOn(false)
} else {
a.play().then(() => ramp(VOLUME)).catch(() => setOn(false))
setOn(true)
}
}
return (
<button
type="button"
onClick={toggle}
aria-pressed={on}
aria-label={on ? 'Mute background narration' : 'Play background narration'}
className="fixed bottom-5 left-5 z-[60] inline-flex h-11 items-center gap-2.5 rounded-full border border-bone/25 bg-ink/70 px-4 text-[13px] font-medium text-bone backdrop-blur-md transition-colors hover:border-gold hover:text-gold sm:bottom-7 sm:left-7"
>
{on ? <Volume2 size={16} strokeWidth={1.75} className="text-gold" /> : <VolumeX size={16} strokeWidth={1.75} />}
<span className="hidden sm:inline">{on ? 'Sound on' : 'Sound off'}</span>
</button>
)
}