Source: fortune-cookie/script.js

import { pick, timeoutId, wait } from "../utils.js";
import { fortunes } from "./fortunes.js";

let previousFortune = "";
const fortuneButton = document.getElementById("fortune-button");
const fortunePaper = document.getElementById("fortune-paper");
const fortuneText = document.getElementById("fortune-text");
const fortuneAudioCrack = document.getElementById("fortune-crack");
const cookieWrapper = document.getElementById("cookie-wrapper");
const cookieButton = document.getElementById("cookie-button");
const cookieLeft = document.getElementById("fortune-image-left");
const background = document.getElementById("background");
const resetButton = document.getElementById("reset-button");
const cancelButton = document.getElementById("cancel-animation-btn");

let hasEnabledVoice = false;

document.addEventListener("click", () => {
  if (hasEnabledVoice) {
    return;
  }
  const lecture = new SpeechSynthesisUtterance("hello");
  lecture.volume = 0;
  speechSynthesis.speak(lecture);
  hasEnabledVoice = true;
});

/**
 * Stops the current animation and resets all animatable parts of the app to the
 * specified view (`state`).
 * @param {'fortune' | 'cookie'} state - Whether to set the view to showing the
 * fortune (`'fortune'`) or the cookie (`'cookie'`).
 */
function reset(state) {
  clearTimeout(timeoutId);
  fortuneAudioCrack.ontimeupdate = null;
  fortunePaper.onanimationend = null;
  fortuneAudioCrack.pause();
  window.speechSynthesis.cancel();
  fortunePaper.classList.remove("pull-out");
  document.body.classList.remove("dramatic-mode");
  cancelButton.parentElement.classList.remove("animating");
  cookieWrapper.classList.remove("cracked");
  fortuneText.style.transform = null;
  elem = null;
  cookieFalling = false;
  fortunePaper.style.transform = null;
  cookieButton.style.transform = null;
  cookieLeft.style.transform = null;
  if (state === "fortune") {
    fortunePaper.classList.add("reveal");
    cookieButton.classList.add("hide-cookie");
    resetButton.disabled = false;
    fortuneButton.disabled = true;
    cookieButton.disabled = true;
  } else if (state === "cookie") {
    fortunePaper.classList.remove("reveal");
    cookieButton.classList.remove("hide-cookie");
    resetButton.disabled = true;
    fortuneButton.disabled = false;
    cookieButton.disabled = false;
  }
}

resetButton.addEventListener("click", () => {
  if (prefersReducedMotion()) {
    reset("cookie");
    return;
  }
  resetButton.disabled = true;
  cookieButton.classList.remove("hide-cookie");
  cancelButton.parentElement.classList.add("animating");
  cancelButton.parentElement.classList.add("animating-new-cookie");
  fallFortune();
  fallNewCookie();
  wait(2000).then(handleCookieReady);
});

/**
 * This function will get a random fortune from the fortune array and make sure it does not match the previous one
 * @returns {string} a random fortune
 */
function getRandomFortune() {
  let fortune = pick(fortunes);

  // Generate a new random fortune if it matches the previous fortune
  while (fortune === previousFortune) {
    fortune = pick(fortunes);
  }

  previousFortune = fortune;
  return fortune;
}

/**
 * Reads out the fortune using speech synthesis
 * @param {string} fortune What the fortune to be read out is
 */
function speakFortune(fortune) {
  const speech = new SpeechSynthesisUtterance(fortune);
  speech.voice = voices[voiceSelect.value];
  speech.lang = "en-US";
  speech.rate = 0.8;
  speech.pitch = 1.2;
  window.speechSynthesis.speak(speech);

  // Reenable button when fortune is done being read
  speech.addEventListener("end", handleFortuneEnd);
}

/**
 * Disables button so user cannot click it
 */
function disableButton() {
  fortuneButton.disabled = true;
  cookieButton.disabled = true;
}

/**
 * A handler called whenever the animation for opening the fortune cookie ends.
 */
function handleFortuneEnd() {
  reset("fortune");
}

/**
 * A handler called whenever the animation for dropping a new fortune cookie
 * ends.
 */
function handleCookieReady() {
  reset("cookie");
}

/**
 * Sets the animation to make the left half of the cookie fall.
 */
function fallLeft() {
  elem = cookieLeft;
  x = 0;
  y = 0;
  xv = -0.25;
  yv = -0.4;
  rot = 0;
  rotv = -0.05;
  shakeIntensity = 10;
}
/**
 * Sets the animation to make the right half of the cookie fall.
 */
function fallRight() {
  elem = cookieButton;
  x = 0;
  y = 0;
  xv = 0.25;
  yv = 0;
  rot = 0;
  rotv = 0.05;
  shakeIntensity = 0;
}
/**
 * Sets the animation to make the fortune paper fall.
 */
function fallFortune() {
  elem = fortunePaper;
  x = 0;
  y = 0;
  xv = 0;
  yv = -0.4;
  rot = 0;
  rotv = -0.05;
}
/**
 * Sets the animation to make a new, full fortune cookie fall.
 */
function fallNewCookie() {
  cookieLeft.style.transform = null;
  cookieButton.style.transform = null;
  cookieY = -innerHeight / 2 - 300;
  cookieYV = 0;
  cookieFalling = true;
}

/**
 * Determines whether the user has `prefers-reduced-motion` enabled.
 * @returns {boolean} Whether to reduce motion.
 */
