Skip to the content

Capsid menu

app/enhance/podcast.ts

94 lines and 13 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of podcast.ts
FunctionLinesComplexityCoveredCRAPMutation
duration12 to 134No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
position21 to 212No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
paint23 to 314No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
paintButton33 to 352No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
seekTo42 to 675No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:51>51 to 552No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:73>73 to 762No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:74>74 to 741No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:79>79 to 791No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:82>82 to 851No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:86>86 to 891No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:91>91 to 951No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:100>100 to 1031No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code

Imports

It imports app/lib/podcast/clock.mjs. No module of this repo imports it.

Source

import { clockTime } from "~/lib/podcast/clock.mjs";

for (const root of document.querySelectorAll<HTMLElement>("[data-podcast]")) {
  const audio = root.querySelector<HTMLAudioElement>(".podcast-audio");
  const controls = root.querySelector<HTMLElement>("[data-podcast-controls]");
  const play = root.querySelector<HTMLButtonElement>("[data-podcast-play]");
  const seek = root.querySelector<HTMLInputElement>("[data-podcast-seek]");
  const elapsed = root.querySelector<HTMLElement>("[data-podcast-elapsed]");
  const total = root.querySelector<HTMLElement>("[data-podcast-total]");
  if (!audio || !controls || !play || !seek || !elapsed || !total) continue;

  const duration = () =>
    Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : Number(seek.max) || 0;

  let dragging = false;
  /**
   * A seek asked for before the metadata has loaded. The element cannot take a position yet, so the
   * slider and clock show this one instead of snapping back to 0:00, and a second skip adds to it.
   */
  let pending: number | null = null;
  const position = () => pending ?? audio.currentTime;

  const paint = () => {
    const now = position();
    if (!dragging) seek.value = String(Math.floor(now));
    elapsed.textContent = clockTime(dragging ? Number(seek.value) : now);
    seek.setAttribute(
      "aria-valuetext",
      `${clockTime(dragging ? Number(seek.value) : now)} of ${clockTime(duration())}`,
    );
  };

  const paintButton = () => {
    play.textContent = audio.paused ? "Play" : "Pause";
  };

  /**
   * A seek before the metadata has loaded is held and applied once it has. `preload="none"` means
   * nothing is fetched until asked, so the first held seek asks for the metadata; a load already
   * under way (a Play just pressed) is left alone, since `load()` would abort it.
   */
  const seekTo = (seconds: number) => {
    const target = Math.min(Math.max(0, seconds), duration() || seconds);
    if (audio.readyState >= 1) {
      pending = null;
      audio.currentTime = target;
    } else {
      if (pending === null) {
        audio.addEventListener(
          "loadedmetadata",
          () => {
            if (pending !== null) audio.currentTime = pending;
            pending = null;
            paint();
          },
          { once: true },
        );
        if (audio.networkState !== audio.NETWORK_LOADING) {
          audio.preload = "metadata";
          audio.load();
        }
      }
      pending = target;
    }
    seek.value = String(Math.floor(target));
    paint();
  };

  audio.controls = false;
  controls.hidden = false;
  paint();

  play.addEventListener("click", () => {
    if (audio.paused) void audio.play().catch(() => paintButton());
    else audio.pause();
  });
  for (const button of root.querySelectorAll<HTMLButtonElement>("[data-podcast-skip]")) {
    const delta = Number(button.dataset.podcastSkip);
    button.addEventListener("click", () => seekTo(position() + delta));
  }

  seek.addEventListener("input", () => {
    dragging = true;
    paint();
  });
  seek.addEventListener("change", () => {
    dragging = false;
    seekTo(Number(seek.value));
  });

  audio.addEventListener("loadedmetadata", () => {
    seek.max = String(Math.floor(duration()));
    total.textContent = clockTime(duration());
    paint();
  });
  audio.addEventListener("timeupdate", paint);
  audio.addEventListener("play", paintButton);
  audio.addEventListener("pause", paintButton);
  audio.addEventListener("ended", paintButton);
  audio.addEventListener("error", () => {
    controls.hidden = true;
    audio.controls = true;
  });
}