Skip to main content

Overview

The background audio system provides a seamless spiritual atmosphere with auto-playing prayer music. It features volume fading, user consent management, and playback that persists across page navigation. The system complies with browser autoplay policies while maintaining a smooth user experience.

Audio Context Provider

The audio system is managed through a React Context that wraps the entire application:
contexts/audio-context.tsx
interface AudioContextType {
  play: () => void
  pause: () => void
  fadeOut: (duration?: number) => void
  fadeToVolume: (targetVolume: number, duration?: number) => void
  toggle: () => void
  isPlaying: boolean
  isLoaded: boolean
  hasUserConsent: boolean
  grantConsent: () => void
}

const AudioContext = createContext<AudioContextType | null>(null)

export function AudioProvider({ children }: { children: ReactNode }) {
  const audioRef = useRef<HTMLAudioElement | null>(null)
  const [isPlaying, setIsPlaying] = useState(false)
  const [isLoaded, setIsLoaded] = useState(false)
  const hasUserConsentRef = useRef(false)
  const [hasUserConsent, setHasUserConsent] = useState(false)
  const fadeIntervalRef = useRef<NodeJS.Timeout | null>(null)
  const targetVolumeRef = useRef(1)
  const pathname = usePathname()

  useEffect(() => {
    // Initialize audio element
    audioRef.current = new Audio('https://cdn.njrajatmahotsav.com/audio_files/prathna%2Banthem.mp3')
    audioRef.current.preload = 'auto'
    // Start muted until explicit user interaction
    audioRef.current.volume = 0

    const audio = audioRef.current

    const handleCanPlayThrough = () => setIsLoaded(true)
    const handleLoadedMetadata = () => setIsLoaded(true)
    const handleEnded = () => setIsPlaying(false)

    audio.addEventListener('canplaythrough', handleCanPlayThrough)
    audio.addEventListener('loadedmetadata', handleLoadedMetadata)
    audio.addEventListener('ended', handleEnded)

    return () => {
      if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current)
      audio.pause()
      audio.removeEventListener('canplaythrough', handleCanPlayThrough)
      audio.removeEventListener('loadedmetadata', handleLoadedMetadata)
      audio.removeEventListener('ended', handleEnded)
    }
  }, [])

  // ... implementation details
}

export function useAudioContext() {
  const context = useContext(AudioContext)
  if (!context) throw new Error('useAudioContext must be used within AudioProvider')
  return context
}

Audio File

The prayer audio is served from Cloudflare CDN:
const audioUrl = 'https://cdn.njrajatmahotsav.com/audio_files/prathna%2Banthem.mp3'
audioRef.current = new Audio(audioUrl)
audioRef.current.preload = 'auto'
The audio file combines prayer (prathna) and anthem for a continuous spiritual experience throughout the event website.
Browser autoplay policies require explicit user interaction before audio can play:
contexts/audio-context.tsx
const grantConsent = () => {
  hasUserConsentRef.current = true
  setHasUserConsent(true)
}

const play = () => {
  if (audioRef.current && hasUserConsentRef.current) {
    audioRef.current.volume = targetVolumeRef.current
    audioRef.current.play()
    setIsPlaying(true)
  }
}

const toggle = () => {
  if (isPlaying) {
    pause()
  } else {
    grantConsent() // Grant consent on first interaction
    if (audioRef.current) {
      audioRef.current.volume = targetVolumeRef.current
      audioRef.current.play()
      setIsPlaying(true)
    }
  }
}

Fade Effects

The system includes smooth volume fading:

Fade Out

contexts/audio-context.tsx
const fadeOut = (duration = 1000) => {
  if (!audioRef.current || !isPlaying || !hasUserConsentRef.current) return

  if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current)

  const audio = audioRef.current
  const startVolume = audio.volume
  const steps = 50
  const stepTime = duration / steps
  const volumeStep = startVolume / steps

  fadeIntervalRef.current = setInterval(() => {
    if (audio.volume > volumeStep) {
      audio.volume = Math.max(0, audio.volume - volumeStep)
    } else {
      audio.volume = 0
      audio.pause()
      setIsPlaying(false)
      if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current)
    }
  }, stepTime)
}

Fade to Volume

contexts/audio-context.tsx
const fadeToVolume = (targetVolume: number, duration = 1000) => {
  if (!audioRef.current || !hasUserConsentRef.current) return

  if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current)

  const audio = audioRef.current
  const startVolume = audio.volume
  const volumeDiff = targetVolume - startVolume
  const steps = 50
  const stepTime = duration / steps
  const volumeStep = volumeDiff / steps

  targetVolumeRef.current = targetVolume

  fadeIntervalRef.current = setInterval(() => {
    const newVolume = audio.volume + volumeStep
    if ((volumeStep > 0 && newVolume < targetVolume) || (volumeStep < 0 && newVolume > targetVolume)) {
      audio.volume = newVolume
    } else {
      audio.volume = targetVolume
      if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current)
    }
  }, stepTime)
}

Fade Out

Gradually reduces volume to 0 over 1 second (default), then pauses playback

Fade To Volume

Smoothly transitions to any volume level between 0 and 1

Audio Player Component

