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 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 05:09:22 +01:00
co-authored by Claude Opus 5.5
parent 38c4614726
commit 2fbf3a6980
13 changed files with 1734 additions and 1 deletions
+109
View File
@@ -0,0 +1,109 @@
// The real content, read at runtime from the server's mounted folder (spec 003): the library (FLAC audio,
// timed or plain lyrics, saga covers) and the Odyssey by book (Butcher & Lang, Greek names). Nothing here
// is copied into the repo: without the folder every /content/ route is 404 and the app runs without it.
// Ported from src/mockups/17-voyage-real/content.ts to the album types.
import type { HomerBook } from "../content/homer";
import type { Library, LibrarySong } from "../content/library";
import type { AlbumSong } from "../data/album";
import { parseLrc, type LrcLine } from "../shared/lrc";
export type Lyrics = { timed: true; lines: LrcLine[] } | { timed: false; lines: string[] };
export type LyricsRef = NonNullable<LibrarySong["lyrics"]>;
export interface RealSong { audio: string; cover: string | null; lyrics: LyricsRef | null }
/** Case-, accent- and punctuation-insensitive title key ("Wouldn’t You Like" = "wouldnt you like"). */
export const normTitle = (s: string) =>
s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[’'`]/g, "").replace(/[^a-z0-9]+/g, " ").trim();
const byTitle = new Map<string, RealSong>();
let pending: Promise<boolean> | null = null;
let generation = 0;
/** Loads /content/library.json; false when the folder is not mounted (404), the request fails or times out. */
export function loadLibrary(timeoutMs = 4000): Promise<boolean> {
byTitle.clear();
const gen = ++generation;
const p = (async () => {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs);
try {
const res = await fetch("/content/library.json", { signal: ctl.signal });
if (!res.ok) return false;
const lib = (await res.json()) as Library;
if (gen !== generation) return false; // a newer load replaced this one
for (const saga of lib.sagas) for (const song of saga.songs) {
byTitle.set(normTitle(song.title), { audio: song.audio, cover: saga.cover, lyrics: song.lyrics });
}
return byTitle.size > 0;
} catch {
return false;
} finally {
clearTimeout(timer);
}
})();
pending = p;
return p;
}
/** The library load in flight or done, starting one if none was asked for yet. */
export const libraryReady = (): Promise<boolean> => pending ?? loadLibrary();
/** Whether the library has loaded with at least one song. */
export const contentMounted = () => byTitle.size > 0;
/** The library entry for an album song, matched by normalised title; null if not mounted or no match. */
export function realFor(song: AlbumSong): RealSong | null {
return byTitle.get(normTitle(song.title)) ?? null;
}
const lyricCache = new Map<string, Promise<Lyrics | null>>();
/** Fetches and parses a song's lyrics (.lrc → timed lines, .txt → plain lines); null if it fails. */
export function loadLyrics(ref: LyricsRef): Promise<Lyrics | null> {
const key = `${ref.timed}:${ref.url}`;
let p = lyricCache.get(key);
if (!p) {
p = fetch(ref.url)
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
.then((text): Lyrics => ref.timed
? { timed: true, lines: parseLrc(text) }
: { timed: false, lines: text.trim().split(/\r?\n/).map((l) => l.trim()) })
.catch(() => { lyricCache.delete(key); return null; });
lyricCache.set(key, p);
}
return p;
}
const bookCache = new Map<number, Promise<HomerBook | null>>();
/** One book of the Odyssey from the content folder; null if it is not there. */
export function loadBook(book: number): Promise<HomerBook | null> {
let p = bookCache.get(book);
if (!p) {
p = fetch(`/content/homer/odyssey/${book}.json`)
.then((r) => (r.ok ? (r.json() as Promise<HomerBook>) : null))
.catch(() => null)
.then((b) => { if (!b) bookCache.delete(book); return b; });
bookCache.set(book, p);
}
return p;
}
const reEsc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
/** A whole-word, case-insensitive pattern for any of the keywords; null for none. */
export const keywordRe = (words: string[]) => (words.length ? new RegExp(`\\b(${words.map(reEsc).join("|")})\\b`, "gi") : null);
/** Index of the first paragraph that mentions a keyword, and which one; paragraph 0 if none does. */
export function passageStart(book: HomerBook, keywords: string[]): { index: number; word: string | null } {
const re = keywordRe(keywords);
if (re) for (let i = 0; i < book.paragraphs.length; i++) {
re.lastIndex = 0;
const m = re.exec(book.paragraphs[i]!);
if (m) return { index: i, word: m[1]! };
}
return { index: 0, word: null };
}
/** Gutenberg's footnote markers sit glued to words ("the place.114"); the reading view drops them. */
export const stripFootnotes = (s: string) => s.replace(/([A-Za-z.,;:!?’”)])\d{1,3}(?=[\s,;:.’”]|$)/g, "$1");
const ROMAN = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII", "XIII", "XIV", "XV", "XVI", "XVII", "XVIII", "XIX", "XX", "XXI", "XXII", "XXIII", "XXIV"];
export const roman = (n: number) => ROMAN[n] ?? String(n);
+30
View File
@@ -0,0 +1,30 @@
// About: track, saga, story time, the Odyssey reference (with "Read the passage" when the content folder is
// mounted), the facts, and the parts and singers. Watch and the listen entry live in Listen & lyrics.
import { sagaOf, songs, type AlbumSong } from "../../data/album";
import { roman } from "../content";
import { esc } from "./util";
export function aboutView(song: AlbumSong, mounted: boolean): HTMLElement {
const el = document.createElement("div");
el.className = "about";
const saga = sagaOf(song);
const o = song.odyssey;
const homer = !o ? "No counterpart in Homer"
: mounted
? `<span class="odref">Book ${roman(o.book)}</span><button type="button" class="readbtn" data-act="read">Read the passage <span aria-hidden="true">→</span></button>`
: `<span class="odref">Book ${roman(o.book)}</span> · <a href="${esc(o.url)}" target="_blank" rel="noopener noreferrer">Butcher &amp; Lang on Gutenberg</a>`;
const parts = song.parts.length
? song.parts.map((p) => `<li><span class="parts__name">${esc(p.name)}</span><span class="parts__who">${p.singers.map(esc).join(", ")}</span></li>`).join("")
: song.singers.map((s) => `<li><span class="parts__name">${esc(s)}</span></li>`).join("");
el.innerHTML = `
<dl class="facts">
<div><dt>Track</dt><dd>${song.track} of ${songs.length} · ${song.sagaTrack} in the saga</dd></div>
<div><dt>Saga</dt><dd>${esc(saga.name)} <span class="facts__soft">(${saga.year})</span></dd></div>
<div><dt>Story time</dt><dd>${esc(song.storyTime.label)}</dd></div>
<div><dt>The Odyssey</dt><dd>${homer}</dd></div>
</dl>
${song.facts.map((f) => `<p class="about__fact">${esc(f)}</p>`).join("")}
<h3 class="sec">${song.parts.length ? "Parts and singers" : "Singers"}</h3>
<ol class="parts">${parts}</ol>`;
return el;
}
+90
View File
@@ -0,0 +1,90 @@
// The route chart in Paths: a small vellum sea chart of the nine places of the voyage, Troy to Ithaca, with
// "You are here" on the current song. Redrawn from 17's chart (which had two places) for the whole album.
// Stylised, not geographic: the course wanders west from Troy and loops back east to Ithaca.
import { sagas, songsInSaga, type AlbumSong, type PlaceId } from "../../data/album";
type Pt = [number, number];
/** Each place: its port on the 320×210 card, its short name and where the name sits. */
const PLACES: Record<PlaceId, { at: Pt; name: string; dx: number; dy: number; anchor?: "start" | "middle" | "end" }> = {
troy: { at: [286, 44], name: "Troy", dx: 0, dy: -9, anchor: "middle" },
cyclops: { at: [238, 86], name: "Cyclops' isle", dx: 0, dy: 19, anchor: "middle" },
ocean: { at: [186, 42], name: "Open sea", dx: 0, dy: -9, anchor: "middle" },
circe: { at: [132, 82], name: "Aeaea", dx: 0, dy: 19, anchor: "middle" },
underworld: { at: [66, 40], name: "Underworld", dx: 0, dy: -9, anchor: "middle" },
thunder: { at: [40, 120], name: "Scylla", dx: 8, dy: 4, anchor: "start" },
wisdom: { at: [100, 166], name: "Olympus", dx: 0, dy: 19, anchor: "middle" },
vengeance: { at: [164, 128], name: "Charybdis", dx: 0, dy: -9, anchor: "middle" },
ithaca: { at: [232, 170], name: "Ithaca", dx: -10, dy: 5, anchor: "end" },
};
const ORDER = sagas.map((s) => s.id);
const pts = ORDER.map((id) => PLACES[id].at);
const f = (n: number) => n.toFixed(1);
/** A smooth course through every port (Catmull-Rom as cubic Béziers). */
function course(): string {
let d = `M${f(pts[0]![0])},${f(pts[0]![1])}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] ?? pts[i]!, p1 = pts[i]!, p2 = pts[i + 1]!, p3 = pts[i + 2] ?? p2;
const c1: Pt = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
const c2: Pt = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
d += ` C${f(c1[0])},${f(c1[1])} ${f(c2[0])},${f(c2[1])} ${f(p2[0])},${f(p2[1])}`;
}
return d;
}
/** Where "You are here" sits for a song: at its saga's port, edging along the course as the saga goes on. */
export function hereAt(song: AlbumSong): Pt {
const i = ORDER.indexOf(song.sagaId);
const p = pts[i]!, next = pts[i + 1];
if (!next) return p;
const n = songsInSaga(song.sagaId).length;
const k = n > 1 ? ((song.sagaTrack - 1) / n) * 0.6 : 0;
return [p[0] + (next[0] - p[0]) * k, p[1] + (next[1] - p[1]) * k];
}
export function chartSvg(song: AlbumSong): string {
const [hx, hy] = hereAt(song);
const cur = ORDER.indexOf(song.sagaId);
const ports = ORDER.map((id, i) => {
const p = PLACES[id];
const cls = i < cur ? " is-past" : i === cur ? " is-current" : "";
return `<g class="c-stop${cls}"><circle class="c-port" cx="${p.at[0]}" cy="${p.at[1]}" r="${i === cur ? 4 : 3}"/>
<text class="c-place" x="${p.at[0] + p.dx}" y="${p.at[1] + p.dy}" text-anchor="${p.anchor ?? "start"}">${p.name}</text></g>`;
}).join("");
let rhumbs = "";
for (let i = 0; i < 16; i++) {
const a = (i / 16) * Math.PI * 2;
rhumbs += `M160,105 L${(160 + Math.cos(a) * 240).toFixed(0)},${(105 + Math.sin(a) * 240).toFixed(0)} `;
}
// "You are here" sits on the side away from the current place's own name
const own = PLACES[song.sagaId];
const label = own.anchor === "end" ? { x: 10, y: -9, a: "start" }
: own.dy < 0 && own.anchor === "middle" ? { x: 0, y: 21, a: "middle" }
: { x: 0, y: -11, a: "middle" };
return `<svg class="chart__svg" viewBox="0 0 320 210" role="img" aria-label="Chart of the voyage from Troy to Ithaca; you are at ${sagas[cur]!.place}">
<defs><clipPath id="chart-clip"><rect x="6" y="6" width="308" height="198"/></clipPath></defs>
<rect class="c-vellum" x="0" y="0" width="320" height="210"/>
<g clip-path="url(#chart-clip)">
<path class="c-rhumb" d="${rhumbs}"/>
<path class="c-land" d="M262,0 Q270,26 300,30 Q312,46 320,52 V0 Z"/>
<path class="c-land" d="M320,124 Q288,140 270,168 Q262,190 284,210 H320 Z"/>
<ellipse class="c-land" cx="232" cy="171" rx="13" ry="7"/>
<ellipse class="c-land" cx="238" cy="88" rx="16" ry="9"/>
<ellipse class="c-land" cx="132" cy="84" rx="14" ry="8"/>
<ellipse class="c-land" cx="100" cy="168" rx="18" ry="9"/>
<path class="c-ink" d="M92,168 l8,-11 l8,11"/>
<circle class="c-dark" cx="66" cy="42" r="8"/>
<path class="c-land" d="M0,92 Q26,104 34,124 Q30,150 0,160 Z"/>
<ellipse class="c-ink" cx="164" cy="130" rx="11" ry="5"/><ellipse class="c-ink" cx="164" cy="130" rx="6" ry="2.6"/>
<path class="c-course" d="${course()}"/>
${ports}
<g class="c-rose" transform="translate(296,104)"><circle r="10"/><path d="M0,-14 L3,0 L0,14 L-3,0 Z M-14,0 L0,3 L14,0 L0,-3 Z"/></g>
</g>
<g class="c-here" transform="translate(${f(hx)},${f(hy)})">
<circle class="c-here__ring" r="7"/>
<circle class="c-here__dot" r="3"/>
<text class="c-here__label" x="${label.x}" y="${label.y}" text-anchor="${label.a}">You are here</text>
</g>
<rect class="c-frame" x="4" y="4" width="312" height="202"/>
</svg>`;
}
+25
View File
@@ -0,0 +1,25 @@
// Fan works: credited, linked fan works for the song; an empty state while none are collected (v1).
import { fanWorks, type AlbumSong, type FanWork } from "../../data/album";
import { esc } from "./util";
const TYPE_LABEL: Record<FanWork["type"], string> = {
art: "Art", animatic: "Animatic", cover: "Cover", cosplay: "Cosplay", other: "Fan work",
};
export function fanView(song: AlbumSong): HTMLElement {
const el = document.createElement("div");
el.className = "fan";
const works = fanWorks.filter((w) => w.songId === song.id);
el.innerHTML = works.length
? `<ul class="fw">${works.map((w) => `<li class="fw__card">
<p class="fw__type">${TYPE_LABEL[w.type]}</p>
<p class="fw__title"><a href="${esc(w.url)}" target="_blank" rel="noopener noreferrer">${esc(w.title)}</a></p>
<p class="fw__by">by ${esc(w.creator)}</p>
<p class="fw__credit">${esc(w.permission)}</p>
</li>`).join("")}</ul>`
: `<div class="fan__empty">
<svg class="fan__glyph" viewBox="0 0 72 44" aria-hidden="true"><path class="g-line" d="M36 28 V5"/><path class="g-sail" d="M37 7 L52 24 H37 Z"/><path class="g-hull" d="M12 28 Q36 40 60 28 Z"/><path class="g-line" d="M4 39 q6 -4 12 0 t12 0 t12 0 t12 0 t12 0"/></svg>
<p class="fan__lead">No fan works collected yet. Real ones will be credited and linked to their creators.</p>
</div>`;
return el;
}
+248
View File
@@ -0,0 +1,248 @@
// Listen & lyrics: the player (real audio, seek bar, cover art), the line-synced lyrics with the current
// line lit and click-to-seek, the song's parts and motifs, and Watch (YouTube link cards). Ported from 17's
// panel.ts/player.ts; without the content folder there is no player, only a note (no simulated playback).
import type { AlbumSong } from "../../data/album";
import type { Song } from "../../data/types";
import { createVideoCards } from "../../shared/video-cards";
import { loadLyrics, type Lyrics, type RealSong } from "../content";
import { AudioPlayer } from "./player";
import { dot, esc, fmt, motifsOf, PAUSE_ICON, PLAY_ICON } from "./util";
type LyricsState = Lyrics | "none" | "loading" | "failed";
export interface ListenDeps {
/** Where the hidden <audio> element lives. */
host: HTMLElement;
/** The panel's scrolling view, which the lyrics follow inside. */
view: HTMLElement;
reduced: () => boolean;
/** Called on every player change (time, play/pause), for the panel header. */
onTick: () => void;
/** Called when what Listen shows changes (lyrics arrived, the file failed): re-render it if shown. */
onContent: () => void;
}
export type Listen = ReturnType<typeof createListen>;
export function createListen(deps: ListenDeps) {
let song: AlbumSong | null = null;
let real: RealSong | null = null;
let mounted = false;
let lyrics: LyricsState = "none";
/** The lit line, the line last scrolled to, and until when the reader's own scroll wins. */
let lineNow = -1;
let lineScrolled = -1;
let userScrollUntil = 0;
const player = new AudioPlayer(deps.host, () => {
if (player.failed && real) { real = null; deps.onContent(); }
sync();
deps.onTick();
});
function setSong(next: AlbumSong, nextReal: RealSong | null, isMounted: boolean) {
mounted = isMounted;
const same = song?.id === next.id && real?.audio === nextReal?.audio;
song = next;
if (same) return;
real = nextReal;
lyrics = "none";
lineNow = -1;
lineScrolled = -1;
player.load(real?.audio ?? null, next.id);
if (real?.lyrics) {
lyrics = "loading";
const id = next.id;
loadLyrics(real.lyrics).then((l) => {
if (song?.id !== id) return;
lyrics = l ?? "failed";
deps.onContent();
});
}
}
const timed = (): Extract<Lyrics, { timed: true }> | null =>
typeof lyrics === "object" && lyrics.timed ? lyrics : null;
function render(): HTMLElement {
const s = song!;
const el = document.createElement("div");
el.className = "listen";
const ms = motifsOf(s);
const parts = s.parts.length ? s.parts.map((p) => esc(p.name)).join(" · ") : s.singers.map(esc).join(", ");
el.innerHTML = `
${player.loaded ? playerHtml(s) : noPlayerHtml()}
<section class="psum" aria-label="Parts and motifs">
<p class="psum__row"><span class="psum__k">${s.parts.length ? "Parts" : "Singers"}</span><span class="psum__v">${parts}</span></p>
${ms.length ? `<p class="psum__row"><span class="psum__k">Motifs</span><span class="psum__chips">${ms.map((m) =>
`<button type="button" class="mtag" data-m="${m.id}" data-pick="${m.id}" aria-pressed="false">${dot(m)}${esc(m.name)}</button>`).join("")}</span></p>` : ""}
<p class="psum__note">Parts and motifs are not yet timed to the recording.</p>
</section>
<section class="watch" aria-label="Watch">
<h3 class="sec">Watch</h3>
<div class="watch__cards"></div>
</section>
<section class="lyrics" aria-label="Lyrics">
<h3 class="sec">Lyrics</h3>
${lyricsHtml()}
</section>`;
// createVideoCards only reads `videos`; the album song has the same shape for it
el.querySelector(".watch__cards")!.append(createVideoCards(s as unknown as Song));
wireScrub(el);
lineNow = -1;
return el;
}
function playerHtml(s: AlbumSong): string {
const r = real!;
return `
<section class="player player--real" aria-label="Player">
<div class="player__top">
${r.cover ? `<img class="player__cover" src="${esc(r.cover)}" alt="" width="56" height="56" />` : ""}
<div class="player__meta">
<p class="player__label">Track ${s.track} · ${esc(s.title)}</p>
<p class="player__time"><span data-elapsed>0:00</span> <span class="player__of">/ <span data-total>…</span></span></p>
</div>
<button type="button" class="player__play" data-act="toggle" aria-pressed="false"></button>
</div>
<div class="scrub" role="slider" tabindex="0" aria-label="Position in ${esc(s.title)}" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
<div class="scrub__track"><div class="scrub__fill" data-fill></div><div class="scrub__head" data-head></div></div>
</div>
</section>`;
}
function noPlayerHtml(): string {
const why = !mounted
? `<b>Content folder not mounted.</b> The recording and its lyrics play here when the server has <code>CONTENT_DIR</code>.`
: player.failed ? `<b>The recording could not be loaded.</b> Check the file in the content folder.`
: `<b>This song is not in the content folder.</b> Its recording and lyrics play here once it is added.`;
return `<section class="player player--none" aria-label="Player"><p class="cnote">${why}</p></section>`;
}
function lyricsHtml(): string {
if (!real) return `<p class="note">Lyrics are shown only from the mounted content folder, with the recording.</p>`;
if (lyrics === "loading") return `<p class="hint">Loading the lyrics…</p>`;
if (lyrics === "failed") return `<p class="note">The lyrics file could not be loaded.</p>`;
if (lyrics === "none") return `<p class="note">This song has no lyrics file in the content folder.</p>`;
if (!lyrics.timed) {
return `<p class="note">These lyrics are not timed, so they do not follow the song.</p>
<div class="lyrics__plain">${lyrics.lines.map((l) => (l ? `<p>${esc(l)}</p>` : `<p class="lyrics__gap" aria-hidden="true"></p>`)).join("")}</div>`;
}
return `<p class="hint">Follows the song. Click a line to play from there.</p>
<ol class="lyrics__lines">${lyrics.lines.map((l, i) => (l.text
? `<li><button type="button" class="ly" data-line="${i}" title="Play from ${fmt(l.time)}"><span class="ly__at">${fmt(l.time)}</span><span class="ly__x">${esc(l.text)}</span></button></li>`
: `<li class="ly ly--gap" data-line="${i}" aria-hidden="true"><span class="ly__x">· · ·</span></li>`)).join("")}</ol>`;
}
function wireScrub(el: HTMLElement) {
const scrub = el.querySelector<HTMLElement>(".scrub");
if (!scrub) return;
const seekAt = (clientX: number) => {
if (!player.ready) return;
const r = scrub.getBoundingClientRect();
player.seek(((clientX - r.left) / r.width) * player.dur);
followNow();
};
scrub.addEventListener("pointerdown", (e) => {
seekAt(e.clientX);
scrub.setPointerCapture?.(e.pointerId);
const move = (ev: PointerEvent) => seekAt(ev.clientX);
scrub.addEventListener("pointermove", move);
scrub.addEventListener("pointerup", () => scrub.removeEventListener("pointermove", move), { once: true });
});
scrub.addEventListener("keydown", (e) => {
const d = e.key === "ArrowRight" ? 5 : e.key === "ArrowLeft" ? -5 : 0;
if (d) { e.preventDefault(); player.seek(player.t + d); followNow(); }
});
}
/** After a seek or a line click, the lyrics follow the song again at once. */
function followNow() { userScrollUntil = 0; lineScrolled = -1; }
/** The reader scrolled the lyrics: their scroll wins for ~4 s. */
function userScrolled() { userScrollUntil = performance.now() + 4000; lineScrolled = -1; }
/** Index of the timed line playing at time t (a small lead so the highlight lands with the voice). */
function lineAt(t: number): number {
const ly = timed();
if (!ly) return -1;
let k = -1;
for (let i = 0; i < ly.lines.length && ly.lines[i]!.time <= t + 0.15; i++) k = i;
return k;
}
/** Brings a lyric line to about a third of the way down the space under the sticky player. */
function scrollToLine(line: HTMLElement) {
const view = deps.view;
const card = view.querySelector<HTMLElement>(".player--real");
const capH = card ? card.offsetHeight : 0;
const vr = view.getBoundingClientRect();
const lr = line.getBoundingClientRect();
const top = view.scrollTop + (lr.top - vr.top) - (capH + (view.clientHeight - capH) * 0.3);
view.scrollTo?.({ top: Math.max(0, top), behavior: deps.reduced() ? "auto" : "smooth" });
}
/** Reflects the player in the Listen view, if it is shown. */
function sync() {
const lv = deps.view.querySelector<HTMLElement>(".listen");
if (!lv) { lineNow = -1; return; }
const pb = lv.querySelector<HTMLButtonElement>(".player__play");
if (!pb) return;
const on = player.playing;
const pct = player.ready && player.dur ? Math.min(100, (player.t / player.dur) * 100) : 0;
lv.querySelector<HTMLElement>("[data-fill]")!.style.width = `${pct}%`;
lv.querySelector<HTMLElement>("[data-head]")!.style.left = `${pct}%`;
lv.querySelector("[data-elapsed]")!.textContent = fmt(player.t);
lv.querySelector("[data-total]")!.textContent = player.ready ? fmt(player.dur) : "…";
// buttons are only rewritten when their state changes, so a click is never lost to a re-render
if (pb.dataset.on !== String(on)) {
pb.dataset.on = String(on);
pb.innerHTML = `${on ? PAUSE_ICON : PLAY_ICON}<span class="sr">${on ? "Pause" : "Play"}</span>`;
pb.setAttribute("aria-pressed", String(on));
}
const scrub = lv.querySelector<HTMLElement>(".scrub")!;
scrub.setAttribute("aria-valuemax", String(Math.round(player.dur)));
scrub.setAttribute("aria-valuenow", String(Math.round(player.t)));
scrub.setAttribute("aria-valuetext", `${fmt(player.t)} of ${player.ready ? fmt(player.dur) : "unknown length"}`);
const k = player.t > 0 || on ? lineAt(player.t) : -1;
if (k !== lineNow) {
lineNow = k;
lv.querySelectorAll<HTMLElement>("[data-line]").forEach((n) => {
const i = Number(n.dataset.line);
n.classList.toggle("is-now", i === k);
n.classList.toggle("is-past", i < k);
if (i === k) n.setAttribute("aria-current", "true"); else n.removeAttribute("aria-current");
});
}
if (k >= 0 && k !== lineScrolled && performance.now() >= userScrollUntil) {
const el = lv.querySelector<HTMLElement>(`[data-line="${k}"]`);
if (el) { lineScrolled = k; scrollToLine(el); }
}
}
/** Handles a click inside the Listen view; true if it was one of ours. */
function click(target: Element): boolean {
const line = target.closest<HTMLElement>("button[data-line]");
const ly = timed();
if (line && ly) {
player.seek(ly.lines[Number(line.dataset.line)]!.time);
followNow();
player.play();
return true;
}
if (target.closest<HTMLElement>("[data-act=toggle]")) { player.toggle(); return true; }
return false;
}
return {
player,
setSong,
render,
sync,
click,
userScrolled,
/** Seek to t seconds and play (e.g. for a deep link into a song). */
playAt(t: number) { player.seek(t); followNow(); player.play(); },
destroy() { player.destroy(); },
};
}
+502 -1
View File
@@ -1 +1,502 @@
/* the side panel (plan task 4) */
/* The side panel (plan task 4): About · Listen & lyrics · Paths · Fan works. Ported from mockup 17's panel
styles onto the app's tokens (src/app/style.css). Generic names are scoped under .panel. */
/* ---------- frame ---------- */
.panel {
position: relative;
z-index: 8;
background:
repeating-linear-gradient(90deg, rgb(91 65 40 / 0.03) 0 1px, transparent 1px 7px),
linear-gradient(180deg, var(--vellum-hi), #e6d9b8);
color: var(--iron);
box-shadow: -14px 0 30px -18px rgb(0 0 0 / 0.7);
font-size: var(--fs-body);
line-height: 1.5;
}
.panel.meander-edge::before { z-index: 3; height: 18px; background-size: 18px 18px; border-top: 0; }
.panel__body {
position: absolute;
inset: 0;
width: calc(var(--panel-w) - 2px);
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
padding-top: 18px;
transition: opacity 0.25s ease, transform 0.42s var(--ease);
}
.panel[data-open="false"] .panel__body { opacity: 0; transform: translateX(40px); pointer-events: none; }
.panel__head { display: flex; align-items: flex-start; gap: 14px; padding: 18px 20px 12px 22px; }
.panel__song { flex: 1; min-width: 0; }
.panel__eyebrow { margin: 0; font-size: var(--fs-label); font-weight: 600; color: var(--clay-deep); font-variant-numeric: tabular-nums; }
.panel__eyebrow.is-live::before { content: ""; display: inline-block; width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: var(--clay); box-shadow: 0 0 0 1.5px var(--glaze); vertical-align: 1px; }
.panel__title { margin: 2px 0 4px; font: 600 var(--fs-title)/1.05 var(--serif); overflow-wrap: break-word; text-wrap: balance; }
.panel__sub { margin: 0; font-size: var(--fs-body); color: var(--iron-soft); }
.pplay, .player__play {
flex: none;
display: grid; place-items: center;
width: 52px; height: 52px;
margin-top: 4px;
border: 2px solid var(--glaze);
border-radius: 50%;
background: var(--clay);
color: var(--glaze);
cursor: pointer;
box-shadow: 0 3px 0 var(--glaze);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.pplay svg, .player__play svg { width: 22px; height: 22px; fill: currentColor; }
.pplay:hover, .player__play:hover { transform: translateY(-1px); box-shadow: 0 4px 0 var(--glaze); }
.pplay:active, .player__play:active { transform: translateY(2px); box-shadow: 0 1px 0 var(--glaze); }
.pplay[aria-pressed="true"], .player__play[aria-pressed="true"] { background: var(--glaze); color: var(--added-white); }
.panel__hide {
flex: none;
margin-top: 2px;
padding: 5px 12px;
border: 1.5px solid var(--line);
border-radius: 999px;
background: transparent;
font-size: var(--fs-small);
font-weight: 500;
color: var(--iron-soft);
cursor: pointer;
}
.panel__hide:hover { color: var(--iron); border-color: var(--glaze); }
.ptabs { display: flex; gap: 2px; padding: 0 20px 0 22px; border-bottom: 1.5px solid var(--glaze); }
.ptabs button {
position: relative;
padding: 11px 9px 10px;
border: 0;
background: transparent;
color: var(--iron-soft);
font-size: var(--fs-body);
font-weight: 500;
white-space: nowrap;
cursor: pointer;
}
.ptabs button:hover { color: var(--iron); }
.ptabs button[aria-selected="true"] { color: var(--iron); font-weight: 600; }
.ptabs button[aria-selected="true"]::after { content: ""; position: absolute; left: 8px; right: 8px; bottom: -1.5px; height: 4px; background: var(--clay); border: 1px solid var(--glaze); border-bottom: 0; }
.panel__view {
min-height: 0;
overflow: auto;
padding: 18px 20px 28px 22px;
scrollbar-width: thin;
scrollbar-color: var(--line) transparent;
}
.panel.is-swapped .panel__view, .panel.is-swapped .panel__song { animation: panel-settle 0.36s var(--ease); }
@keyframes panel-settle { from { opacity: 0; transform: translateY(6px); } }
/* the slim tab, shown when collapsed */
.panel__tab {
position: absolute;
inset: 18px 0 0;
width: var(--panel-slim);
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 22px 0;
border: 0;
background: transparent;
color: var(--iron);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease 0.1s;
}
.panel[data-open="false"] .panel__tab { opacity: 1; pointer-events: auto; }
.panel__tab-arrow { font-size: 1.5rem; line-height: 1; color: var(--clay-deep); }
.panel__tab-n {
display: inline-grid; place-items: center;
width: 30px; height: 30px;
border: 1.5px solid var(--glaze); border-radius: 50%;
background: var(--clay); color: var(--glaze);
font: 700 var(--fs-body) var(--sans);
}
.panel__tab-label { writing-mode: vertical-rl; font-size: var(--fs-body); font-weight: 600; }
/* ---------- shared content ---------- */
.panel .sec { margin: 26px 0 8px; font: 600 1.375rem/1.2 var(--serif); }
.panel__view > div > .sec:first-child { margin-top: 0; }
.panel .note {
margin: 0 0 14px;
padding: 9px 12px;
border: 1px dashed rgb(43 33 24 / 0.35);
border-radius: 4px;
color: var(--iron-soft);
font-size: var(--fs-small);
}
.panel .hint { margin: 0 0 10px; color: var(--iron-soft); font-size: var(--fs-small); }
.panel .cnote {
margin: 0;
padding: 10px 12px;
border-left: 4px solid var(--clay);
background: var(--vellum-hi);
color: var(--iron-soft);
font-size: var(--fs-small);
}
.panel .cnote b { color: var(--iron); font-weight: 600; }
.panel .cnote code { font-size: var(--fs-label); }
.mdot { flex: none; display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 1.5px var(--glaze); }
/* ---------- About ---------- */
.panel .facts { margin: 0; display: grid; }
.panel .facts div { display: grid; grid-template-columns: 112px 1fr; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); }
.panel .facts dt { font-size: var(--fs-small); font-weight: 600; color: var(--iron-soft); }
.panel .facts dd { margin: 0; }
.facts__soft { color: var(--iron-soft); }
.panel .facts a { color: var(--clay-deep); text-underline-offset: 3px; }
.about__fact { margin: 14px 0 0; font: italic 500 1.25rem/1.35 var(--serif); }
.panel .parts { margin: 0; padding: 0; list-style: none; }
.panel .parts li { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; border-bottom: 1px solid var(--line); }
.parts__name { font-weight: 600; }
.parts__who { color: var(--iron-soft); text-align: right; }
.odref { font-weight: 600; }
.readbtn {
display: inline-flex; align-items: center; gap: 6px;
margin-left: 10px;
padding: 2px 12px;
border: 1.5px solid var(--glaze);
border-radius: 999px;
background: var(--vellum-hi);
color: var(--iron);
font-size: var(--fs-small); font-weight: 600;
cursor: pointer;
}
.readbtn:hover { background: var(--clay); }
/* ---------- the reading view ---------- */
.reading__back {
display: inline-flex; gap: 6px;
margin: 0 0 12px;
padding: 4px 0;
border: 0;
background: transparent;
color: var(--clay-deep);
font-size: var(--fs-body); font-weight: 600;
cursor: pointer;
}
.reading__back:hover { text-decoration: underline; text-underline-offset: 3px; }
.reading__eyebrow { margin: 0; font-size: var(--fs-small); font-weight: 600; color: var(--iron-soft); }
.reading__title { margin: 2px 0 6px; font: 600 1.75rem/1.15 var(--serif); }
.reading__sum { margin: 0 0 14px; font-size: var(--fs-label); font-weight: 600; line-height: 1.45; letter-spacing: 0.02em; color: var(--iron-soft); }
.reading__more {
display: block;
width: 100%;
margin: 0 0 6px;
padding: 8px 12px;
border: 1px dashed rgb(43 33 24 / 0.45);
border-radius: 4px;
background: transparent;
color: var(--iron-soft);
font-size: var(--fs-small); font-weight: 600;
cursor: pointer;
}
.reading__more:hover { color: var(--iron); border-color: var(--glaze); }
.reading__mark {
position: relative;
margin: 18px 0 10px;
padding: 16px 0 0;
color: var(--clay-deep);
font-size: var(--fs-small); font-weight: 600;
scroll-margin-top: 8px;
}
.reading__mark::before {
content: "";
position: absolute; left: 0; right: 0; top: 0;
height: 8px;
background: var(--meander) 0 0 / 8px 8px repeat-x;
border-top: 1px solid var(--glaze);
border-bottom: 1px solid var(--glaze);
}
.reading__p { margin: 0 0 12px; font: 500 1.1875rem/1.5 var(--serif); hanging-punctuation: first; }
.reading__p mark { padding: 0 2px; border-radius: 2px; background: rgb(196 106 51 / 0.3); color: inherit; }
.reading__end { margin: 18px 0 0; font-size: var(--fs-label); color: var(--iron-soft); text-align: center; }
/* ---------- Listen & lyrics: the player ---------- */
.panel .player {
position: relative;
margin: 0 0 16px;
padding: 22px 16px 14px;
border: 1.5px solid var(--glaze);
border-radius: 2px;
background: #f3ecd8;
overflow: hidden;
}
.panel .player::before {
content: "";
position: absolute; left: 0; right: 0; top: 0;
height: 10px;
background: var(--meander) 0 0 / 10px 10px repeat-x;
border-bottom: 1.5px solid var(--glaze);
}
.panel .player--none { padding: 22px 0 0; }
.panel .player--none .cnote { border-left: 0; background: transparent; padding: 10px 14px 12px; }
/* it stays pinned while the lyrics scroll under it */
.panel .player--real {
position: sticky;
top: -18px;
z-index: 2;
margin-top: -18px;
border-top-width: 0;
box-shadow: 0 10px 14px -12px rgb(29 20 14 / 0.55);
}
.player__top { display: flex; align-items: center; gap: 12px; }
.player__play { margin-top: 0; width: 56px; height: 56px; }
.player__play svg { width: 24px; height: 24px; }
.player__meta { flex: 1; min-width: 0; }
.player__cover {
flex: none;
width: 56px; height: 56px;
border: 1.5px solid var(--glaze);
border-radius: 2px;
object-fit: cover;
background: var(--glaze);
}
.player__label { margin: 0; font-size: var(--fs-small); color: var(--iron-soft); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.player__time { margin: 0; font-size: 1.0625rem; font-weight: 600; font-variant-numeric: tabular-nums; }
.player__of { font-weight: 400; color: var(--iron-soft); }
.scrub { position: relative; margin: 14px 0 0; padding: 8px 0; cursor: pointer; touch-action: none; }
.scrub:focus-visible { outline-offset: 0; }
.scrub__track { position: relative; height: 8px; border: 1.5px solid var(--glaze); border-radius: 999px; background: var(--vellum-lo); }
.scrub__fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; border-radius: 999px; background: var(--clay); }
.scrub__head { position: absolute; top: 50%; left: 0; width: 18px; height: 18px; margin: -9px 0 0 -9px; border: 2px solid var(--glaze); border-radius: 50%; background: var(--added-white); }
/* the song's parts and motifs, compact, above the lyrics */
.psum { margin: 0 0 4px; padding: 0 0 12px; border-bottom: 1px solid var(--line); }
.psum__row { display: flex; align-items: baseline; gap: 10px; margin: 0 0 6px; }
.psum__k { flex: none; width: 62px; font-size: var(--fs-small); font-weight: 600; color: var(--iron-soft); }
.psum__v { font-size: var(--fs-body); line-height: 1.4; }
.psum__chips { display: flex; flex-wrap: wrap; gap: 6px; }
.psum__note { margin: 2px 0 0 72px; font-size: var(--fs-label); color: var(--iron-soft); }
.mtag {
display: inline-flex; align-items: center; gap: 7px;
padding: 2px 10px 2px 7px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgb(255 250 236 / 0.5);
color: var(--iron);
font-size: var(--fs-label); font-weight: 600;
cursor: pointer;
transition: opacity 0.2s ease;
}
.mtag .mdot { width: 9px; height: 9px; }
.mtag:hover, .mtag.is-on { border-color: var(--glaze); }
.mtag.is-on { background: var(--vellum-lo); box-shadow: inset 0 -3px 0 var(--c); }
.mtag.is-dim { opacity: 0.5; }
/* Watch: YouTube link cards */
.watch .sec { margin-top: 18px; }
.panel .video-cards { display: flex; flex-wrap: wrap; gap: 8px; }
.panel .video-card {
position: relative;
padding: 9px 14px 9px 36px;
border: 1.5px solid var(--glaze);
border-radius: 2px;
background: #f3ecd8;
color: var(--iron);
font-weight: 600;
text-decoration: none;
}
.panel .video-card:hover { background: var(--vellum-hi); text-decoration: underline; text-underline-offset: 3px; }
.panel .video-card::before {
content: "";
position: absolute;
left: 13px; top: 50%;
border: 6px solid transparent;
border-left: 9px solid var(--clay);
transform: translateY(-50%);
}
/* synced lyrics: the line playing now is lit; earlier lines are sung */
.lyrics .sec { margin-top: 22px; }
.lyrics__lines { margin: 0; padding: 0; list-style: none; }
.ly {
display: grid;
grid-template-columns: 46px 1fr;
align-items: baseline;
width: 100%;
margin: 0;
padding: 4px 10px 4px 0;
border: 0;
border-radius: 2px;
background: transparent;
color: var(--iron);
text-align: left;
cursor: pointer;
transition: background-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease;
}
.ly__at {
justify-self: end;
padding-right: 8px;
font-size: var(--fs-label);
font-variant-numeric: tabular-nums;
color: var(--iron-soft);
opacity: 0;
transition: opacity 0.15s ease;
}
.ly__x { font: italic 500 1.1875rem/1.4 var(--serif); }
.ly:hover .ly__at, .ly:focus-visible .ly__at { opacity: 1; }
.ly:hover .ly__x { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 4px; text-decoration-color: rgb(43 33 24 / 0.4); }
.ly.is-past { color: var(--iron-soft); }
.ly.is-now { background: rgb(196 106 51 / 0.18); box-shadow: inset 0 0 0 1.5px var(--glaze); color: var(--iron); }
.ly.is-now .ly__at { opacity: 1; color: var(--glaze); font-weight: 600; }
.ly.is-now .ly__x { font-weight: 700; }
.ly--gap { cursor: default; padding-top: 2px; padding-bottom: 2px; }
.ly--gap .ly__x { grid-column: 2; font: 500 var(--fs-label)/1 var(--sans); color: rgb(90 72 54 / 0.6); letter-spacing: 0.3em; }
.ly--gap.is-now { background: transparent; box-shadow: inset 3px 0 0 var(--clay); }
.lyrics__plain p { margin: 2px 0; font: italic 500 1.1875rem/1.4 var(--serif); }
.lyrics__plain .lyrics__gap { height: 12px; }
/* ---------- Paths ---------- */
/* the motif strip: the bottom timeline in miniature, shown while the timeline is hidden */
.motif-strip {
position: relative;
margin: 0 0 6px;
padding: 18px 0 10px;
border: 1.5px solid var(--glaze);
border-radius: 2px;
background: var(--vellum-hi);
overflow: hidden;
}
.motif-strip::before {
content: "";
position: absolute; left: 0; right: 0; top: 0;
height: 8px;
background: var(--meander) 0 0 / 8px 8px repeat-x;
border-bottom: 1.5px solid var(--glaze);
}
.ms__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; margin: 0 10px 2px; }
.ms__title { font-size: var(--fs-small); font-weight: 600; }
.ms__sub { font-size: var(--fs-label); color: var(--iron-soft); }
.ms__body { position: relative; width: calc(var(--w) * 1px); height: calc(var(--h) * 1px); margin: 0 auto; }
.ms__svg { display: block; width: calc(var(--w) * 1px); height: calc(var(--h) * 1px); overflow: visible; }
.ms__band { fill: rgb(196 106 51 / 0.18); }
.ms__course { fill: none; stroke: var(--clay-deep); stroke-width: 1.4; stroke-dasharray: 4 3; }
.ms__saga { stroke: var(--glaze); stroke-width: 1; opacity: 0.45; }
.ms__stop { fill: var(--iron-soft); cursor: pointer; }
.ms__stop:hover { fill: var(--clay-deep); }
.ms__stop.is-current { fill: var(--clay); stroke: var(--glaze); stroke-width: 1.8; }
.ms__hull { fill: var(--clay); stroke: var(--glaze); stroke-width: 1.3; stroke-linejoin: round; }
.ms__mast { stroke: var(--glaze); stroke-width: 1.5; }
.ms__thread { transition: opacity 0.2s ease; }
.ms__edge { fill: none; stroke: var(--glaze); stroke-width: 4.6; stroke-linecap: round; }
.ms__line { fill: none; stroke: var(--c); stroke-width: 2.6; stroke-linecap: round; }
.ms__thread.is-on .ms__edge { stroke-width: 6.4; }
.ms__thread.is-on .ms__line { stroke-width: 4.2; }
.ms__knot { fill: var(--c); stroke: var(--glaze); stroke-width: 1.3; }
.ms__knot.is-here { stroke-width: 2; }
.ms__thread.is-dim { opacity: 0.25; }
.ms__name {
position: absolute; left: 4px;
display: inline-flex; align-items: center; gap: 7px;
padding: 0 6px 0 4px;
border: 0; border-radius: 2px;
background: rgb(236 226 198 / 0.85);
color: var(--iron);
font-size: var(--fs-label); font-weight: 600; line-height: 1.5;
cursor: pointer;
}
.ms__name .mdot { width: 9px; height: 9px; }
.ms__name:hover, .ms__name.is-on { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 3px; }
.ms__name.is-dim { opacity: 0.5; }
/* per-motif rows: heard before · here · returns */
.mrows { margin: 0 0 12px; padding: 0; list-style: none; display: grid; gap: 8px; }
.mrow { border: 1px solid var(--line); border-radius: 2px; background: rgb(255 250 236 / 0.35); transition: opacity 0.2s ease; }
.mrow.is-on { border-color: var(--glaze); background: var(--vellum-hi); }
.mrow.is-dim { opacity: 0.55; }
.mrow__name {
width: 100%;
display: flex; align-items: center; gap: 8px;
padding: 8px 12px 6px;
border: 0;
background: transparent;
text-align: left;
font-weight: 600;
cursor: pointer;
}
.mrow__name:hover > span:first-of-type { text-decoration: underline; text-underline-offset: 3px; }
.mrow__kind { margin-left: auto; font-size: var(--fs-label); font-weight: 500; color: var(--iron-soft); }
.mrow__route { margin: 0; padding: 0 12px 8px 30px; display: grid; gap: 2px; }
.mrow__route div { display: grid; grid-template-columns: 92px 1fr; gap: 8px; align-items: baseline; }
.mrow__route dt { font-size: var(--fs-label); font-weight: 600; color: var(--iron-soft); }
.mrow__route dd { margin: 0; font-size: var(--fs-small); }
.mrow__song, .mthread__song {
display: inline-flex; align-items: baseline; gap: 6px;
padding: 0;
border: 0;
background: transparent;
color: var(--iron);
font-size: var(--fs-small); font-weight: 500;
text-align: left;
text-decoration: underline; text-decoration-color: rgb(43 33 24 / 0.35); text-underline-offset: 3px;
cursor: pointer;
}
.mrow__song:hover, .mthread__song:hover { text-decoration-color: var(--clay-deep); color: var(--clay-deep); }
.mrow__n { font-size: var(--fs-label); font-variant-numeric: tabular-nums; color: var(--iron-soft); }
.mrow__none, .mrow__more { color: var(--iron-soft); }
/* the followed motif's whole thread */
.mthread { margin: 0 0 8px; padding: 10px 12px; border-left: 4px solid var(--glaze); background: var(--vellum-hi); }
.mthread__head { display: flex; align-items: center; gap: 8px; margin: 0; font-size: var(--fs-small); }
.mthread__desc { margin: 4px 0 6px; color: var(--iron-soft); font-size: var(--fs-small); }
.mthread__songs { margin: 0; padding: 0; list-style: none; display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; }
.mthread__song.is-current { font-weight: 700; text-decoration: none; }
/* the route chart */
.route {
margin: 0;
padding: 8px 9px 6px;
background: linear-gradient(160deg, #ece2c6, #dccca5);
border: 1.5px solid var(--glaze);
border-radius: 2px;
}
.route figcaption { margin-top: 4px; font-size: var(--fs-small); color: var(--iron-soft); }
.chart__svg { display: block; width: 100%; height: auto; }
.c-vellum { fill: #e6dab9; }
.c-frame { fill: none; stroke: var(--iron); stroke-width: 0.8; }
.c-rhumb { fill: none; stroke: rgb(154 69 32 / 0.18); stroke-width: 0.5; }
.c-land { fill: #c9b07a; stroke: var(--iron); stroke-width: 0.9; }
.c-dark { fill: var(--iron); opacity: 0.75; }
.c-ink { fill: none; stroke: var(--iron); stroke-width: 0.8; }
.c-course { fill: none; stroke: var(--clay-deep); stroke-width: 1.3; stroke-dasharray: 3 2.4; }
.c-port { fill: #f3ebd6; stroke: var(--iron); stroke-width: 0.9; }
.c-stop.is-past .c-port { fill: var(--clay); }
.c-stop.is-current .c-port { fill: var(--clay); stroke-width: 1.4; }
.c-place { font: 600 12px var(--sans); fill: var(--iron); paint-order: stroke; stroke: #e6dab9; stroke-width: 3px; stroke-linejoin: round; }
.c-stop.is-current .c-place { fill: var(--clay-deep); }
.c-rose circle { fill: none; stroke: var(--iron-soft); stroke-width: 0.6; }
.c-rose path { fill: var(--iron-soft); }
.c-here__ring { fill: rgb(196 106 51 / 0.25); stroke: var(--clay-deep); stroke-width: 1.4; }
.c-here__dot { fill: var(--clay-deep); }
.c-here__label { font: 700 12px var(--sans); fill: var(--clay-deep); paint-order: stroke; stroke: #efe6cd; stroke-width: 3px; stroke-linejoin: round; }
/* ---------- Fan works ---------- */
.fan__empty { display: grid; justify-items: center; gap: 10px; padding: 36px 18px; border: 1px dashed rgb(43 33 24 / 0.35); border-radius: 4px; text-align: center; }
.fan__glyph { width: 72px; height: 44px; }
.fan__glyph .g-hull, .fan__glyph .g-sail { fill: var(--clay); stroke: var(--glaze); stroke-width: 2; stroke-linejoin: round; }
.fan__glyph .g-line { fill: none; stroke: var(--glaze); stroke-width: 2; stroke-linecap: round; }
.fan__lead { margin: 0; max-width: 30ch; color: var(--iron-soft); }
.fw { margin: 0; padding: 0; list-style: none; display: grid; gap: 10px; }
.fw__card { padding: 10px 12px; border: 1.5px solid var(--glaze); border-radius: 2px; background: #f3ecd8; }
.fw__card p { margin: 0; }
.fw__type { font-size: var(--fs-label); font-weight: 600; color: var(--clay-deep); }
.fw__title { font-weight: 600; }
.fw__title a { color: var(--iron); text-underline-offset: 3px; }
.fw__by, .fw__credit { font-size: var(--fs-small); color: var(--iron-soft); }
@media (prefers-reduced-motion: reduce) {
.panel__body, .panel__tab { transition: none; }
.panel.is-swapped .panel__view, .panel.is-swapped .panel__song { animation: none; }
}
/* narrow screens: the panel is a bottom sheet (src/app/style.css); its body flows instead of overlaying */
@media (max-width: 900px) {
.panel { height: 50dvh; }
.shell[data-panel="closed"] .panel { height: 56px; }
.panel__body { width: auto; }
.panel__tab { inset: 18px 0 0; width: auto; flex-direction: row; justify-content: center; padding: 8px 0; }
.panel__tab-label { writing-mode: horizontal-tb; }
}
+229
View File
@@ -0,0 +1,229 @@
// 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;
},
};
}
+96
View File
@@ -0,0 +1,96 @@
// Paths: the motif strip (only while the bottom timeline is hidden), then one row per motif of the song with
// where it was heard before, where it plays here and where it returns, the followed motif's whole thread,
// and the chart of the nine places.
import { motifs, sagaOf, songs, type AlbumSong, type Motif } from "../../data/album";
import { chartSvg } from "./chart";
import { dot, esc, hitsOf, motifsOf } from "./util";
export function pathsView(song: AlbumSong, pick: string | null, showStrip: boolean): HTMLElement {
const el = document.createElement("div");
el.className = "paths";
const ms = motifsOf(song);
const followed = pick ? motifs.find((m) => m.id === pick) ?? null : null;
el.innerHTML = `
${showStrip ? motifStrip(song, ms) : ""}
<h3 class="sec">Motif threads</h3>
${ms.length
? `<p class="hint">Each thread is a motif or a character's theme. Pick one to follow it through the album.</p>
<ul class="mrows">${ms.map((m) => motifRow(song, m)).join("")}</ul>`
: `<p class="note">No motifs are recorded for this song yet.</p>`}
${followed ? threadDetail(song, followed) : ""}
<h3 class="sec">The route</h3>
<figure class="route">${chartSvg(song)}<figcaption>${esc(sagaOf(song).place)} · stop ${song.track} of ${songs.length} on the voyage.</figcaption></figure>`;
return el;
}
const songBtn = (s: AlbumSong) =>
`<button type="button" class="mrow__song" data-song="${s.id}"><span class="mrow__n">${s.track}</span>${esc(s.title)}</button>`;
function motifRow(song: AlbumSong, m: Motif): string {
const hits = hitsOf(m.id);
const before = hits.filter((s) => s.track < song.track);
const after = hits.filter((s) => s.track > song.track);
const prev = before.at(-1);
const next = after[0];
const inParts = song.parts.filter((p) => p.motifIds.includes(m.id)).map((p) => p.name);
const here = inParts.length && inParts.length < song.parts.length ? `in ${inParts.map(esc).join(", ")}` : "throughout";
return `<li class="mrow" data-m="${m.id}">
<button type="button" class="mrow__name" data-pick="${m.id}" aria-pressed="false">${dot(m)}<span>${esc(m.name)}</span>
<span class="mrow__kind">${m.kind === "motif" ? "motif" : "character theme"}</span></button>
<dl class="mrow__route">
<div><dt>Heard before</dt><dd>${prev ? songBtn(prev) : `<span class="mrow__none">first heard here</span>`}</dd></div>
<div><dt>Here</dt><dd>${here}</dd></div>
<div><dt>Returns</dt><dd>${next ? songBtn(next) + (after.length > 1 ? ` <span class="mrow__more">+${after.length - 1} more</span>` : "") : `<span class="mrow__none">not heard again</span>`}</dd></div>
</dl>
</li>`;
}
/** The followed motif: what it is and every song it plays in. */
function threadDetail(song: AlbumSong, m: Motif): string {
const hits = hitsOf(m.id);
return `<section class="mthread" data-m="${m.id}" aria-label="${esc(m.name)} through the album">
<p class="mthread__head">${dot(m)}<span>Following <b>${esc(m.name)}</b> · ${hits.length} song${hits.length === 1 ? "" : "s"}</span></p>
<p class="mthread__desc">${esc(m.description)}</p>
<ol class="mthread__songs">${hits.map((s) => `<li><button type="button" class="mthread__song${s.id === song.id ? " is-current" : ""}" data-song="${s.id}"${s.id === song.id ? ' aria-current="true"' : ""}>
<span class="mrow__n">${s.track}</span>${esc(s.title)}</button></li>`).join("")}</ol>
</section>`;
}
/* The motif strip: the bottom timeline in miniature, for when the timeline is hidden. The album's 40 songs
as stops on a course (saga dividers, the ship on the current song), then one thread per motif of this
song, knotted where it plays. */
const W = 356, PAD = 8, COURSE_Y = 26, ROW = 34;
const X = (i: number) => PAD + (i * (W - PAD * 2)) / (songs.length - 1);
function motifStrip(song: AlbumSong, ms: Motif[]): string {
const cur = songs.indexOf(song);
const H = COURSE_Y + 14 + ms.length * ROW;
let svg = `<rect class="ms__band" x="${(X(cur) - 5).toFixed(1)}" y="0" width="10" height="${H}" rx="2"/>`;
svg += `<path class="ms__course" d="M${PAD - 4},${COURSE_Y}H${W - PAD + 4}"/>`;
songs.forEach((s, i) => {
if (i > 0 && s.sagaId !== songs[i - 1]!.sagaId) {
const x = ((X(i) + X(i - 1)) / 2).toFixed(1);
svg += `<path class="ms__saga" d="M${x},${COURSE_Y - 9}V${H}"/>`;
}
svg += `<circle class="ms__stop${i === cur ? " is-current" : ""}" data-song="${s.id}" cx="${X(i).toFixed(1)}" cy="${COURSE_Y}" r="${i === cur ? 4.5 : 2}"><title>${s.track}. ${esc(s.title)}</title></circle>`;
});
svg += `<g class="ms__ship" transform="translate(${X(cur).toFixed(1)},${COURSE_Y - 6})"><path class="ms__hull" d="M-7,-6 Q0,0 7,-6 Z"/><path class="ms__mast" d="M0,-6 V-17"/><path class="ms__hull" d="M0.5,-16 L6,-10 H0.5 Z"/></g>`;
ms.forEach((m, k) => {
const y = COURSE_Y + 14 + k * ROW + ROW - 9;
const idx = hitsOf(m.id).map((s) => songs.indexOf(s));
const x0 = X(idx[0]!), x1 = X(idx.at(-1)!);
svg += `<g class="ms__thread" data-m="${m.id}" style="--c:${m.color}">
<path class="ms__edge" d="M${x0.toFixed(1)},${y}H${x1.toFixed(1)}"/><path class="ms__line" d="M${x0.toFixed(1)},${y}H${x1.toFixed(1)}"/>
${idx.map((i) => `<circle class="ms__knot${i === cur ? " is-here" : ""}" cx="${X(i).toFixed(1)}" cy="${y}" r="${i === cur ? 4.6 : 3.2}"><title>${esc(m.name)} in ${esc(songs[i]!.title)}</title></circle>`).join("")}
</g>`;
});
const names = ms.map((m, k) => `<button type="button" class="ms__name" data-m="${m.id}" data-pick="${m.id}" aria-pressed="false"
style="top:${COURSE_Y + 14 + k * ROW}px">${dot(m)}${esc(m.name)}</button>`).join("");
return `<section class="motif-strip" aria-label="Motif paths across the album">
<p class="ms__head"><span class="ms__title">Motif paths</span><span class="ms__sub">Track ${song.track} of ${songs.length} · heard before · here · returns</span></p>
<div class="ms__body" style="--w:${W};--h:${H}">
<svg class="ms__svg" viewBox="0 0 ${W} ${H}" aria-hidden="true">${svg}</svg>
${names}
</div>
</section>`;
}
+97
View File
@@ -0,0 +1,97 @@
// The Listen & lyrics player: one song's real FLAC through an <audio> element (kept in the DOM, hidden).
// Ported from 17's SongPlayer without the simulated clock: with no recording there is no player at all.
export class AudioPlayer {
t = 0;
dur = 0;
playing = false;
/** Whether the file's length is known yet. */
ready = false;
/** Set when the file fails to load. */
failed = false;
private audio: HTMLAudioElement | null = null;
private raf = 0;
constructor(private host: HTMLElement, private onChange: () => void) {}
get loaded() { return !!this.audio; }
get element() { return this.audio; }
/** Loads a recording, stopped at 0:00; null unloads. */
load(url: string | null, label = "") {
this.pause();
if (this.audio) { this.audio.removeAttribute("src"); this.audio.remove(); }
this.audio = null;
this.t = 0;
this.dur = 0;
this.ready = false;
this.failed = false;
if (url) {
const a = document.createElement("audio");
a.preload = "metadata";
a.hidden = true;
a.dataset.song = label;
a.src = url;
a.addEventListener("loadedmetadata", () => {
if (a !== this.audio || !Number.isFinite(a.duration)) return;
this.dur = a.duration;
this.ready = true;
// a seek asked for before the length was known is applied now
if (this.t > 0 && Math.abs(a.currentTime - this.t) > 0.5) a.currentTime = this.t;
this.onChange();
});
a.addEventListener("timeupdate", () => { if (a === this.audio && this.ready) { this.t = a.currentTime; this.onChange(); } });
a.addEventListener("pause", () => { if (a === this.audio && this.playing && !a.ended) { this.playing = false; this.onChange(); } });
a.addEventListener("ended", () => { if (a === this.audio) { this.playing = false; cancelAnimationFrame(this.raf); this.onChange(); } });
a.addEventListener("error", () => {
if (a !== this.audio) return;
this.audio = null;
this.failed = true;
this.playing = false;
a.remove();
this.onChange();
});
this.host.append(a);
this.audio = a;
}
this.onChange();
}
play() {
const a = this.audio;
if (!a || this.playing) return;
if (this.ready && this.t >= this.dur - 0.05) this.seek(0);
this.playing = true;
a.play()?.catch?.(() => { if (a === this.audio) { this.playing = false; this.onChange(); } });
this.raf = requestAnimationFrame(this.tick);
this.onChange();
}
pause() {
if (!this.playing) return;
this.playing = false;
this.audio?.pause();
cancelAnimationFrame(this.raf);
this.onChange();
}
toggle() { if (this.playing) this.pause(); else this.play(); }
seek(t: number) {
if (!this.audio) return;
// before the length is known, any time is allowed; loadedmetadata applies it
this.t = Math.max(0, this.ready ? Math.min(this.dur, t) : t);
this.audio.currentTime = this.t;
this.onChange();
}
destroy() { this.load(null); }
// read the element's clock every frame so the lyric highlight is smooth (timeupdate is ~4 Hz)
private tick = () => {
if (!this.playing || !this.audio) return;
if (this.ready) this.t = this.audio.currentTime;
this.onChange();
this.raf = requestAnimationFrame(this.tick);
};
}
+45
View File
@@ -0,0 +1,45 @@
// The reading view: the song's book of the Odyssey (Butcher & Lang, which keeps the Greek names), opened at
// the first paragraph naming one of the song's keywords, with the paragraphs before it folded away.
import type { AlbumSong } from "../../data/album";
import { keywordRe, loadBook, passageStart, roman, stripFootnotes } from "../content";
import { esc } from "./util";
/** `readAll` shows the whole book from the start, keeping the passage marker. */
export function readingView(song: AlbumSong, readAll: boolean): HTMLElement {
const o = song.odyssey!;
const el = document.createElement("div");
el.className = "reading";
el.innerHTML = `
<button type="button" class="reading__back" data-act="unread"><span aria-hidden="true">←</span> Back to About</button>
<p class="reading__eyebrow">The Odyssey · Book ${roman(o.book)} · tr. Butcher &amp; Lang</p>
<div class="reading__body" aria-busy="true"><p class="hint">Opening Book ${roman(o.book)}…</p></div>`;
const body = el.querySelector<HTMLElement>(".reading__body")!;
loadBook(o.book).then((book) => {
if (!el.isConnected) return; // the reader has moved on
body.removeAttribute("aria-busy");
if (!book) {
body.innerHTML = `<p class="note">Book ${roman(o.book)} could not be loaded from the content folder.
<a href="${esc(o.url)}" target="_blank" rel="noopener noreferrer">Read it on Project Gutenberg</a>.</p>`;
return;
}
const at = passageStart(book, o.keywords);
const re = keywordRe(o.keywords);
const para = (t: string) => {
let h = esc(stripFootnotes(t));
if (re) h = h.replace(re, "<mark>$1</mark>");
return `<p class="reading__p">${h}</p>`;
};
const from = readAll ? 0 : at.index;
const marker = `<p class="reading__mark" id="reading-mark">${at.word
? `The passage for ${esc(song.title)} begins here · first mention of “${esc(at.word)}”`
: `No keyword for ${esc(song.title)} found · Book ${roman(o.book)} from the start`}</p>`;
body.innerHTML = `
<h3 class="reading__title">${esc(book.heading.replace(/^BOOK\b/, "Book").replace(/\.$/, ""))}</h3>
${book.summary ? `<p class="reading__sum">${esc(book.summary)}</p>` : ""}
${from > 0 ? `<button type="button" class="reading__more" data-act="read-all">Show the ${from} paragraph${from === 1 ? "" : "s"} before this passage</button>` : ""}
${book.paragraphs.slice(from).map((t, i) => (from + i === at.index ? marker : "") + para(t)).join("")}
<p class="reading__end">End of Book ${roman(o.book)} · Project Gutenberg #1728</p>`;
if (readAll) el.querySelector<HTMLElement>("#reading-mark")?.scrollIntoView?.({ block: "start" });
});
return el;
}
+38
View File
@@ -0,0 +1,38 @@
// Small helpers shared by the panel's tabs.
import { motifs, songs, type AlbumSong, type Motif } from "../../data/album";
export const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
export const PLAY_ICON = `<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M6 3.5 L16.5 10 L6 16.5 Z"/></svg>`;
export const PAUSE_ICON = `<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 3.5 H8.6 V16.5 H5 Z M11.4 3.5 H15 V16.5 H11.4 Z"/></svg>`;
/** m:ss */
export const fmt = (sec: number) => {
const s = Math.max(0, Math.floor(Number.isFinite(sec) ? sec : 0));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
};
const motifById = new Map(motifs.map((m) => [m.id, m]));
/** The song's motifs and character themes, musical motifs first. */
export function motifsOf(song: AlbumSong): Motif[] {
const ms = song.motifIds.flatMap((id) => motifById.get(id) ?? []);
return [...ms.filter((m) => m.kind === "motif"), ...ms.filter((m) => m.kind === "character")];
}
/** Songs where a motif plays (song level or in a part), in album order. */
export const hitsOf = (id: string): AlbumSong[] =>
songs.filter((s) => s.motifIds.includes(id) || s.parts.some((p) => p.motifIds.includes(id)));
/** A motif's dot: its own colour with an ink edge, so even the pale ones read on parchment. */
export const dot = (m: Motif) => `<i class="mdot" style="--c:${m.color}" aria-hidden="true"></i>`;
/** Toggle is-on / is-dim on every element carrying data-m, so one pick highlights everywhere in the panel. */
export function applyPick(root: ParentNode, pick: string | null) {
root.querySelectorAll<HTMLElement>("[data-m]").forEach((el) => {
const id = el.dataset.m;
el.classList.toggle("is-on", !!pick && id === pick);
el.classList.toggle("is-dim", !!pick && id !== pick);
});
root.querySelectorAll<HTMLElement>("button[data-pick]").forEach((b) => b.setAttribute("aria-pressed", String(!!pick && b.dataset.pick === pick)));
}
+86
View File
@@ -0,0 +1,86 @@
import { afterEach, expect, test } from "bun:test";
import { loadBook, loadLibrary, loadLyrics, passageStart, realFor } from "../src/app/content";
import { songById } from "../src/data/album";
import type { HomerBook } from "../src/content/homer";
// Placeholder text only: never real lyrics in tests.
const realFetch = globalThis.fetch;
afterEach(() => { globalThis.fetch = realFetch; });
function mockFetch(routes: Record<string, () => Response>) {
const seen: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
seen.push(url);
const hit = routes[url];
return hit ? hit() : new Response("Not found", { status: 404 });
}) as typeof fetch;
return seen;
}
const library = {
sagas: [
{
name: "The Circe Saga", year: 2024, cover: "/content/files/circe/folder.jpg",
songs: [
{ track: 2, title: "Wouldn't You Like", audio: "/content/files/circe/02.flac", lyrics: { url: "/content/files/circe/02.lrc", timed: true } },
{ track: 3, title: "Done For", audio: "/content/files/circe/03.flac", lyrics: { url: "/content/files/circe/03.txt", timed: false } },
],
},
],
};
test("loadLibrary is false when /content 404s", async () => {
mockFetch({});
expect(await loadLibrary()).toBe(false);
expect(realFor(songById("polyphemus")!)).toBeNull();
});
test("loadLibrary is false when the request times out", async () => {
globalThis.fetch = ((_: RequestInfo | URL, init?: RequestInit) =>
new Promise((_res, rej) => init?.signal?.addEventListener("abort", () => rej(new Error("aborted"))))) as typeof fetch;
expect(await loadLibrary(20)).toBe(false);
});
test("realFor matches by normalised title, including a curly apostrophe", async () => {
mockFetch({ "/content/library.json": () => Response.json(library) });
expect(await loadLibrary()).toBe(true);
const song = songById("wouldnt-you-like")!;
expect(song.title).toBe("Wouldn’t You Like");
expect(realFor(song)).toEqual({
audio: "/content/files/circe/02.flac",
cover: "/content/files/circe/folder.jpg",
lyrics: { url: "/content/files/circe/02.lrc", timed: true },
});
expect(realFor(songById("polyphemus")!)).toBeNull();
});
test("loadLyrics parses .lrc as timed lines and .txt as plain lines", async () => {
mockFetch({
"/content/files/x/a.lrc": () => new Response("[ar:Someone]\n[00:02.00]Line B\n[00:01.00]Line A\n"),
"/content/files/x/a.txt": () => new Response("Line A\r\n\r\nLine B\n"),
});
expect(await loadLyrics({ url: "/content/files/x/a.lrc", timed: true })).toEqual({
timed: true, lines: [{ time: 1, text: "Line A" }, { time: 2, text: "Line B" }],
});
expect(await loadLyrics({ url: "/content/files/x/a.txt", timed: false })).toEqual({ timed: false, lines: ["Line A", "", "Line B"] });
expect(await loadLyrics({ url: "/content/files/x/missing.lrc", timed: true })).toBeNull();
});
test("loadBook fetches one book, null when missing", async () => {
const book: HomerBook = { book: 11, heading: "BOOK XI", summary: "S", paragraphs: ["p"] };
const seen = mockFetch({ "/content/homer/odyssey/11.json": () => Response.json(book) });
expect(await loadBook(11)).toEqual(book);
expect(await loadBook(30)).toBeNull();
expect(seen).toContain("/content/homer/odyssey/11.json");
});
test("passageStart finds the first paragraph with a keyword, else 0", () => {
const book: HomerBook = {
book: 9, heading: "BOOK IX", summary: "",
paragraphs: ["Nothing here.", "Still nothing, cyclopean is not a word match.", "Then the Cyclops spoke.", "The cave again."],
};
expect(passageStart(book, ["cave", "Cyclops"])).toEqual({ index: 2, word: "Cyclops" });
expect(passageStart(book, ["Noman"])).toEqual({ index: 0, word: null });
expect(passageStart(book, [])).toEqual({ index: 0, word: null });
});
+139
View File
@@ -0,0 +1,139 @@
import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
import type { HomerBook } from "../src/content/homer";
import { createStore, DEFAULT_STATE, type AppState } from "../src/app/state";
beforeAll(() => GlobalRegistrator.register({ url: "http://localhost:3000/" }));
afterAll(() => GlobalRegistrator.unregister());
// Placeholder text only: never real lyrics in tests.
const library = {
sagas: [{
name: "The Cyclops Saga", year: 2023, cover: "/content/files/cyclops/folder.jpg",
songs: [{ track: 1, title: "Polyphemus", audio: "/content/files/cyclops/01.flac", lyrics: { url: "/content/files/cyclops/01.lrc", timed: true } }],
}],
};
const book: HomerBook = {
book: 9, heading: "BOOK IX.", summary: "Summary line.",
paragraphs: ["First paragraph.", "Second paragraph.", "Here the Cyclops appears.", "The end."],
};
let realFetch: typeof fetch;
function serve(mounted: boolean) {
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
if (mounted && url.endsWith("/content/library.json")) return Response.json(library);
if (mounted && url.endsWith("/content/files/cyclops/01.lrc")) return new Response("[00:01.00]Line A\n[00:05.00]Line B\n[00:09.00]Line C\n");
if (mounted && url.endsWith("/content/homer/odyssey/9.json")) return Response.json(book);
return new Response("Not found", { status: 404 });
}) as typeof fetch;
}
const flush = async () => { for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0)); };
let destroy: (() => void) | null = null;
afterEach(() => { destroy?.(); destroy = null; globalThis.fetch = realFetch; });
beforeAll(() => { realFetch = globalThis.fetch; });
async function mount(mounted: boolean, state: Partial<AppState> = {}) {
serve(mounted);
const content = await import("../src/app/content");
await content.loadLibrary();
const { mountPanel } = await import("../src/app/panel/panel");
document.body.innerHTML = `<main class="shell" data-panel="open"><aside id="panel" class="panel"></aside></main>`;
const root = document.getElementById("panel")!;
const store = createStore({ ...DEFAULT_STATE, songId: "polyphemus", ...state }, { history: false });
const panel = mountPanel(root, store, { timelineHidden: () => false });
destroy = panel.destroy;
await flush();
return { root, store, panel };
}
test("About has no Watch and no listen button", async () => {
const { root } = await mount(true, { tab: "about" });
expect(root.querySelector(".about")).not.toBeNull();
expect(root.querySelector(".watch")).toBeNull();
expect(root.querySelector(".golisten")).toBeNull();
expect(root.querySelector(".panel__title")!.textContent).toBe("Polyphemus");
});
test("Listen & lyrics has the player, the synced lyrics and Watch", async () => {
const { root } = await mount(true, { tab: "listen" });
expect(root.querySelector(".player")).not.toBeNull();
expect(root.querySelector(".lyrics")).not.toBeNull();
expect(root.querySelector(".watch .video-card")).not.toBeNull();
expect(root.querySelectorAll(".lyrics button.ly")).toHaveLength(3);
expect(root.querySelector("audio")).not.toBeNull();
});
test("clicking a lyric line seeks the audio there", async () => {
const { root } = await mount(true, { tab: "listen" });
const audio = root.querySelector("audio")!;
(root.querySelectorAll<HTMLButtonElement>(".lyrics button.ly")[1]!).click();
expect(audio.currentTime).toBe(5);
});
test("Paths shows the motif strip only while the timeline is closed", async () => {
const { root, store } = await mount(true, { tab: "paths" });
expect(root.querySelector(".paths")).not.toBeNull();
expect(root.querySelector(".motif-strip")).toBeNull();
store.set({ timeline: "closed" });
expect(root.querySelector(".motif-strip")).not.toBeNull();
store.set({ timeline: "open" });
expect(root.querySelector(".motif-strip")).toBeNull();
});
test("Paths has a row per motif and the chart of all nine places", async () => {
const { root } = await mount(true, { tab: "paths" });
expect(root.querySelectorAll(".mrow").length).toBeGreaterThan(0);
const places = [...root.querySelectorAll(".c-place")].map((t) => t.textContent);
expect(places).toHaveLength(9);
expect(places[0]).toBe("Troy");
expect(places[8]).toBe("Ithaca");
});
test("Fan works shows the empty state", async () => {
const { root } = await mount(true, { tab: "fan" });
expect(root.querySelector(".fan")!.textContent).toContain(
"No fan works collected yet. Real ones will be credited and linked to their creators.",
);
});
test("without the content folder, Listen says so and has no audio", async () => {
const { root } = await mount(false, { tab: "listen" });
expect(root.querySelector(".listen")!.textContent).toContain("Content folder not mounted");
expect(root.querySelector("audio")).toBeNull();
expect(root.querySelector(".watch")).not.toBeNull();
});
test("the header play button switches to Listen & lyrics", async () => {
const { root, store } = await mount(true, { tab: "about" });
root.querySelector<HTMLButtonElement>(".pplay")!.click();
expect(store.get().tab).toBe("listen");
expect(root.querySelector(".listen")).not.toBeNull();
});
test("the reading view opens at the passage with the paragraphs before folded, and Back closes it", async () => {
const { root, store } = await mount(true, { tab: "about", read: true });
const reading = root.querySelector(".reading")!;
expect(reading.textContent).toContain("Show the 2 paragraphs before");
expect(reading.textContent).not.toContain("Roman");
expect(reading.querySelectorAll(".reading__p")).toHaveLength(2);
root.querySelector<HTMLButtonElement>(".reading__back")!.click();
expect(store.get().read).toBe(false);
expect(root.querySelector(".reading")).toBeNull();
});
test("changing the song re-renders the header", async () => {
const { root, store } = await mount(true);
store.set({ songId: "would-you-fall-in-love-with-me-again" });
expect(root.querySelector(".panel__title")!.textContent).toBe("Would You Fall in Love with Me Again");
});
test("a long title can wrap: no nowrap on .panel__title", () => {
const css = readFileSync(new URL("../src/app/panel/panel.css", import.meta.url), "utf8");
const rule = /\.panel__title\s*\{[^}]*\}/.exec(css);
expect(rule).not.toBeNull();
expect(rule![0]).not.toContain("nowrap");
});