@docyrus/animaguru 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -1
- package/dist/animaguru.js +581 -268
- package/package.json +1 -1
- package/skills/animaguru/SKILL.md +16 -0
- package/skills/animaguru/references/sharing.md +4 -1
- package/skills/animaguru-branching-story/SKILL.md +72 -0
- package/skills/animaguru-general-video/SKILL.md +1 -0
- package/skills/animaguru-general-video/references/branching-story.md +13 -0
- package/skills/animaguru-quiz/SKILL.md +80 -0
- package/skills/animaguru-video/SKILL.md +16 -2
- package/skills/animaguru-video/references/short-form-ad.md +1 -0
- package/templates/video/AGENTS.md +2 -0
- package/templates/video/README.md +8 -0
- package/templates/video/WORKSPACE-AGENTS.md +3 -1
- package/templates/video/index.html +1 -1
- package/templates/video/scripts/build.mjs +4 -1
- package/templates/video/scripts/export-mp4.mjs +5 -0
- package/templates/video/scripts/html-export.mjs +12 -4
- package/templates/video/scripts/project-context.mjs +20 -3
- package/templates/video/scripts/quiz.d.mts +31 -0
- package/templates/video/scripts/quiz.mjs +126 -0
- package/templates/video/scripts/review.mjs +9 -1
- package/templates/video/scripts/story.d.mts +36 -0
- package/templates/video/scripts/story.mjs +139 -0
- package/templates/video/scripts/workspace-plugin.mjs +9 -4
- package/templates/video/shared/locales/en/player.json +30 -1
- package/templates/video/src/kit/index.ts +1 -0
- package/templates/video/src/kit/story.ts +19 -0
- package/templates/video/src/main.ts +6 -1
- package/templates/video/src/player-types.ts +6 -0
- package/templates/video/src/player.ts +232 -21
- package/templates/video/src/quiz.ts +315 -0
- package/templates/video/src/story.ts +112 -0
- package/templates/video/src/style.css +93 -1
|
@@ -5,9 +5,11 @@ import { publicAsset } from "./assets";
|
|
|
5
5
|
import { downloadHtml, downloadUrl } from "./download";
|
|
6
6
|
import { loadFonts } from "./kit/fonts";
|
|
7
7
|
import type { ResourceTimeline } from "./resource-timeline";
|
|
8
|
+
import { StoryMap, StoryOverlay, type StorySegment, type StoryView } from "./story";
|
|
9
|
+
import { QuizOverlay, QuizRun, quizCopy } from "./quiz";
|
|
8
10
|
import "./style.css";
|
|
9
11
|
|
|
10
|
-
export async function mountPlayer({ config, Film, chapters, branding, exportEndpoint, templateLabel }: PlayerOptions) {
|
|
12
|
+
export async function mountPlayer({ config, Film, chapters, branding, exportEndpoint, templateLabel, story: storyGraph, quiz: quizData }: PlayerOptions) {
|
|
11
13
|
const root = document.querySelector<HTMLDivElement>("#app")!;
|
|
12
14
|
// All variable copy is inserted as text, including agent-authored film titles.
|
|
13
15
|
root.innerHTML = `<header class="masthead"><a class="brand" href="https://animaguru.com" target="_blank" rel="noreferrer"><img src="${publicAsset("brand/logo-mark.svg")}" alt=""/><strong>AnimaGuru</strong><span data-copy="studio"></span></a><span class="template" data-copy="template"></span></header>
|
|
@@ -31,6 +33,24 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
31
33
|
const capture = params.has("capture");
|
|
32
34
|
const exporting = !embed && params.has("export");
|
|
33
35
|
const autoplay = embed && params.get("autoplay") === "1" && !matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
36
|
+
// A branching story plays one segment at a time and asks the viewer at its choices. Capture and review see its whole
|
|
37
|
+
// timeline as one film; an MP4 has one path, so a story never exports one.
|
|
38
|
+
let story: StoryMap | null = null;
|
|
39
|
+
// A quiz pauses the film at its checkpoints and follows its end with a final round and the score. Capture and review
|
|
40
|
+
// see the film without it; its questions wait for a viewer's answers, so a quiz never exports an MP4. visited: the story segments played since the last restart, whose questions count.
|
|
41
|
+
let quiz: QuizRun | null = null;
|
|
42
|
+
const visited = new Set<string>();
|
|
43
|
+
if (storyGraph || quizData) {
|
|
44
|
+
document.querySelector<HTMLElement>('[data-copy="exportHint"]')!.textContent = storyGraph ? copy.storyHint : copy.quizHint;
|
|
45
|
+
try {
|
|
46
|
+
if (exporting) throw new Error(storyGraph ? copy.storyNoMp4 : copy.quizNoMp4);
|
|
47
|
+
if (storyGraph && !capture && !params.has("review")) story = new StoryMap(storyGraph, config.duration);
|
|
48
|
+
if (quizData && !capture && !params.has("review")) quiz = new QuizRun(quizData, config.duration, story ? { segments: story.graph.segments, visited } : undefined);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
status.textContent = error instanceof Error ? error.message : copy.failed; element("loading")?.remove(); console.error(error);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
34
54
|
const loop = embed && params.get("loop") === "1";
|
|
35
55
|
if (capture) document.body.classList.add("capture");
|
|
36
56
|
if (embed) document.body.classList.add("embed");
|
|
@@ -48,6 +68,11 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
48
68
|
if (id !== "download-menu") element(id).title = label;
|
|
49
69
|
}
|
|
50
70
|
const format = (time: number) => `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`;
|
|
71
|
+
// Safari before 16.4 has only the prefixed Fullscreen API, and an iPhone has none for a page element: the player then
|
|
72
|
+
// fills the window instead.
|
|
73
|
+
type WebkitDocument = Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => void };
|
|
74
|
+
type WebkitElement = HTMLElement & { webkitRequestFullscreen?: () => void };
|
|
75
|
+
const fullscreenElement = () => document.fullscreenElement ?? (document as WebkitDocument).webkitFullscreenElement ?? null;
|
|
51
76
|
|
|
52
77
|
async function start() {
|
|
53
78
|
await document.fonts.ready;
|
|
@@ -55,7 +80,6 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
55
80
|
await app.init({ width: config.width, height: config.height, resolution: 1, autoStart: false, antialias: true, background: "#F4F1E9", preference: "webgl", preserveDrawingBuffer: true });
|
|
56
81
|
app.canvas.setAttribute("aria-label", config.title);
|
|
57
82
|
element("canvas-host").append(app.canvas);
|
|
58
|
-
element("loading").remove();
|
|
59
83
|
// Variable brand fonts are registered with their weight range, so bold weights are real rather than synthesized.
|
|
60
84
|
if (branding) await loadFonts(Object.values(branding.fonts).filter((font) => font.file).map((font) => ({ family: font.family.split(",")[0].trim().replace(/^["']|["']$/g, ""), url: publicAsset(font.file!) })));
|
|
61
85
|
const logo = branding?.logo ? await Assets.load(publicAsset(branding.logo)) : null;
|
|
@@ -67,6 +91,34 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
67
91
|
const initialMuted = autoplay || capture || exporting;
|
|
68
92
|
if (audio) { audio.crossOrigin = "anonymous"; audio.src = publicAsset(String(config.audio)); audio.preload = "auto"; audio.muted = initialMuted; audio.addEventListener("error", audioFailure); }
|
|
69
93
|
let time = Math.max(0, Math.min(Number(params.get("t")) || 0, config.duration));
|
|
94
|
+
// ?segment=ID starts a story at that segment; ?t= at that time of the timeline.
|
|
95
|
+
let segment: StorySegment | null = story ? story.graph.segments.find((item) => item.id === params.get("segment")) ?? (params.has("t") ? story.at(time) : story.first) : null;
|
|
96
|
+
if (segment && !params.has("t")) time = segment.start;
|
|
97
|
+
if (segment) visited.add(segment.id);
|
|
98
|
+
/** The choice taken while its segment still plays; the story follows it when the segment ends. */
|
|
99
|
+
let picked: number | null = null;
|
|
100
|
+
/** Seconds a timed choice has been on screen. */
|
|
101
|
+
let waited = 0;
|
|
102
|
+
/** The story is at an ending. */
|
|
103
|
+
let finished = false;
|
|
104
|
+
/** The segments the viewer chose at, for going back. */
|
|
105
|
+
const path: string[] = [];
|
|
106
|
+
/** A story's final quiz and score have shown at an ending in this playthrough. */
|
|
107
|
+
let quizEnded = false;
|
|
108
|
+
const overlay = story ? new StoryOverlay(element("canvas-host"), story.graph, {
|
|
109
|
+
theEnd: story.graph.ui?.theEnd ?? copy.storyEnd, playAgain: story.graph.ui?.playAgain ?? copy.storyPlayAgain,
|
|
110
|
+
back: story.graph.ui?.back ?? copy.storyBack, choose: story.graph.ui?.choose ?? copy.storyChoose,
|
|
111
|
+
}, branding, { choose, restart: () => { void restart(); }, back: () => { void back(); } }) : null;
|
|
112
|
+
const quizOverlay = quiz ? new QuizOverlay(element("canvas-host"), quiz.quiz, quizCopy(copy, quiz.quiz.ui), branding, {
|
|
113
|
+
pick, check: checkAnswer, next: () => { void nextQuestion(); }, watchAgain: () => { void watchAgain(); }, retake: retakeQuiz, finish: () => { void finishQuiz(); },
|
|
114
|
+
}) : null;
|
|
115
|
+
const span = () => segment ? { start: segment.start, end: segment.end } : { start: 0, end: config.duration };
|
|
116
|
+
const atEnd = () => time >= span().end - 0.0005;
|
|
117
|
+
const choosing = () => Boolean(story && segment?.choices && time >= story.choiceAt(segment) - 0.0005);
|
|
118
|
+
// A quiz card covers the story's panels until it closes.
|
|
119
|
+
const storyView = (): StoryView => !segment || quiz?.panel ? { kind: "none" }
|
|
120
|
+
: finished ? { kind: "ending", segment, canGoBack: path.length > 0 }
|
|
121
|
+
: choosing() ? { kind: "choices", segment, picked, remaining: segment.timeout ? 1 - waited / segment.timeout : null } : { kind: "none" };
|
|
70
122
|
let playing = false;
|
|
71
123
|
let muted = initialMuted;
|
|
72
124
|
let last = performance.now();
|
|
@@ -74,32 +126,53 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
74
126
|
let playbackRequest = 0;
|
|
75
127
|
let resources: ResourceTimeline | undefined;
|
|
76
128
|
const update = () => {
|
|
77
|
-
|
|
129
|
+
// A story's transport covers the segment playing, not the whole timeline of every path.
|
|
130
|
+
const { start: from, end: to } = span();
|
|
131
|
+
playButton.textContent = playing ? copy.pause : (segment ? finished : time >= config.duration) ? copy.replay : copy.play;
|
|
132
|
+
// A question showing holds the film: it continues once the viewer answers.
|
|
133
|
+
const asking = Boolean(quiz?.asking);
|
|
134
|
+
playButton.disabled = asking || Boolean(segment?.choices && !playing && atEnd() && picked === null);
|
|
135
|
+
timeline.disabled = asking;
|
|
78
136
|
playButton.setAttribute("aria-label", playButton.textContent);
|
|
79
|
-
element("time").textContent = `${format(time)} / ${format(
|
|
80
|
-
timeline.
|
|
81
|
-
timeline.
|
|
137
|
+
element("time").textContent = `${format(time - from)} / ${format(to - from)}`;
|
|
138
|
+
timeline.max = String(to - from);
|
|
139
|
+
timeline.value = String(time - from);
|
|
140
|
+
timeline.setAttribute("aria-valuetext", `${format(time - from)} / ${format(to - from)}`);
|
|
82
141
|
soundButton.textContent = !audio ? copy.noAudio : muted ? copy.unmute : copy.mute;
|
|
83
142
|
soundButton.disabled = !audio;
|
|
84
143
|
soundButton.setAttribute("aria-pressed", String(Boolean(audio) && !muted));
|
|
85
144
|
captionsButton.setAttribute("aria-pressed", String(film.captions));
|
|
86
145
|
resources?.update({ time, playing, captions: film.captions, muted });
|
|
146
|
+
overlay?.render(storyView());
|
|
147
|
+
if (quiz) quizOverlay?.render(quiz.view());
|
|
87
148
|
};
|
|
88
149
|
const render = (seconds: number) => {
|
|
150
|
+
// A story holding at a segment's end shows that segment's last frame, not the next segment's first.
|
|
151
|
+
const at = segment ? Math.min(seconds, segment.end - 0.001) : seconds;
|
|
89
152
|
// Seeking and frame capture are serialized so decoded video frames cannot race.
|
|
90
|
-
renderQueue = renderQueue.then(() => film.render(
|
|
153
|
+
renderQueue = renderQueue.then(() => film.render(at));
|
|
91
154
|
return renderQueue;
|
|
92
155
|
};
|
|
93
156
|
function pause() { playbackRequest++; playing = false; audio?.pause(); update(); }
|
|
94
157
|
async function seek(seconds: number) {
|
|
95
158
|
if (!Number.isFinite(seconds)) throw new Error("Seek time must be finite.");
|
|
96
159
|
time = Math.max(0, Math.min(seconds, config.duration)); pause();
|
|
160
|
+
// A seek closes a quiz card unanswered; its checkpoint asks again when the film next reaches it.
|
|
161
|
+
quiz?.close();
|
|
162
|
+
// A seek stays in the story's segment while it can, up to its end; another time of the timeline enters its segment.
|
|
163
|
+
if (story && segment && !(time >= segment.start && time <= segment.end)) { segment = story.at(time); visited.add(segment.id); }
|
|
164
|
+
if (segment) { picked = null; waited = 0; finished = segment.ending !== undefined && atEnd(); }
|
|
97
165
|
if (audio) audio.currentTime = time;
|
|
98
166
|
await render(time); update();
|
|
99
167
|
}
|
|
100
168
|
async function play() {
|
|
101
|
-
if (exporting) return;
|
|
102
|
-
|
|
169
|
+
if (exporting || quiz?.asking) return;
|
|
170
|
+
// Play over the score replays the film, or the story from its start.
|
|
171
|
+
quiz?.close();
|
|
172
|
+
if (segment) {
|
|
173
|
+
if (finished) { await restart(); return; }
|
|
174
|
+
if (segment.choices && picked === null && atEnd()) return;
|
|
175
|
+
} else if (time >= config.duration) await seek(0);
|
|
103
176
|
const request = ++playbackRequest;
|
|
104
177
|
if (audio) {
|
|
105
178
|
audio.currentTime = time;
|
|
@@ -113,10 +186,108 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
113
186
|
muted = value; audio.muted = muted; update();
|
|
114
187
|
}
|
|
115
188
|
function toggleSound() { setMuted(!muted); }
|
|
189
|
+
/** Plays a story segment from a time (its start by default); a choice shown at the segment's very end waits there. */
|
|
190
|
+
async function enter(target: StorySegment, at = target.start) {
|
|
191
|
+
segment = target; time = at; picked = null; waited = 0; finished = false; visited.add(target.id);
|
|
192
|
+
if (audio) audio.currentTime = time;
|
|
193
|
+
await render(time);
|
|
194
|
+
if (atEnd()) { pause(); return; }
|
|
195
|
+
if (playing) { last = performance.now(); update(); } else await play();
|
|
196
|
+
}
|
|
197
|
+
/** What a segment does at its end: continue, go where the viewer chose, wait for a choice, or end the story. */
|
|
198
|
+
async function arrive() {
|
|
199
|
+
if (!story || !segment) return;
|
|
200
|
+
if (segment.next) await enter(story.get(segment.next));
|
|
201
|
+
else if (segment.choices) {
|
|
202
|
+
if (picked !== null) await take(picked);
|
|
203
|
+
else { playbackRequest++; playing = false; audio?.pause(); }
|
|
204
|
+
} else {
|
|
205
|
+
finished = true; pause();
|
|
206
|
+
// A quiz's final round and score come before the story's ending card, once a playthrough.
|
|
207
|
+
if (quiz && !quizEnded) { quizEnded = true; if (quizEnd()) update(); }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function choose(index: number) {
|
|
211
|
+
if (!segment?.choices?.[index] || picked !== null || !choosing() || quiz?.panel) return;
|
|
212
|
+
picked = index;
|
|
213
|
+
// A choice made while the segment plays waits for its end, so the segment finishes as it was cut.
|
|
214
|
+
if (!playing || atEnd()) void take(index); else update();
|
|
215
|
+
}
|
|
216
|
+
async function take(index: number) {
|
|
217
|
+
path.push(segment!.id);
|
|
218
|
+
await enter(story!.get(segment!.choices![index].to));
|
|
219
|
+
}
|
|
220
|
+
async function restart() { path.length = 0; quiz?.reset(); visited.clear(); quizEnded = false; await enter(story!.first); }
|
|
221
|
+
/** Back to the last choice the viewer made, with its choices showing. */
|
|
222
|
+
async function back() {
|
|
223
|
+
const id = path.pop();
|
|
224
|
+
if (!id) { await restart(); return; }
|
|
225
|
+
const decision = story!.get(id);
|
|
226
|
+
await enter(decision, story!.choiceAt(decision));
|
|
227
|
+
}
|
|
228
|
+
/** A quiz event for a page hosting the film: each answer, and the score when the quiz is complete. */
|
|
229
|
+
const emit = (type: "answer" | "complete", detail: object | null) => window.dispatchEvent(new CustomEvent("animaguru:quiz", { detail: { type, ...detail } }));
|
|
230
|
+
function pick(index: number) {
|
|
231
|
+
if (!quiz?.pick(index)) return;
|
|
232
|
+
if (quiz.answered) emit("answer", quiz.lastAnswer);
|
|
233
|
+
update();
|
|
234
|
+
}
|
|
235
|
+
function checkAnswer() { if (quiz?.check()) { emit("answer", quiz.lastAnswer); update(); } }
|
|
236
|
+
/** The final quiz, or the score, at the film's end or a story's ending; false when the viewer met no question. */
|
|
237
|
+
function quizEnd() {
|
|
238
|
+
if (!quiz?.openEnd()) return false;
|
|
239
|
+
if (quiz.panel?.kind === "results") emit("complete", quiz.score());
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
/** After an answer: the next question, the score, or back to the film where the checkpoint paused it. */
|
|
243
|
+
async function nextQuestion() {
|
|
244
|
+
const step = quiz?.advance();
|
|
245
|
+
if (step === "results") emit("complete", quiz!.score());
|
|
246
|
+
update();
|
|
247
|
+
if (step !== "closed") return;
|
|
248
|
+
if (segment) { if (atEnd()) await arrive(); else await play(); }
|
|
249
|
+
else if (time >= config.duration - 0.0005) { if (quizEnd()) update(); }
|
|
250
|
+
else await play();
|
|
251
|
+
}
|
|
252
|
+
/** Back to the part a checkpoint covers, which asks again when the film reaches it. */
|
|
253
|
+
async function watchAgain() {
|
|
254
|
+
const panel = quiz?.panel;
|
|
255
|
+
if (panel?.kind !== "checkpoint") return;
|
|
256
|
+
quiz!.arm(panel.checkpoint);
|
|
257
|
+
await seek(quiz!.reviewTime(panel.checkpoint)); await play();
|
|
258
|
+
}
|
|
259
|
+
function retakeQuiz() { quiz?.retake(); update(); }
|
|
260
|
+
/** The score's last button: a story's ending card, or the film again from the start with every question fresh. */
|
|
261
|
+
async function finishQuiz() {
|
|
262
|
+
if (segment) { quiz?.close(); update(); return; }
|
|
263
|
+
quiz?.reset(); await seek(0); await play();
|
|
264
|
+
}
|
|
116
265
|
function setCaptions(value: boolean) { film.captions = value; void render(time); update(); }
|
|
266
|
+
const fullscreenButton = element<HTMLButtonElement>("fullscreen");
|
|
267
|
+
function showFullscreen() {
|
|
268
|
+
const on = Boolean(fullscreenElement()) || theater.classList.contains("expanded");
|
|
269
|
+
fullscreenButton.setAttribute("aria-pressed", String(on));
|
|
270
|
+
fullscreenButton.setAttribute("aria-label", on ? copy.exitFullscreen : copy.fullscreen); fullscreenButton.title = fullscreenButton.getAttribute("aria-label")!;
|
|
271
|
+
}
|
|
272
|
+
function expand(on: boolean) { theater.classList.toggle("expanded", on); document.body.classList.toggle("theater-expanded", on); showFullscreen(); }
|
|
117
273
|
async function fullscreen() {
|
|
118
|
-
|
|
119
|
-
|
|
274
|
+
if (theater.classList.contains("expanded")) { expand(false); return; }
|
|
275
|
+
const page = document as WebkitDocument; const target = theater as WebkitElement;
|
|
276
|
+
try {
|
|
277
|
+
if (fullscreenElement()) { if (document.exitFullscreen) await document.exitFullscreen(); else page.webkitExitFullscreen?.(); }
|
|
278
|
+
else if (target.requestFullscreen) await target.requestFullscreen();
|
|
279
|
+
else if (target.webkitRequestFullscreen) target.webkitRequestFullscreen();
|
|
280
|
+
else expand(true);
|
|
281
|
+
} catch { expand(true); }
|
|
282
|
+
}
|
|
283
|
+
document.addEventListener("fullscreenchange", showFullscreen);
|
|
284
|
+
document.addEventListener("webkitfullscreenchange", showFullscreen);
|
|
285
|
+
/** A seek from the bar or the keys keeps a playing film playing; the API's seek pauses, for capture. */
|
|
286
|
+
let resumeAfterScrub = false;
|
|
287
|
+
async function scrub(seconds: number, done = true) {
|
|
288
|
+
resumeAfterScrub ||= playing;
|
|
289
|
+
await seek(segment ? Math.max(segment.start, Math.min(segment.end, seconds)) : seconds);
|
|
290
|
+
if (done && resumeAfterScrub) { resumeAfterScrub = false; if (!finished) await play(); }
|
|
120
291
|
}
|
|
121
292
|
async function saveFrame() {
|
|
122
293
|
pause(); await render(time);
|
|
@@ -178,15 +349,32 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
178
349
|
playButton.addEventListener("click", () => { if (playing) pause(); else void play(); });
|
|
179
350
|
soundButton.addEventListener("click", toggleSound);
|
|
180
351
|
captionsButton.addEventListener("click", () => setCaptions(!film.captions));
|
|
181
|
-
|
|
352
|
+
fullscreenButton.addEventListener("click", () => { void fullscreen(); });
|
|
353
|
+
// The picture plays or pauses on a click and goes full screen on a double click, as a video does.
|
|
354
|
+
if (!capture && !exporting) {
|
|
355
|
+
app.canvas.addEventListener("click", () => { if (playing) pause(); else void play(); });
|
|
356
|
+
app.canvas.addEventListener("dblclick", () => { void fullscreen(); });
|
|
357
|
+
}
|
|
182
358
|
element("save").addEventListener("click", () => { closeDownloads(true); void saveFrame(); });
|
|
183
|
-
timeline.addEventListener("input", () => { void
|
|
359
|
+
timeline.addEventListener("input", () => { void scrub(span().start + Number(timeline.value), false); });
|
|
360
|
+
timeline.addEventListener("change", () => { void scrub(span().start + Number(timeline.value)); });
|
|
184
361
|
document.addEventListener("visibilitychange", () => { if (document.hidden && !exporting) pause(); });
|
|
185
362
|
document.addEventListener("keydown", (event) => {
|
|
186
|
-
|
|
363
|
+
const target = event.target instanceof HTMLElement ? event.target : null;
|
|
364
|
+
if (exporting || target?.closest("input,textarea,select,[contenteditable]")) return;
|
|
365
|
+
// Number keys answer a question or pick a story's choice, also with focus on one of their buttons.
|
|
366
|
+
if (quiz?.asking && /^[1-6]$/.test(event.key)) { event.preventDefault(); pick(Number(event.key) - 1); return; }
|
|
367
|
+
if (segment && !quiz?.panel && /^[1-4]$/.test(event.key)) { choose(Number(event.key) - 1); return; }
|
|
368
|
+
if (target?.closest("button,a,summary")) return;
|
|
369
|
+
if (quiz?.asking) {
|
|
370
|
+
if (event.key === "Enter") { event.preventDefault(); if (quiz.answered) void nextQuestion(); else checkAnswer(); }
|
|
371
|
+
// The film waits for the answer: the play and seek keys do nothing.
|
|
372
|
+
if (["Space", "ArrowRight", "ArrowLeft"].includes(event.code)) { event.preventDefault(); return; }
|
|
373
|
+
}
|
|
187
374
|
if (event.code === "Space") { event.preventDefault(); if (playing) pause(); else void play(); }
|
|
188
|
-
if (event.code === "ArrowRight") { event.preventDefault(); void
|
|
189
|
-
if (event.code === "ArrowLeft") { event.preventDefault(); void
|
|
375
|
+
if (event.code === "ArrowRight") { event.preventDefault(); void scrub(time + 5); }
|
|
376
|
+
if (event.code === "ArrowLeft") { event.preventDefault(); void scrub(time - 5); }
|
|
377
|
+
if (event.key === "Escape" && theater.classList.contains("expanded")) expand(false);
|
|
190
378
|
if (event.key.toLowerCase() === "m") toggleSound();
|
|
191
379
|
if (event.key.toLowerCase() === "c") setCaptions(!film.captions);
|
|
192
380
|
if (event.key.toLowerCase() === "f") void fullscreen();
|
|
@@ -194,20 +382,43 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
194
382
|
async function tick(now: number) {
|
|
195
383
|
try {
|
|
196
384
|
if (playing) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
385
|
+
const end = span().end, from = time;
|
|
386
|
+
time = Math.min(end, audio && !audio.paused ? audio.currentTime : time + (now - last) / 1000);
|
|
387
|
+
// A checkpoint the film passes stops it on its own frame and asks.
|
|
388
|
+
const due = quiz?.due(from, time, segment ? span() : undefined);
|
|
389
|
+
if (due) { time = due.at; pause(); quiz!.open(due); await render(time); }
|
|
390
|
+
else {
|
|
391
|
+
await render(time);
|
|
392
|
+
if (time >= end) { if (segment) await arrive(); else if (quizEnd()) pause(); else if (loop) { await seek(0); await play(); } else pause(); }
|
|
393
|
+
}
|
|
200
394
|
update();
|
|
201
395
|
}
|
|
396
|
+
// A timed choice counts down while the film plays on and while it holds on the segment's last frame.
|
|
397
|
+
if (segment?.timeout !== undefined && picked === null && choosing() && (playing || atEnd()) && !quiz?.panel && !document.hidden) {
|
|
398
|
+
waited += (now - last) / 1000;
|
|
399
|
+
if (waited >= segment.timeout) choose(segment.default ?? 0); else if (!playing) update();
|
|
400
|
+
}
|
|
202
401
|
last = now;
|
|
203
402
|
requestAnimationFrame((next) => { void tick(next); });
|
|
204
403
|
} catch (error) { pause(); status.textContent = copy.failed; console.error(error); }
|
|
205
404
|
}
|
|
206
|
-
await render(time); update();
|
|
405
|
+
await render(time); update(); showFullscreen();
|
|
406
|
+
// The first frame is drawn: the loading line gives way to it.
|
|
407
|
+
element("loading")?.remove();
|
|
207
408
|
const api = {
|
|
208
409
|
ready: true, width: config.width, height: config.height, duration: config.duration, fps: config.fps, language: config.language, languages: [{ code: config.language, name: config.language }], canvas: app.canvas,
|
|
209
|
-
get state() {
|
|
410
|
+
get state() {
|
|
411
|
+
return { time, playing, muted, captions: film.captions, audio: { configured: Boolean(audio), readyState: audio?.readyState ?? 0, error: audio?.error?.code ?? null },
|
|
412
|
+
// waiting: the segment has ended and only a choice continues the story, so play does nothing.
|
|
413
|
+
...(segment ? { story: { segment: segment.id, start: segment.start, end: segment.end, choosing: choosing() && picked === null, waiting: Boolean(segment.choices && !playing && atEnd() && picked === null), ended: finished } } : {}),
|
|
414
|
+
// open: a quiz card shows; asking: it asks a question, so play and seeking wait for the answer.
|
|
415
|
+
...(quiz ? { quiz: { open: Boolean(quiz.panel), asking: quiz.asking, card: quiz.panel?.kind ?? null, score: quiz.score() } } : {}) };
|
|
416
|
+
},
|
|
210
417
|
resources: film.resources,
|
|
418
|
+
// A branching story: its graph, the path taken, and the viewer's actions.
|
|
419
|
+
...(story ? { story: { graph: story.graph, get path() { return [...path]; }, choose, back, restart, async goTo(id: string) { path.length = 0; await enter(story!.get(id)); } } } : {}),
|
|
420
|
+
// A quiz: its questions, the viewer's actions on the card, and the score so far.
|
|
421
|
+
...(quiz ? { quiz: { data: quiz.quiz, pick, check: checkAnswer, next: nextQuestion, retake: retakeQuiz, reset() { quiz!.reset(); update(); }, get score() { return quiz!.score(); } } } : {}),
|
|
211
422
|
get texts() { return film.texts; },
|
|
212
423
|
get cues() { return film.cues; },
|
|
213
424
|
seek, play, pause, setMuted, setCaptions, saveFrame,
|