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:
co-authored by
Claude Opus 5.5
parent
38c4614726
commit
2fbf3a6980
@@ -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);
|
||||
@@ -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 & 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;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 & 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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 & 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;
|
||||
}
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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)));
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user