Last active
September 26, 2024 18:45
-
-
Save wking-io/bb9225a31d81ed7d48b46199e3742621 to your computer and use it in GitHub Desktop.
Hook for headless controls on an audio element
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>) { |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function Example() { | |
return ( | |
<AudioPlayer src={handle.mediaUrl}> | |
<AudioToggle> | |
<AudioPlaying>Pause</AudioPlaying> | |
<AudioPaused>Play</AudioPaused> | |
</AudioToggle> | |
</AudioPlayer> | |
) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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