sparkletown/sparkle

View on GitHub
src/hooks/sounds.tsx

Summary

Maintainability
B
6 hrs
Test Coverage
import React, {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useState,
} from "react";
import Bugsnag from "@bugsnag/js";
import { HowlOptions } from "howler";
import useSound from "use-sound";
import {
  ExposedData,
  HookOptions,
  PlayFunction,
  PlayOptions,
} from "use-sound/dist/types";

import { fetchSoundConfigs } from "api/sounds";

import { SoundConfigMap, SoundConfigReference } from "types/sounds";

import { isDefined } from "utils/types";

import { useUser } from "hooks/useUser";

export type PlaySpriteFunction = (options?: PlaySpriteOptions) => void;
export type PlaySpriteOptions = Omit<PlayOptions, "id">;

export type UseCustomSoundOptions = HookOptions & HowlOptions;

export interface ExposedDataWithPlay extends ExposedData {
  play: PlayFunction;
}

export interface CustomSoundsState {
  soundConfigs: SoundConfigMap;
  isLoading: boolean;
}

export const initialValue: CustomSoundsState = {
  soundConfigs: {},
  isLoading: false,
};

export const CustomSoundsContext = createContext<CustomSoundsState>(
  initialValue
);

export interface CustomSoundsProviderProps {
  waitTillConfigLoaded?: boolean;
  loadingComponent?: React.ReactNode;
}

export const CustomSoundsProvider: React.FC<CustomSoundsProviderProps> = ({
  loadingComponent = "Loading...",
  waitTillConfigLoaded = false,
  children,
}) => {
  const [soundConfigs, setSoundConfigs] = useState<SoundConfigMap>(
    initialValue.soundConfigs
  );
  const [isLoading, setIsLoading] = useState<boolean>(initialValue.isLoading);

  const { user } = useUser();
  const userId = user?.uid;

  // Fetch the sound configs data on first load
  useEffect(() => {
    if (!userId) return;

    setIsLoading(true);
    fetchSoundConfigs()
      .then(setSoundConfigs)
      .finally(() => setIsLoading(false));
  }, [userId]);

  const providerData = useMemo(
    () => ({
      soundConfigs,
      isLoading,
    }),
    [soundConfigs, isLoading]
  );

  return (
    <CustomSoundsContext.Provider value={providerData}>
      {waitTillConfigLoaded && isLoading ? loadingComponent : children}
    </CustomSoundsContext.Provider>
  );
};

export const useCustomSoundsContext = (): CustomSoundsState =>
  useContext(CustomSoundsContext);

const USE_SOUND_DISABLED_URL = "";
const USE_SOUND_DISABLED_CONFIG = { soundEnabled: false };

/**
 * Load a custom sound using a SoundConfigReference, and expose controls to play it/a sprite within it.
 *
 * @example
 *   const [play] = useCustomSound({ soundId: "ABC123" });
 *
 * @example
 *   const [play] = useCustomSound({
 *     soundId: "ABC123",
 *     spriteName: "somethingSnazzy",
 *   });
 *
 * @param soundRef
 * @param options see use-sound's HookOptions
 *
 * @see https://github.com/joshwcomeau/use-sound
 * @see HookOptions (from use-sound)
 * @see ReturnedValue (from use-sound)
 * @see PlayFunction (from use-sound)
 * @see ExposedData (from use-sound)
 *
 * @see https://github.com/goldfire/howler.js
 * @see HowlOptions
 */
