Skip to content

Instantly share code, notes, and snippets.

@wking-io
Last active September 26, 2024 18:45
Show Gist options
  • Save wking-io/bb9225a31d81ed7d48b46199e3742621 to your computer and use it in GitHub Desktop.
Save wking-io/bb9225a31d81ed7d48b46199e3742621 to your computer and use it in GitHub Desktop.
Hook for headless controls on an audio element
import {
createContext,
type PropsWithChildren,
useContext,
type RefObject,
} from 'react'
import { useHeadlessAudio } from '#app/hooks/useHeadlessAudio.js'
import { type PropsWithClassName } from '#app/types.js'
type AudioPlayerState = {
isPlaying: boolean
currentTime: number
totalTime: number
playbackSpeed: number
volume: number
audioRef: RefObject<HTMLAudioElement>
toggleAudio(): void
changeSpeed(speed: number): void
changeVolume(volume: number): void
}
/**
* A critical part of the Compound Component pattern is having a shared context that all the
* components can use to communicate with each other.
*/
const AudioPlayerContext = createContext<AudioPlayerState | null>(null)
AudioPlayerContext.displayName = 'AudioPlayerContext'
/**
* Fun Trick: The error in this function makes sure to restrict the use of Compound Components
* to only be used within the <StatusButton /> component. Throws a helpful message when you don't.
*/
function useAudioPlayerContext(component: string) {
let context = useContext(AudioPlayerContext)
if (context === null) {
let err = new Error(
`<${component} /> is missing a parent <StatusButton /> component.`,
)
if (Error.captureStackTrace)
Error.captureStackTrace(err, useAudioPlayerContext)
throw err
}
return context
}
export function AudioPlayer({
src,
children,
}: PropsWithChildren<{ src: string }>) {
const {
audioRef,
toggleAudio,
isPlaying,
currentTime,
totalTime,
playbackSpeed,
volume,
changeSpeed,
changeVolume,
} = useHeadlessAudio()
return (
<AudioPlayerContext.Provider
value={{
isPlaying,
currentTime,
totalTime,
playbackSpeed,
volume,
audioRef,
toggleAudio,
changeSpeed,
changeVolume,
}}
>
<audio src={src} controls className="sr-only" ref={audioRef} />
{children}
</AudioPlayerContext.Provider>
)
}
export function AudioToggle({
className,
children,
}: PropsWithChildren<PropsWithClassName>) {
function Example() {
return (
<AudioPlayer src={handle.mediaUrl}>
<AudioToggle>
<AudioPlaying>Pause</AudioPlaying>
<AudioPaused>Play</AudioPaused>
</AudioToggle>
</AudioPlayer>
)
}
import { useRef, useState } from 'react'
export function useHeadlessAudio() {
const audioRef = useRef<HTMLAudioElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [playbackSpeed, setPlaybackSpeed] = useState(1)
const [volume, setVolume] = useState(0.5)
// Update current time as the audio plays
const handleTimeUpdate = () => {
if (audioRef.current) {
setCurrentTime(audioRef.current.currentTime)
}
}
// Set duration when audio metadata is loaded
const handleLoadedMetadata = () => {
if (audioRef.current) {
setDuration(audioRef.current.duration)
}
}
const toggleAudio = () => {
setIsPlaying((prev) => {
if (!audioRef.current) return prev
if (prev) {
audioRef.current.pause()
} else {
audioRef.current.play()
}
return !prev
})
}
// Handle playback speed change
const changeSpeed = (speed: number) => {
if (audioRef.current) {
audioRef.current.playbackRate = speed
setPlaybackSpeed(speed)
}
}
const changeVolume = (volume: number) => {
if (audioRef.current) {
const adjustedVolume = Math.min(Math.max(0, volume), 1)
audioRef.current.playbackRate = adjustedVolume
setVolume(adjustedVolume)
}
}
// Calculate progress percentage
const progress = (currentTime / duration) * 100
return {
audioRef,
isPlaying,
currentTime,
volume,
setVolume,
totalTime: duration,
toggleAudio,
handleTimeUpdate,
handleLoadedMetadata,
changeSpeed,
changeVolume,
progress,
playbackSpeed,
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment