@@ -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<HTMLDivElement>(null);
 | 
			
		||||
  const volumeRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<typeof requestAnimationFrame>;
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										62
									
								
								app/javascript/mastodon/hooks/useAudioContext.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								app/javascript/mastodon/hooks/useAudioContext.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,62 @@
 | 
			
		||||
import { useCallback, useEffect, useRef } from 'react';
 | 
			
		||||
 | 
			
		||||
interface AudioContextOptions {
 | 
			
		||||
  audioElementRef: React.MutableRefObject<HTMLAudioElement | null>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 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<AudioContext>();
 | 
			
		||||
  const sourceRef = useRef<MediaElementAudioSourceNode>();
 | 
			
		||||
  const gainNodeRef = useRef<GainNode>();
 | 
			
		||||
 | 
			
		||||
  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,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
@@ -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<HTMLAudioElement | null>,
 | 
			
		||||
  numBands: number,
 | 
			
		||||
) => {
 | 
			
		||||
  const audioContextRef = useRef<AudioContext>();
 | 
			
		||||
  const sourceRef = useRef<MediaElementAudioSourceNode>();
 | 
			
		||||
interface AudioVisualiserOptions {
 | 
			
		||||
  audioContextRef: React.MutableRefObject<AudioContext | undefined>;
 | 
			
		||||
  sourceRef: React.MutableRefObject<MediaElementAudioSourceNode | undefined>;
 | 
			
		||||
  numBands: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const useAudioVisualizer = ({
 | 
			
		||||
  audioContextRef,
 | 
			
		||||
  sourceRef,
 | 
			
		||||
  numBands,
 | 
			
		||||
}: AudioVisualiserOptions) => {
 | 
			
		||||
  const analyzerRef = useRef<AnalyserNode>();
 | 
			
		||||
 | 
			
		||||
  const [frequencyBands, setFrequencyBands] = useState<number[]>(
 | 
			
		||||
@@ -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;
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user