Floating audio control button with adaptive styling:
components/audio-player.tsx
export function AudioPlayer() {
  const { toggle, isPlaying, isLoaded } = useAudioContext()
  const [isDarkBackground, setIsDarkBackground] = useState(true)
  const [hasScrolled, setHasScrolled] = useState(false)
  const rafRef = useRef<number>()

  useEffect(() => {
    let ticking = false
    
    const handleScroll = () => {
      if (!hasScrolled && window.scrollY > 0) {
        setHasScrolled(true)
      }
      if (!ticking) {
        requestAnimationFrame(() => {
          if (rafRef.current) cancelAnimationFrame(rafRef.current)
          rafRef.current = requestAnimationFrame(() => {
            const element = document.elementFromPoint(50, window.innerHeight - 100)
            if (element) {
              const styles = window.getComputedStyle(element)
              const bgColor = styles.backgroundColor
              
              if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
                const rgb = bgColor.match(/\d+/g)
                if (rgb) {
                  const brightness = (parseInt(rgb[0]) * 299 + parseInt(rgb[1]) * 587 + parseInt(rgb[2]) * 114) / 1000
                  setIsDarkBackground(brightness < 128)
                }
              }
            }
          })
          
          ticking = false
        })
        ticking = true
      }
    }

    handleScroll()
    window.addEventListener('scroll', handleScroll, { passive: true })
    return () => {
      window.removeEventListener('scroll', handleScroll)
      if (rafRef.current) cancelAnimationFrame(rafRef.current)
    }
  }, [hasScrolled])

  return (
    <div className="fixed bottom-6 right-18 z-40">
      <FloatingButton
        onClick={toggle}
        disabled={!isLoaded}
        isDarkBackground={isDarkBackground}
        isVisible={isLoaded}
        className={!hasScrolled ? 'animate-float-attention' : undefined}
        aria-label={isPlaying ? 'Pause audio' : 'Play audio'}
      >
        {isPlaying ? <Pause size={18} /> : <Play size={18} className="ml-0.5" />}
      </FloatingButton>
    </div>
  )
}

Adaptive Background Detection

The audio button adapts its styling based on the background color:
components/audio-player.tsx
const element = document.elementFromPoint(50, window.innerHeight - 100)
if (element) {
  const styles = window.getComputedStyle(element)
  const bgColor = styles.backgroundColor
  
  if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
    const rgb = bgColor.match(/\d+/g)
    if (rgb) {
      // Calculate brightness using luminance formula
      const brightness = (parseInt(rgb[0]) * 299 + parseInt(rgb[1]) * 587 + parseInt(rgb[2]) * 114) / 1000
      setIsDarkBackground(brightness < 128)
    }
  }
}
The button uses the luminance formula (0.299R + 0.587G + 0.114B) to accurately determine if the background is dark or light, adjusting its appearance for optimal contrast.

Attention Animation

On initial load, the button pulses to draw user attention:
components/audio-player.tsx
<FloatingButton
  onClick={toggle}
  disabled={!isLoaded}
  isDarkBackground={isDarkBackground}
  isVisible={isLoaded}
  className={!hasScrolled ? 'animate-float-attention' : undefined} // Pulse until scroll
  aria-label={isPlaying ? 'Pause audio' : 'Play audio'}
>
  {isPlaying ? <Pause size={18} /> : <Play size={18} className="ml-0.5" />}
</FloatingButton>

Persistent Playback

Audio continues playing across page navigation:
contexts/audio-context.tsx
// Commenting out automatic fadeout on navigation
// This allows audio to continue playing across pages
// useEffect(() => {
//   if (pathname !== '/') {
//     fadeOut(1500)
//   }
// }, [pathname])
The audio will continue playing even when navigating away from the home page. Users can manually pause using the floating audio button available on all pages.

Integration in Layout

The AudioProvider wraps the entire app in the root layout:
app/layout.tsx
export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider forcedTheme="light" attribute="class">
          <LoadingProvider>
            <AudioProvider> {/* Audio context wraps everything */}
              <Navigation />
              {children}
              <StickyFooter />
              <ScrollToTop />
              <FloatingMenuButton />
              <AudioPlayer /> {/* Floating audio control */}
            </AudioProvider>
          </LoadingProvider>
        </ThemeProvider>
      </body>
    </html>
  )
}

Usage in Components

Access audio controls from any component:
import { useAudioContext } from '@/contexts/audio-context'

export function MyComponent() {
  const { play, pause, fadeOut, fadeToVolume, toggle, isPlaying, isLoaded } = useAudioContext()
  
  return (
    <button onClick={() => fadeToVolume(0.5, 2000)}>
      Fade to 50% volume
    </button>
  )
}

States & Properties

isPlaying

Boolean indicating if audio is currently playing

isLoaded

Boolean indicating if audio file has loaded

hasUserConsent

Boolean indicating if user has granted playback permission

targetVolume

Ref storing the target volume level (0-1)

Performance Optimization

1

Request Animation Frame

Background detection uses RAF to batch DOM reads during scroll
2

Passive Event Listeners

Scroll listener uses { passive: true } for better performance
3

Cleanup

All intervals and event listeners properly cleaned up on unmount
4

Preloading

Audio set to preload='auto' for instant playback when user consents

Browser Compatibility

The audio system handles browser autoplay policies:
  • Chrome/Edge: Requires user interaction before unmuted playback
  • Safari: Requires user gesture to initiate audio context
  • Firefox: More lenient but still respects autoplay settings
Due to browser autoplay policies, audio will NOT start automatically. Users must click the audio button to grant consent and begin playback.

Build docs developers (and LLMs) love