carton.pm

Making a soundboard with React - Part 2

February 14, 2021

Recap from last episode: We made a component that plays a sound when you click on it, stops when you click again, and displays a sort of ugly progress bar.

The buttons could use some visual refinements, but they work 🤷‍♂️.

The goal today is to go from a single button to an actual soundboard, here's a non-contractual list of requirements (non-contractual because if something isn't fun to make, I won't make it eh.):

  • Have a responsive list of buttons
  • Add some settings
    • When a button is pressed, the other buttons stop playing
    • Volume control
  • Support adding buttons using sounds from the local system

That was pretty straightforward: I tweaked the SoundControl component to support children, and wrapped that in a SoundBoard component with some css flex attributes.

interface Props {
  children: React.ReactChildren
}

export const SoundBoard: React.FunctionComponent<Props> = ({ children }) => {
  return <div className="soundboard">{children}</div>
}

With a bit of css:

.soundboard {
  margin: auto;
  display: flex;
  flex-direction: row;
  max-width: 40rem;
  justify-content: center;
  flex-wrap: wrap;
}

Now add some farm animal sounds and you have a minimal soundboard!

Now let's add some settings, we'll start with the simplest one, a volume slider. One way we can do this is by adjusting the volume property on our <audio> tag.

There's a cool input type called "range" that is convenient to handle a volume bar:

Here's one way to use it:

<div className="settings-volume">
  <label htmlFor="volume">Volume</label>
  <input
    id="volume"
    name="volume"
    type="range"
    min={0}
    max={10}
    value={settingsState.volume}
    onChange={handleChange}
  />

The sound board with the new settings:

Settings

It was not obvious to me how to tell all the buttons to "stop" playing when another button is pressed. I found two ways to handle this:

  1. Defer the playing state to the parent component (the soundboard), which actually makes sense, but would have required centralizing the logic in the soundboard itself and then checking when the playing state changes to stop the playback. Instead of having the button itself control when it's playing, the parent component would pass a "playing" boolean which would determine if the button should be playing by looking a prop transitions. For example if isPlaying goes from true to false, then button is reset, and if isPlaying goes from false to true, then the button needs to start playing. That works, and is probably the most "react" approach

  2. My other idea, which is more fun, is to use the Custom Event API. When a button starts playing, it can broadcast a "stop" event to the other buttons. It's a worse approach, because it defers the logic of the soundboard to the buttons, which would eventually lead to all sorts of problems (for example that would make the buttons harder to test)

This #2 looks like this:

// Declaring the event and the "broadcast" operation
export const STOP = "stop";

export const stopAll = (elem: any) => {
  const event = new CustomEvent(STOP, { detail: elem });
  document.dispatchEvent(event);
};


// In the component, update the useEffect hook that controls playback

[..]

useEffect(() => {
  const el = audioRef.current
  if (!el) {
    return
  }

  el.addEventListener("play", () => setIsPlaying(true))
  el.addEventListener("pause", () => setIsPlaying(false))
  el.addEventListener("ended", () => setIsPlaying(false))

  // Cancel playing when ESC is pressed
  const keyUpListener = (e: any) => {
    if (e.keyCode === 27) {
      stop()
    }
  }
  const stopAllListeners = (e: any) => {
    if (e.detail !== audioRef.current) {
      stop()
    }
  }
  document.addEventListener("keyup", keyUpListener)
  document.addEventListener(STOP, stopAllListeners)

  // Remember to remove the listeners when the component is unmounted
  return () => {
    document.removeEventListener("keyup", keyUpListener)
    document.removeEventListener(STOP, stopAllListeners)
  }
}, [stop])
[..]

The code for the buttons is getting long, so I'm only showing the interesting bits.

We still can't add buttons dynamically, but it's taken longer than I meant to spend, so I'll save this for another post! I will probably also add a little bit of nginx config to show how simple it is to reverse proxy a react app on a VPS.