export const useCustomSound = (
  soundRef?: string | SoundConfigReference,
  options?: UseCustomSoundOptions
): [PlaySpriteFunction, ExposedDataWithPlay] => {
  const { soundId, spriteName } = (() => {
    switch (typeof soundRef) {
      case "string":
        return { soundId: soundRef, spriteName: undefined };

      // This case won't actually allow us to play any sounds, and is only here to
      // work around the inability to conditionally render hooks
      case "undefined":
        return { soundId: "", spriteName: undefined };

      default:
        return soundRef;
    }
  })();

  const { onend } = options ?? {};

  // Fetch all of our loaded sound configs
  const { soundConfigs } = useCustomSoundsContext();

  // Try to access just the one we want here
  const soundConfig = soundConfigs[soundId];

  const sprites = soundConfig?.sprites;

  const hasSoundRef = isDefined(soundRef);
  const hasSoundConfig = isDefined(soundConfig);
  const hasSprites = isDefined(sprites);
  const wantsSprites = isDefined(spriteName);
  // @debt we don't use hasSprites / wantsSprites here as TypeScript then seems to think spriteName can be undefined still
  //   see https://github.com/microsoft/TypeScript/issues/12798#issuecomment-800824801
  const wantedSpriteExists =
    sprites && spriteName ? sprites.hasOwnProperty(spriteName) : false;
  const enableSprites = hasSprites && wantsSprites;

  // We must both haveSprites && wantSprites or not have either for the config to be valid
  const hasValidSpriteConfig =
    (enableSprites && wantedSpriteExists) || (!hasSprites && !wantsSprites);

  // Track when we don't find the soundConfig that corresponds to soundRef. This will probably
  // just mean that there is some stale/broken reference in our firestore DB. The app shouldn't
  // break, as we should gracefully fall back to not playing any sound here in this case.
  useEffect(() => {
    if (hasSoundRef && !hasSoundConfig) {
      const msg = "[useCustomSound] Unable to find matching soundConfig";
      const context = {
        location: "hooks::sounds::useCustomSound",
        soundRef,
        soundConfigsKeys: Object.keys(soundConfigs),
      };

      console.warn(msg, context);
      Bugsnag.notify(msg, (event) => {
        event.severity = "warning";
        event.addMetadata("context", context);
      });
    }
  }, [hasSoundConfig, hasSoundRef, soundConfigs, soundRef]);

  // Track when soundConfig.sprites doesn't contain soundRef.spriteName. This will probably
  // just mean that there is some stale/broken reference in our firestore DB. The app shouldn't
  // break, as we should gracefully fall back to not playing any sound here in this case.
  useEffect(() => {
    if (hasSprites && wantsSprites && !wantedSpriteExists) {
      const msg =
        "[useCustomSound] requested sprite missing from soundConfig.sprites";
      const context = {
        location: "hooks::sounds::useCustomSound",
        soundRef,
        spritesKeys: sprites ? Object.keys(sprites) : [],
      };

      console.warn(msg, context);
      Bugsnag.notify(msg, (event) => {
        event.severity = "warning";
        event.addMetadata("context", context);
      });
    }
  }, [
    hasSprites,
    soundRef,
    spriteName,
    sprites,
    wantedSpriteExists,
    wantsSprites,
  ]);

  // Track when "hasSprites but not wantsSprites", or when we "wantsSprites but not hasSprites".
  // This will probably just mean that there is some stale/broken reference in our firestore DB.
  // The app shouldn't break, as we should gracefully fall back to not playing any sound here in this case.
  //
  // @debt we don't use hasSoundRef / hasSoundConfig here as TypeScript then seems to think that they can be undefined still
  //   see https://github.com/microsoft/TypeScript/issues/12798#issuecomment-800824801
  useEffect(() => {
    if (
      isDefined(soundRef) &&
      isDefined(soundConfig) &&
      !hasValidSpriteConfig
    ) {
      const msg = "[useCustomSound] invalid sprite configuration";
      const context = {
        location: "hooks::sounds::useCustomSound",
        soundRef,
        sprites,
        hasSprites,
        wantsSprites,
      };

      console.warn(msg, context);
      Bugsnag.notify(msg, (event) => {
        event.severity = "warning";
        event.addMetadata("context", context);
      });
    }
  }, [
    hasSprites,
    hasValidSpriteConfig,
    soundConfig,
    soundRef,
    sprites,
    wantsSprites,
  ]);

  const soundUrl = hasValidSpriteConfig
    ? soundConfig?.url ?? USE_SOUND_DISABLED_URL
    : USE_SOUND_DISABLED_URL;

  const optionsWithSprites: UseCustomSoundOptions = {
    ...options,
    // Only define the sprites when we're requesting to play a sprite (works around a Howler error)
    sprite: enableSprites ? soundConfig?.sprites : undefined,
  };

  // If we didn't find a matching sound config then force the sound to be disabled
  const optionsWithExtras: UseCustomSoundOptions = hasSoundConfig
    ? optionsWithSprites
    : USE_SOUND_DISABLED_CONFIG;

  // @debt Figure out a nicer way to handle conditionally calling this hook when we don't have a valid config
  const [play, exposedData] = useSound(soundUrl, optionsWithExtras);

  /**
   * Wrap use-sound's play function so that it plays the SoundConfigReference.spriteId sprite.
   *
   * @param options
   */
  const playSprite: PlaySpriteFunction = useCallback(
    (options = {}) => {
      play({ ...options, id: spriteName });

      // This works around the fact that onend isn't called when we disable the sound playback,
      // yet it can be useful to use onend to trigger functionality (eg. changing rooms) after
      // the sound playback has finished.
      if (!hasSoundConfig || !hasValidSpriteConfig) {
        onend && onend(-1);
      }
    },
    [play, spriteName, hasSoundConfig, hasValidSpriteConfig, onend]
  );

  return [playSprite, { ...exposedData, play }];
};