Test and deploy / test-and-deploy (push) Successful in 1m1s
Two short macro films generated on the local ComfyUI instance (Wan 2.2 14B t2v + lightx2v 4-step LoRAs, 9 clips at 832x480) and stitched with crossfades and burned-in phase labels: roast-physics.mp4 (charge, drying, Maillard, first crack, drop) and brew-physics.mp4 (grind, bloom, immersion, espresso). A "Films — the physics in motion" card on the Academy menu plays them with posters; opening a lesson hides and pauses them. The service worker now bypasses /academy-video/ and /academy-audio/ (Range/206 responses the Cache API rejects) and only caches full 200s. Co-Authored-By: Claude Fable 5 <[email protected]>
227 lines
7.3 KiB
JavaScript
227 lines
7.3 KiB
JavaScript
import { protectedFetch } from "./api.js?v=__ASSET_VERSION__";
|
|
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
|
|
import { ACADEMY_TRACKS } from "./academy-content.js?v=__ASSET_VERSION__";
|
|
import { SCENES, sceneDefs, sceneFallbackText } from "./academy-scenes.js?v=__ASSET_VERSION__";
|
|
|
|
// ── Progress ───────────────────────────────────────────────────────────────
|
|
|
|
const PROGRESS_KEY = "academyProgress.v1";
|
|
function loadProgress() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(PROGRESS_KEY)) ?? {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
function saveProgress(lessonId, patch) {
|
|
const all = loadProgress();
|
|
all[lessonId] = { ...all[lessonId], ...patch };
|
|
try {
|
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify(all));
|
|
} catch {
|
|
/* storage unavailable */
|
|
}
|
|
}
|
|
|
|
// ── Player ─────────────────────────────────────────────────────────────────
|
|
|
|
let current = null; // {track, lesson, index}
|
|
const audio = new Audio();
|
|
let autoplay = true;
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
function renderMenu() {
|
|
const progress = loadProgress();
|
|
const mount = $("academy-menu");
|
|
mount.replaceChildren(
|
|
...ACADEMY_TRACKS.map((track) => {
|
|
const wrap = document.createElement("section");
|
|
wrap.className = "panel-card";
|
|
const head = document.createElement("div");
|
|
head.className = "panel-head";
|
|
const h2 = document.createElement("h2");
|
|
h2.textContent = track.name;
|
|
head.append(h2);
|
|
const body = document.createElement("div");
|
|
body.className = "panel-body";
|
|
const blurb = document.createElement("p");
|
|
blurb.className = "muted";
|
|
blurb.style.marginTop = "0";
|
|
blurb.textContent = track.blurb;
|
|
body.append(blurb);
|
|
const list = document.createElement("div");
|
|
list.className = "academy-lessons";
|
|
track.lessons.forEach((lesson, li) => {
|
|
const state = progress[lesson.id] ?? {};
|
|
const resumeAt = state.done ? 0 : (state.slide ?? 0);
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "academy-lesson";
|
|
const num = document.createElement("span");
|
|
num.className = "academy-lesson-num";
|
|
num.textContent = state.done ? "✓" : String(li + 1);
|
|
if (state.done) num.classList.add("done");
|
|
const mid = document.createElement("span");
|
|
mid.className = "academy-lesson-mid";
|
|
const label = document.createElement("span");
|
|
label.className = "academy-lesson-label";
|
|
label.textContent = lesson.title;
|
|
const meta = document.createElement("span");
|
|
meta.className = "academy-lesson-meta";
|
|
meta.textContent = state.done
|
|
? `completed · ${lesson.slides.length} slides · ~${lesson.minutes} min`
|
|
: resumeAt > 0
|
|
? `resume at slide ${resumeAt + 1} of ${lesson.slides.length}`
|
|
: `${lesson.slides.length} slides · ~${lesson.minutes} min`;
|
|
const bar = document.createElement("span");
|
|
bar.className = "academy-progress";
|
|
const fill = document.createElement("span");
|
|
fill.className = "academy-progress-fill";
|
|
fill.style.width = state.done
|
|
? "100%"
|
|
: `${Math.round((resumeAt / lesson.slides.length) * 100)}%`;
|
|
bar.append(fill);
|
|
mid.append(label, meta, bar);
|
|
btn.append(num, mid);
|
|
btn.addEventListener("click", () => openLesson(track, lesson, resumeAt));
|
|
list.append(btn);
|
|
});
|
|
body.append(list);
|
|
wrap.append(head, body);
|
|
return wrap;
|
|
}),
|
|
);
|
|
}
|
|
|
|
function openLesson(track, lesson, index) {
|
|
current = { track, lesson, index };
|
|
$("academy-menu").classList.add("hidden");
|
|
$("academy-films")?.classList.add("hidden");
|
|
// Pause any playing film so it doesn't run (and download) behind the lesson.
|
|
for (const video of document.querySelectorAll("#academy-films video")) video.pause();
|
|
$("academy-player").classList.remove("hidden");
|
|
renderSlide();
|
|
}
|
|
|
|
function closeLesson() {
|
|
audio.pause();
|
|
current = null;
|
|
$("academy-player").classList.add("hidden");
|
|
$("academy-menu").classList.remove("hidden");
|
|
$("academy-films")?.classList.remove("hidden");
|
|
renderMenu(); // reflect fresh progress
|
|
}
|
|
|
|
function renderSlide() {
|
|
const { lesson, index, track } = current;
|
|
const slide = lesson.slides[index];
|
|
saveProgress(lesson.id, { slide: index });
|
|
$("player-lesson").textContent = `${track.name} — ${lesson.title}`;
|
|
$("player-slide-title").textContent = slide.title;
|
|
$("player-count").textContent = `${index + 1} / ${lesson.slides.length}`;
|
|
|
|
// Rebuilt per slide so CSS animations restart with it.
|
|
const svg = $("player-scene");
|
|
svg.replaceChildren(sceneDefs());
|
|
try {
|
|
svg.append(...SCENES[slide.scene[0]](slide.scene[1] ?? {}));
|
|
} catch {
|
|
svg.append(sceneFallbackText(slide.title));
|
|
}
|
|
|
|
$("player-caption").textContent = slide.transcript;
|
|
|
|
const dots = $("player-dots");
|
|
dots.replaceChildren(
|
|
...lesson.slides.map((s, i) => {
|
|
const dot = document.createElement("button");
|
|
dot.type = "button";
|
|
dot.className = `academy-dot${i === index ? " active" : ""}`;
|
|
dot.setAttribute("aria-label", `Slide ${i + 1}: ${s.title}`);
|
|
dot.addEventListener("click", () => {
|
|
current.index = i;
|
|
renderSlide();
|
|
});
|
|
return dot;
|
|
}),
|
|
);
|
|
$("player-prev").disabled = index === 0;
|
|
$("player-next").textContent =
|
|
index === lesson.slides.length - 1 ? "Finish lesson" : "Next →";
|
|
|
|
audio.src = `/academy-audio/${slide.id}.mp3?v=__ASSET_VERSION__`;
|
|
if (autoplay) audio.play().catch(() => setPlayState(false));
|
|
setPlayState(autoplay);
|
|
}
|
|
|
|
function setPlayState(playing) {
|
|
$("player-play").textContent = playing ? "❚❚" : "▶";
|
|
$("player-play").setAttribute("aria-label", playing ? "Pause narration" : "Play narration");
|
|
}
|
|
|
|
function next() {
|
|
const { lesson, index } = current;
|
|
if (index >= lesson.slides.length - 1) {
|
|
saveProgress(lesson.id, { done: true, slide: 0 });
|
|
closeLesson();
|
|
return;
|
|
}
|
|
current.index++;
|
|
renderSlide();
|
|
}
|
|
|
|
function wirePlayer() {
|
|
$("player-close").addEventListener("click", closeLesson);
|
|
$("player-prev").addEventListener("click", () => {
|
|
if (current.index > 0) {
|
|
current.index--;
|
|
renderSlide();
|
|
}
|
|
});
|
|
$("player-next").addEventListener("click", next);
|
|
$("player-play").addEventListener("click", () => {
|
|
if (audio.paused) {
|
|
autoplay = true;
|
|
audio.play().catch(() => {});
|
|
setPlayState(true);
|
|
} else {
|
|
audio.pause();
|
|
setPlayState(false);
|
|
}
|
|
});
|
|
audio.addEventListener("ended", () => {
|
|
setPlayState(false);
|
|
// Give the animation a beat, then advance — hands-free lesson flow.
|
|
if (autoplay && current) setTimeout(() => current && next(), 1200);
|
|
});
|
|
document.addEventListener("keydown", (event) => {
|
|
if (!current) return;
|
|
if (event.key === "ArrowRight") next();
|
|
if (event.key === "ArrowLeft" && current.index > 0) {
|
|
current.index--;
|
|
renderSlide();
|
|
}
|
|
if (event.key === "Escape") closeLesson();
|
|
if (event.key === " " && event.target === document.body) {
|
|
event.preventDefault();
|
|
$("player-play").click();
|
|
}
|
|
});
|
|
}
|
|
|
|
document.getElementById("btn-logout").addEventListener("click", async () => {
|
|
await protectedFetch("/api/auth/logout", { method: "POST" });
|
|
location.assign("/");
|
|
});
|
|
|
|
async function init() {
|
|
initSideNav();
|
|
const user = await loadNavUser();
|
|
if (!user) return;
|
|
renderMenu();
|
|
wirePlayer();
|
|
}
|
|
|
|
init();
|