Files
Epic-The-Musical-Timeline/src/app/panel/panel.ts
T
Lucas WintherandClaude Opus 5.5 2fbf3a6980 feat(app): content client and side panel
About, Listen & lyrics (real player, synced lyrics, Watch), Paths (strip
when the timeline is hidden, motif rows, nine-place chart), Fan works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 05:09:22 +01:00

230 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The persistent right-side panel: About · Listen & lyrics · Paths · Fan works for the current song.
// It follows the store (song, tab, picked motif, timeline, reading view) and writes back to it; the play
// button in the header starts the song and opens Listen & lyrics. Collapsing the panel to its slim tab is
// local to the panel (it sets data-panel on the surrounding .shell). Ported from 17's panel.ts.
import { songById, songs, sagaOf, type AlbumSong } from "../../data/album";
import { contentMounted, libraryReady, realFor } from "../content";
import type { AppState, Store, Tab } from "../state";
import { aboutView } from "./about";
import { fanView } from "./fanworks";
import { createListen } from "./listen";
import { pathsView } from "./paths";
import { readingView } from "./reading";
import { applyPick, esc, fmt, PAUSE_ICON, PLAY_ICON } from "./util";
export const TABS: { id: Tab; label: string }[] = [
{ id: "about", label: "About" },
{ id: "listen", label: "Listen & lyrics" },
{ id: "paths", label: "Paths" },
{ id: "fan", label: "Fan works" },
];
export interface PanelOpts {
/** Whether the bottom timeline is hidden (besides `state.timeline === "closed"`), e.g. on small screens. */
timelineHidden: () => boolean;
}
export function mountPanel(root: HTMLElement, store: Store, opts: PanelOpts): { refresh(): void; destroy(): void } {
const reduced = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
let song: AlbumSong = songById(store.get().songId) ?? songs[0]!;
let mounted = contentMounted();
let readAll = false;
let destroyed = false;
root.classList.add("panel");
root.innerHTML = `
<button type="button" class="panel__tab" data-act="open" aria-controls="panel-body" aria-expanded="true" tabindex="-1">
<span class="panel__tab-arrow" aria-hidden="true">‹</span>
<span class="panel__tab-n"></span>
<span class="panel__tab-label">About · Listen &amp; lyrics · Paths · Fan works</span>
</button>
<div class="panel__body" id="panel-body">
<header class="panel__head">
<button type="button" class="pplay" aria-pressed="false"></button>
<div class="panel__song" aria-live="polite"></div>
<button type="button" class="panel__hide" data-act="close" aria-controls="panel-body" aria-expanded="true" title="Collapse the panel">Hide <span aria-hidden="true">›</span></button>
</header>
<div class="ptabs" role="tablist" aria-label="About this song">
${TABS.map((t) => `<button type="button" role="tab" id="ptab-${t.id}" aria-controls="panel-view" data-tab="${t.id}">${esc(t.label)}</button>`).join("")}
</div>
<div class="panel__view" id="panel-view" role="tabpanel" tabindex="0"></div>
</div>`;
const q = <T extends HTMLElement>(sel: string) => root.querySelector<T>(sel)!;
const view = q<HTMLElement>(".panel__view");
const songEl = q<HTMLElement>(".panel__song");
const headPlay = q<HTMLButtonElement>(".pplay");
const tabBtns = [...root.querySelectorAll<HTMLButtonElement>("[role=tab]")];
const listen = createListen({
host: root,
view,
reduced,
onTick: syncHead,
onContent: () => { if (store.get().tab === "listen") renderView(true); },
});
/* ----- header ----- */
function renderHead() {
const saga = sagaOf(song);
songEl.innerHTML = `<p class="panel__eyebrow"></p>
<h2 class="panel__title">${esc(song.title)}</h2>
<p class="panel__sub">${esc(saga.name)} · ${esc(song.storyTime.label)}</p>`;
q<HTMLElement>(".panel__tab-n").textContent = String(song.track);
headPlay.dataset.key = "";
syncHead();
}
function syncHead() {
const p = listen.player;
const on = p.playing;
const key = `${on}|${song.id}`;
if (headPlay.dataset.key !== key) {
headPlay.dataset.key = key;
headPlay.innerHTML = `${on ? PAUSE_ICON : PLAY_ICON}<span class="sr">${on ? "Pause" : "Play"} ${esc(song.title)}</span>`;
headPlay.setAttribute("aria-pressed", String(on));
headPlay.title = `${on ? "Pause" : "Play"} ${song.title}`;
}
const eyebrow = songEl.querySelector<HTMLElement>(".panel__eyebrow");
if (!eyebrow) return;
const active = on || p.t > 0;
eyebrow.textContent = active
? `${on ? "Playing" : "Paused"} · ${fmt(p.t)} / ${p.ready ? fmt(p.dur) : "…"}`
: `Track ${song.track} of ${songs.length}`;
eyebrow.classList.toggle("is-live", on);
}
/* ----- the tab view ----- */
const timelineHidden = (s: AppState) => s.timeline === "closed" || opts.timelineHidden();
const reading = (s: AppState) => s.tab === "about" && s.read && !!song.odyssey && mounted;
function renderView(keepScroll = false) {
const s = store.get();
const top = view.scrollTop;
const el = s.tab === "about" ? (reading(s) ? readingView(song, readAll) : aboutView(song, mounted))
: s.tab === "listen" ? listen.render()
: s.tab === "paths" ? pathsView(song, s.motif, timelineHidden(s))
: fanView(song);
view.replaceChildren(el);
view.setAttribute("aria-labelledby", `ptab-${s.tab}`);
tabBtns.forEach((b) => {
const on = b.dataset.tab === s.tab;
b.setAttribute("aria-selected", String(on));
b.tabIndex = on ? 0 : -1;
});
applyPick(view, s.motif);
view.scrollTop = keepScroll ? top : 0;
listen.sync();
}
function loadSong() {
listen.setSong(song, realFor(song), mounted);
readAll = false;
}
/** A gentle swap: the panel's content dips and settles when the voyage reaches a new song. */
function swapIn() {
if (reduced()) return;
root.classList.remove("is-swapped");
void root.offsetWidth;
root.classList.add("is-swapped");
}
const off = store.subscribe((s, prev) => {
if (s.songId !== prev.songId) {
song = songById(s.songId) ?? songs[0]!;
loadSong();
renderHead();
if (s.read && prev.read) { store.set({ read: false }); return; } // a new song closes the reading view
renderView();
swapIn();
return;
}
const pathsChanged = s.tab === "paths" && (s.timeline !== prev.timeline || s.motif !== prev.motif);
if (s.tab !== prev.tab || s.read !== prev.read || pathsChanged) {
if (!s.read) readAll = false;
renderView(pathsChanged);
} else if (s.motif !== prev.motif) {
applyPick(view, s.motif);
}
});
/* ----- open / collapsed ----- */
function setOpen(open: boolean) {
root.dataset.open = String(open);
const shell = root.closest<HTMLElement>(".shell");
if (shell) shell.dataset.panel = open ? "open" : "closed";
root.querySelectorAll("[aria-controls=panel-body]").forEach((b) => b.setAttribute("aria-expanded", String(open)));
q<HTMLElement>(".panel__body").inert = !open;
q<HTMLElement>(".panel__tab").tabIndex = open ? -1 : 0;
}
/* ----- wiring ----- */
root.addEventListener("click", (e) => {
const t = e.target as Element;
const pick = t.closest<HTMLElement>("[data-pick]");
if (pick) { store.set({ motif: store.get().motif === pick.dataset.pick ? null : pick.dataset.pick! }); return; }
const to = t.closest<Element>("[data-song]");
if (to && root.contains(to)) { store.set({ songId: to.getAttribute("data-song")! }); return; }
if (store.get().tab === "listen" && listen.click(t)) return;
const act = t.closest<HTMLElement>("[data-act]")?.dataset.act;
if (act === "read") store.set({ tab: "about", read: true });
else if (act === "unread") store.set({ read: false });
else if (act === "read-all") { readAll = true; renderView(true); }
else if (act === "open") { setOpen(true); headPlay.focus(); }
else if (act === "close") { setOpen(false); q<HTMLElement>(".panel__tab").focus(); }
});
root.addEventListener("keydown", (e) => {
if (e.key === "Escape" && store.get().read) { e.stopPropagation(); store.set({ read: false }); }
});
headPlay.addEventListener("click", () => {
if (listen.player.playing) { listen.player.pause(); return; }
if (store.get().tab !== "listen" || store.get().read) store.set({ tab: "listen", read: false });
listen.player.play();
});
tabBtns.forEach((b, i) => {
b.addEventListener("click", () => store.set({ tab: b.dataset.tab as Tab, read: false }));
b.addEventListener("keydown", (e) => {
const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!d) return;
e.preventDefault();
e.stopPropagation();
const nb = tabBtns[(i + d + tabBtns.length) % tabBtns.length]!;
nb.focus();
nb.click();
});
});
// the reader's own scrolling of the lyrics wins for ~4 s, then the lyrics follow the song again
view.addEventListener("wheel", listen.userScrolled, { passive: true });
view.addEventListener("touchmove", listen.userScrolled, { passive: true });
view.addEventListener("pointerdown", (e) => { if (e.target === view) listen.userScrolled(); });
view.addEventListener("keydown", (e) => {
if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(e.key) && !(e.target as Element).closest(".scrub")) listen.userScrolled();
});
/** Re-reads the content folder's state (call after the library loads) and re-renders. */
function refresh() {
if (destroyed) return;
mounted = contentMounted();
loadSong();
renderHead();
renderView(true);
}
setOpen(true);
loadSong();
renderHead();
renderView();
libraryReady().then(() => { if (contentMounted() !== mounted) refresh(); });
return {
refresh,
destroy() {
destroyed = true;
off();
listen.destroy();
root.replaceChildren();
delete root.dataset.open;
},
};
}