Skip to content

Chapters plugin ​

The story marks four chapters of the Artemis I launch with cue events of kind "chapter", each with its title in data. IVKit only reports which cues are active; a plugin does the rest:

  • It reads every chapter cue of the story and draws a button for each one in its layer, over the video.
  • It highlights the active chapter on each cues event.
  • A click jumps to the chapter: within the scene playing it calls engine.seek(), and otherwise goTo() with the chapter's scene and time.
  • It also exports a check for chapter cues, so validateStory rejects a chapter without a title. This story passes it: no errors.
  1. Events will show up here.

Video: NASA/Frank Michaux, Artemis I Slow Motion Liftoff. NASA footage is in the public domain; its use here doesn't imply NASA's endorsement.

Plugin ​

ts
import type { Cue, CueCheck, SceneId } from "@ivkit/core";
import type { PlayerPlugin } from "@ivkit/player";

/** Rejects chapter cues without a title, when the story is validated. */
export const checkChapter: CueCheck = (cue, report) => {
  const title = (cue["data"] as { title?: unknown } | undefined)?.title;
  if (typeof title !== "string" || title === "") {
    report("/data/title", "A chapter needs a title.");
  }
};

const css = /* css */ `
.chapters {
  position: absolute;
  top: 0.75rem;
  left: 0.75rem;
  right: 0.75rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.375rem;
}
.chapters button {
  pointer-events: auto;
  padding: 0.25rem 0.625rem;
  border: 0;
  border-radius: 999px;
  background: rgb(0 0 0 / 0.55);
  color: #fff;
  font: inherit;
  font-size: 0.8125rem;
  cursor: pointer;
}
.chapters button[aria-current="true"] {
  background: #fff;
  color: #000;
}
`;

/**
 * Shows every chapter cue of the story as a button, highlights the active one and
 * jumps to a chapter on click, in its own scene or in another one.
 */
export const chapters: PlayerPlugin = ({ player, engine, layer, on }) => {
  const list = Object.entries(engine.story.scenes).flatMap(([sceneId, scene]) =>
    (scene.events ?? [])
      .filter((event): event is Cue => event.type === "cue" && event.kind === "chapter")
      .map((cue) => ({ sceneId, cue })),
  );
  if (list.length === 0) return;

  const style = document.createElement("style");
  style.textContent = css;
  const nav = document.createElement("nav");
  nav.className = "chapters";
  nav.setAttribute("part", "chapters");
  nav.setAttribute("aria-label", "Chapters");
  const buttons = list.map(({ sceneId, cue }) => {
    const button = document.createElement("button");
    button.type = "button";
    button.setAttribute("part", "chapter");
    button.textContent = String(cue.data?.["title"]);
    button.addEventListener("click", () => jump(sceneId, cue.from));
    return button;
  });
  nav.append(...buttons);
  layer.append(style, nav);

  // Seeks within the scene playing; otherwise enters the chapter's scene at its time.
  const jump = (sceneId: SceneId, time: number) => {
    if (sceneId !== engine.currentSceneId || !engine.seek(time)) player.goTo(sceneId, time);
  };

  on("cues", ({ cues }) => {
    list.forEach(({ cue }, i) => {
      buttons[i]?.setAttribute("aria-current", String(cues.includes(cue)));
    });
  });
};

Story ​

ts
import type { Story } from "@ivkit/core";
import { video } from "./video";

/** The launch in two scenes, split into four chapters with cues. */
export const chaptered: Story = {
  version: "0.1",
  start: "pad",
  scenes: {
    pad: {
      src: video("launch-pad"),
      next: "liftoff",
      events: [
        { type: "cue", id: "pad", kind: "chapter", from: 0, to: 5, data: { title: "On the pad" } },
        { type: "cue", id: "engines", kind: "chapter", from: 5, data: { title: "Engines start" } },
      ],
    },
    liftoff: {
      src: video("launch-liftoff"),
      events: [
        { type: "cue", id: "liftoff", kind: "chapter", from: 0, to: 7, data: { title: "Liftoff" } },
        { type: "cue", id: "climb", kind: "chapter", from: 7, data: { title: "Climbing out" } },
      ],
    },
  },
};

Learn more: Cues and data for plugins · Plugins · Checking plugins' cues