chromium/ash/webui/camera_app_ui/resources/js/sound.ts

// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {assert, assertExists} from './assert.js';
import {AsyncJobInfo, AsyncJobQueue} from './async_job_queue.js';
import {expandPath} from './util.js';
import {WaitableEvent} from './waitable_event.js';

const SOUND_FILES = new Map([
  ['recordEnd', 'record_end.ogg'],
  ['recordPause', 'record_pause.ogg'],
  ['recordStart', 'record_start.ogg'],
  ['shutter', 'shutter.ogg'],
  ['tickFinal', 'tick_final.ogg'],
  ['tickIncrement', 'tick_inc.ogg'],
  ['tickStart', 'tick_start.ogg'],
] as const);

type SoundKey = typeof SOUND_FILES extends Map<infer K, unknown>? K : never;

interface SoundInfo {
  element: HTMLAudioElement;
  queue: AsyncJobQueue;
}

let soundInfoMap: Map<SoundKey, SoundInfo>|null = null;

/**
 * Preloads all sounds used in CCA.
 *
 * This should only be called once in main.ts.
 */
export function preloadSounds(): void {
  assert(soundInfoMap === null, 'preloadSounds should only be called once');
  soundInfoMap = new Map();
  for (const [key, filename] of SOUND_FILES.entries()) {
    soundInfoMap.set(key, {
      element: new Audio(expandPath(`/sounds/${filename}`)),
      queue: new AsyncJobQueue('keepLatest'),
    });
  }
}

function getSoundInfo(key: SoundKey): SoundInfo {
  assert(soundInfoMap !== null, 'preloadSounds should be called before this');
  return assertExists(soundInfoMap.get(key));
}

/**
 * Plays a sound.
 *
 * @param key Sound to play.
 * @return The async job which will be resolved once the sound is stopped.
 */
export function play(key: SoundKey): AsyncJobInfo {
  cancel(key);

  const {element: el, queue} = getSoundInfo(key);
  return queue.push(async () => {
    el.currentTime = 0;
    try {
      await el.play();
    } catch (e) {
      if (e instanceof DOMException && e.name === 'AbortError') {
        // playing is cancelled.
        return;
      }
      throw e;
    }

    const audioStopped = new WaitableEvent();
    const events = ['ended', 'pause'];
    function onAudioStopped() {
      audioStopped.signal();
      for (const event of events) {
        el.removeEventListener(event, onAudioStopped);
      }
    }
    for (const event of events) {
      el.addEventListener(event, onAudioStopped);
    }
    await audioStopped.wait();
  });
}

/**
 * Cancel a sound from playing.
 *
 * @param key Sound to cancel.
 */
export function cancel(key: SoundKey): void {
  getSoundInfo(key).element.pause();
}