diff --git a/app/javascript/mastodon/features/audio/index.tsx b/app/javascript/mastodon/features/audio/index.tsx index 751af51b81..dd6fef07d9 100644 --- a/app/javascript/mastodon/features/audio/index.tsx +++ b/app/javascript/mastodon/features/audio/index.tsx @@ -17,6 +17,7 @@ import { Blurhash } from 'mastodon/components/blurhash'; import { Icon } from 'mastodon/components/icon'; import { SpoilerButton } from 'mastodon/components/spoiler_button'; import { formatTime, getPointerPosition } from 'mastodon/features/video'; +import { useAudioContext } from 'mastodon/hooks/useAudioContext'; import { useAudioVisualizer } from 'mastodon/hooks/useAudioVisualizer'; import { displayMedia, @@ -119,12 +120,17 @@ export const Audio: React.FC<{ const seekRef = useRef(null); const volumeRef = useRef(null); const hoverTimeoutRef = useRef | null>(); - const [resumeAudio, suspendAudio, frequencyBands] = useAudioVisualizer( - audioRef, - 3, - ); const accessibilityId = useId(); + const { audioContextRef, sourceRef, gainNodeRef, playAudio, pauseAudio } = + useAudioContext({ audioElementRef: audioRef }); + + const frequencyBands = useAudioVisualizer({ + audioContextRef, + sourceRef, + numBands: 3, + }); + const [style, spring] = useSpring(() => ({ progress: '0%', buffer: '0%', @@ -152,6 +158,9 @@ export const Audio: React.FC<{ restoreVolume(audioRef.current); setVolume(audioRef.current.volume); setMuted(audioRef.current.muted); + if (gainNodeRef.current) { + gainNodeRef.current.gain.value = audioRef.current.volume; + } void spring.start({ volume: `${audioRef.current.volume * 100}%`, immediate: reduceMotion, @@ -159,15 +168,14 @@ export const Audio: React.FC<{ } }, [ - spring, - setVolume, - setMuted, + deployPictureInPicture, src, poster, backgroundColor, - accentColor, foregroundColor, - deployPictureInPicture, + accentColor, + gainNodeRef, + spring, ], ); @@ -178,7 +186,11 @@ export const Audio: React.FC<{ audioRef.current.volume = volume; audioRef.current.muted = muted; - }, [volume, muted]); + + if (gainNodeRef.current) { + gainNodeRef.current.gain.value = muted ? 0 : volume; + } + }, [volume, muted, gainNodeRef]); useEffect(() => { if (typeof visible !== 'undefined') { @@ -192,11 +204,10 @@ export const Audio: React.FC<{ }, [visible, sensitive]); useEffect(() => { - if (!revealed && audioRef.current) { - audioRef.current.pause(); - suspendAudio(); + if (!revealed) { + pauseAudio(); } - }, [suspendAudio, revealed]); + }, [pauseAudio, revealed]); useEffect(() => { let nextFrame: ReturnType; @@ -228,13 +239,11 @@ export const Audio: React.FC<{ } if (audioRef.current.paused) { - resumeAudio(); - void audioRef.current.play(); + playAudio(); } else { - audioRef.current.pause(); - suspendAudio(); + pauseAudio(); } - }, [resumeAudio, suspendAudio]); + }, [playAudio, pauseAudio]); const handlePlay = useCallback(() => { setPaused(false); @@ -349,8 +358,7 @@ export const Audio: React.FC<{ document.removeEventListener('mouseup', handleSeekMouseUp, true); setDragging(false); - resumeAudio(); - void audioRef.current?.play(); + playAudio(); }; const handleSeekMouseMove = (e: MouseEvent) => { @@ -377,7 +385,7 @@ export const Audio: React.FC<{ e.preventDefault(); e.stopPropagation(); }, - [setDragging, spring, resumeAudio], + [playAudio, spring], ); const handleMouseEnter = useCallback(() => { @@ -446,10 +454,9 @@ export const Audio: React.FC<{ const handleCanPlayThrough = useCallback(() => { if (startPlaying) { - resumeAudio(); - void audioRef.current?.play(); + playAudio(); } - }, [startPlaying, resumeAudio]); + }, [startPlaying, playAudio]); const seekBy = (time: number) => { if (!audioRef.current) { @@ -492,7 +499,7 @@ export const Audio: React.FC<{ return; } - const newVolume = audioRef.current.volume + step; + const newVolume = Math.max(0, audioRef.current.volume + step); if (!isNaN(newVolume)) { audioRef.current.volume = newVolume; diff --git a/app/javascript/mastodon/hooks/useAudioContext.ts b/app/javascript/mastodon/hooks/useAudioContext.ts new file mode 100644 index 0000000000..84acf5ac7f --- /dev/null +++ b/app/javascript/mastodon/hooks/useAudioContext.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useRef } from 'react'; + +interface AudioContextOptions { + audioElementRef: React.MutableRefObject; +} + +/** + * Create and return an audio context instance for a given audio element [0]. + * Also returns an associated audio source, a gain node, and play and pause actions + * which should be used instead of `audioElementRef.current.play/pause()`. + * + * [0] https://developer.mozilla.org/en-US/docs/Web/API/AudioContext + */ + +export const useAudioContext = ({ audioElementRef }: AudioContextOptions) => { + const audioContextRef = useRef(); + const sourceRef = useRef(); + const gainNodeRef = useRef(); + + useEffect(() => { + if (!audioElementRef.current) { + return; + } + + const context = audioContextRef.current ?? new AudioContext(); + const source = + sourceRef.current ?? + context.createMediaElementSource(audioElementRef.current); + + const gainNode = context.createGain(); + gainNode.connect(context.destination); + source.connect(gainNode); + + audioContextRef.current = context; + gainNodeRef.current = gainNode; + sourceRef.current = source; + + return () => { + if (context.state !== 'closed') { + void context.close(); + } + }; + }, [audioElementRef]); + + const playAudio = useCallback(() => { + void audioElementRef.current?.play(); + void audioContextRef.current?.resume(); + }, [audioElementRef]); + + const pauseAudio = useCallback(() => { + audioElementRef.current?.pause(); + void audioContextRef.current?.suspend(); + }, [audioElementRef]); + + return { + audioContextRef, + sourceRef, + gainNodeRef, + playAudio, + pauseAudio, + }; +}; diff --git a/app/javascript/mastodon/hooks/useAudioVisualizer.ts b/app/javascript/mastodon/hooks/useAudioVisualizer.ts index 50111a81d7..efc0647d8d 100644 --- a/app/javascript/mastodon/hooks/useAudioVisualizer.ts +++ b/app/javascript/mastodon/hooks/useAudioVisualizer.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef } from 'react'; const normalizeFrequencies = (arr: Float32Array): number[] => { return new Array(...arr).map((value: number) => { @@ -10,12 +10,17 @@ const normalizeFrequencies = (arr: Float32Array): number[] => { }); }; -export const useAudioVisualizer = ( - ref: React.MutableRefObject, - numBands: number, -) => { - const audioContextRef = useRef(); - const sourceRef = useRef(); +interface AudioVisualiserOptions { + audioContextRef: React.MutableRefObject; + sourceRef: React.MutableRefObject; + numBands: number; +} + +export const useAudioVisualizer = ({ + audioContextRef, + sourceRef, + numBands, +}: AudioVisualiserOptions) => { const analyzerRef = useRef(); const [frequencyBands, setFrequencyBands] = useState( @@ -23,47 +28,31 @@ export const useAudioVisualizer = ( ); useEffect(() => { - if (!audioContextRef.current) { - audioContextRef.current = new AudioContext(); + if (audioContextRef.current) { analyzerRef.current = audioContextRef.current.createAnalyser(); analyzerRef.current.smoothingTimeConstant = 0.6; analyzerRef.current.fftSize = 2048; } - - return () => { - if (audioContextRef.current) { - void audioContextRef.current.close(); - } - }; - }, []); + }, [audioContextRef]); useEffect(() => { - if ( - audioContextRef.current && - analyzerRef.current && - !sourceRef.current && - ref.current - ) { - sourceRef.current = audioContextRef.current.createMediaElementSource( - ref.current, - ); + if (analyzerRef.current && sourceRef.current) { sourceRef.current.connect(analyzerRef.current); - sourceRef.current.connect(audioContextRef.current.destination); } + const currentSource = sourceRef.current; return () => { - if (sourceRef.current) { - sourceRef.current.disconnect(); + if (currentSource && analyzerRef.current) { + currentSource.disconnect(analyzerRef.current); } }; - }, [ref]); + }, [audioContextRef, sourceRef]); useEffect(() => { - const source = sourceRef.current; const analyzer = analyzerRef.current; const context = audioContextRef.current; - if (!source || !analyzer || !context) { + if (!analyzer || !context) { return; } @@ -94,19 +83,7 @@ export const useAudioVisualizer = ( return () => { clearInterval(updateInterval); }; - }, [numBands]); + }, [numBands, audioContextRef]); - const resume = useCallback(() => { - if (audioContextRef.current) { - void audioContextRef.current.resume(); - } - }, []); - - const suspend = useCallback(() => { - if (audioContextRef.current) { - void audioContextRef.current.suspend(); - } - }, []); - - return [resume, suspend, frequencyBands] as const; + return frequencyBands; };