Latest coffee intake: 75mg of , 2 months ago.

Creating a Music Player with Dynamic Linear Background

2023/12

In the past week I have been working to perfect my music player at the bottom. Previous Post

Click me to jump to music player.

The problem

Initally the music player had a simple background that was a static linear gradient of two colors from left to right. This was fine, but boring, and did not always work out with different album covers. I wanted to implement something that was dynamic based on the colors of the album.

Extracting colors

This first step was to know what colors we will be working with. A simple search on npm.js gave me the extract-colors package, perfect for my use case.

Since I want the function to only run in the browser, I wrapped the call inside a useEffect that reruns whenever the album cover url changes.

I also filtered out black & white colors.

useEffect(() => {
  (async () => {
    if (!img) return;
    const colors = await extractColors(img, {
      crossOrigin: "anonymous",
      colorValidator(red, green, blue, alpha) {
        const s = red + green + blue;
        // prevent overwhite and overblack
        return 255 * 0.4 < s && s < 255 * 2.5;
      },
    });
    const toRGBA = (c: { red: number; green: number; blue: number }) => {
      const kred =
        ((c.red + c.green + c.blue) % 7) / 36 + (darkMode ? 0.9 : 0.39);
      const calc = (x: number) => Math.round(255 - (255 - x) * kred);
      let red = calc(c.red);
      let green = calc(c.green);
      let blue = calc(c.blue);
      // reduce the color of the lowest
      const min = Math.min(red, green, blue);
      // from 0.78 to 0.96
      const reduction = ((red + green + blue) % 8) / 45 + 0.78;
      if (min === red) red = Math.round(red * reduction);
      else if (min === green) green = Math.round(green * reduction);
      else if (min === blue) blue = Math.round(blue * reduction);
      return `rgba(${red},${green},${blue},0.9)`;
    };
    setColors(colors.slice(0, 5).map(toRGBA));
  })();
}, [img, darkMode]);

Now that I have my list of colors, I need to animate them in the background.

I have no prior experience with browser animations, so I used a simple yet dumb solution, combining CSS transitions with a setInterval.

interface RandomMovingGradientProps {
  background: string;
  className?: string;
}
function RandomMovingGradient({
  background,
  className,
}: RandomMovingGradientProps) {
  const random = () => {
    // get random number from -20 to 30, 70 to 120
    const r = Math.random();
    if (r < 0.5) return Math.random() * 60 - 30;
    else return Math.random() * 60 + 70;
  };
  // opacity should favor 0-0.2 and 0.8-1
  const opacity = () => {
    const r = Math.random();
    if (r < 0.2) return Math.random() * 0.3;
    else return Math.random() * 0.2 + 0.8;
  };
  const newSize = () => Math.random() * 200 + 500;
  const [width, setWidth] = useState(newSize());
  const [height, setHeight] = useState(newSize());
  const [pos, setPos] = useState({ x: random(), y: random() });
  const [show, setShow] = useState(opacity());
  const [i, _setInterval] = useState((Math.random() * 5 + 8) * 1000);

  const update = useCallback(() => {
    setPos({
      x: random(),
      y: random(),
    });
    setShow(opacity());
    setWidth(newSize());
    setHeight(newSize());
  }, []);

  useEffect(() => {
    setTimeout(update, 1000);
    const interval = setInterval(update, i);
    return () => clearInterval(interval);
  }, [i, update]);

  return (
    <div
      className={
        "absolute -z-20 rounded-full transition-all ease-in-out " + className
      }
      style={{
        left: `${pos.x}%`,
        top: `${pos.y}%`,
        transform: `translate(-50%, -50%)`,
        position: "absolute",
        transitionDuration: i + "ms",
        background: background,
        width: `${width}px`,
        height: `${height}px`,
        opacity: show,
      }}
    />
  );
}

Dumb, but effective.