function prefersReducedMotion() {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

/**
 * When the user clicks the button, disables it so they cannot click the button
 * in quick succession and cause audio issues
 */
document.body.addEventListener("click", async function (event) {
  if (event.target.closest(".fortune-button")) {
    if (fortuneButton.disabled) {
      return;
    }
    if (prefersReducedMotion()) {
      reset("fortune");
      return;
    }
    document.body.classList.add("dramatic-mode");
    cancelButton.parentElement.classList.add("animating");
    cancelButton.parentElement.classList.remove("animating-new-cookie");
    disableButton();

    await wait(800);

    fortuneAudioCrack.currentTime = 0;
    fortuneAudioCrack.play();
    // Only make the cookie break when the audio is actually crunching
    // (this takes into account audio loading time)
    await new Promise((resolve) => {
      fortuneAudioCrack.ontimeupdate = () => {
        if (fortuneAudioCrack.currentTime > 0.3) {
          fortuneAudioCrack.ontimeupdate = null;
          resolve();
        }
      };
    });

    cookieWrapper.classList.add("cracked");
    fortuneText["data-i18n"] = getRandomFortune();
    fallLeft();

    await wait(1000);

    fortunePaper.classList.add("pull-out");

    wait(1500).then(() => {
      cookieLeft.style.display = null;
      fallRight();
    });

    await new Promise((resolve) => {
      fortunePaper.onanimationend = () => {
        fortunePaper.onanimationend = null;
        resolve();
      };
    });

    fortunePaper.classList.remove("pull-out");
    fortunePaper.classList.add("reveal");

    if (voiceSelect.value !== "none") {
      speakFortune(fortuneText.textContent);
    } else {
      wait(1000).then(handleFortuneEnd);
    }
  }
});

cancelButton.addEventListener("click", () => {
  if (cancelButton.parentElement.classList.contains("animating-new-cookie")) {
    handleCookieReady();
  } else {
    handleFortuneEnd();
  }
});

/**
 * The acceleration due to "gravity" applied on all falling objects in the
 * animation, in px/ms^2.
 * @type {number}
 */
const GRAVITY = 0.002;
let elem, x, y, xv, yv, rot, rotv;
/**
 * The decrease in shake intensity, in px/ms.
 * @type {number}
 */
const shakeV = -0.02;
let shakeIntensity;
let cookieY, cookieYV;
/**
 * Whether to animate a new cookie falling.
 * @type {boolean}
 */
let cookieFalling = false;

/**
 * The timestamp of the last time `paint` was called.
 * @type {number}
 */
let lastTime = Date.now();
/**
 * Draws the next frame of the cookie falling animation.
 */
function paint() {
  const now = Date.now();
  const elapsed = Math.min(now - lastTime, 200);
  lastTime = now;

  yv += GRAVITY * elapsed;
  x += xv * elapsed;
  y += yv * elapsed;
  rot += rotv * elapsed;
  if (elem) {
    if (elem === fortunePaper) {
      elem.style.transform = `translate(${x}px, ${y}px) rotate(${rot}deg) perspective(1000px) scale(0.5) translateZ(350px)`;
    } else {
      elem.style.transform = `translate(${x}px, ${y}px) rotate(${rot}deg)`;
    }
  }

  shakeIntensity = Math.max(0, shakeIntensity + shakeV * elapsed);
  if (shakeIntensity > 0) {
    const shake = `translate(${(Math.random() * 2 - 1) * shakeIntensity}px, ${
      (Math.random() * 2 - 1) * shakeIntensity
    }px)`;
    cookieWrapper.parentElement.style.transform = shake;
    background.style.transform = shake;
  } else {
    cookieWrapper.parentElement.style.transform = null;
    background.style.transform = null;
  }

  if (cookieFalling) {
    cookieYV += GRAVITY * elapsed;
    if (cookieYV > 10) {
      cookieYV = 10;
    }
    cookieY += cookieYV * elapsed;
    // Bounce from floor
    if (cookieY > 0) {
      cookieY = 0;
      cookieYV *= -0.4;
    }
    cookieButton.style.transform = `translateY(${cookieY}px)`;
  }

  window.requestAnimationFrame(paint);
}
paint();

/**
 * A reference to `window.speechSynthesis`.
 * @type {SpeechSynthesis}
 */
const synth = window.speechSynthesis;
/**
 * The voice selection dropdown.
 * @type {HTMLSelectElement}
 */
const voiceSelect = document.querySelector("select");
/**
 * A list of voices available by the browser.
 * @type {SpeechSynthesisVoice[]}
 */
let voices = [];

/**
 * Speech synthesis API, adds options for different voices to read out fortune using voice synthesis
 */
function populateVoiceList() {
  voices = synth.getVoices();

  let defaultVoice = voices[0]?.lang ?? "none";

  for (let i = 0; i < voices.length; i++) {
    const option = document.createElement("option");
    option.textContent = `${voices[i].name} (${voices[i].lang})`;
    option.value = i;

    if (voices[i].default) {
      option.textContent += " — DEFAULT";
      defaultVoice = i;
    }

    voiceSelect.appendChild(option);
  }

  voiceSelect.value = defaultVoice;
}

populateVoiceList();
if (speechSynthesis.onvoiceschanged !== undefined) {
  speechSynthesis.onvoiceschanged = populateVoiceList;
}