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
cuesevent. - A click jumps to the chapter: within the scene playing it calls
engine.seek(), and otherwisegoTo()with the chapter's scene and time. - It also exports a check for chapter cues, so
validateStoryrejects a chapter without a title. This story passes it: no errors.
- 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