diff --git a/src/app/content.ts b/src/app/content.ts new file mode 100644 index 0000000..db5f67f --- /dev/null +++ b/src/app/content.ts @@ -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; +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(); +let pending: Promise | 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 { + 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 => 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>(); +/** Fetches and parses a song's lyrics (.lrc → timed lines, .txt → plain lines); null if it fails. */ +export function loadLyrics(ref: LyricsRef): Promise { + 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>(); +/** One book of the Odyssey from the content folder; null if it is not there. */ +export function loadBook(book: number): Promise { + let p = bookCache.get(book); + if (!p) { + p = fetch(`/content/homer/odyssey/${book}.json`) + .then((r) => (r.ok ? (r.json() as Promise) : 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); diff --git a/src/app/panel/about.ts b/src/app/panel/about.ts new file mode 100644 index 0000000..a766aaa --- /dev/null +++ b/src/app/panel/about.ts @@ -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 + ? `Book ${roman(o.book)}` + : `Book ${roman(o.book)} · Butcher & Lang on Gutenberg`; + const parts = song.parts.length + ? song.parts.map((p) => `
  • ${esc(p.name)}${p.singers.map(esc).join(", ")}
  • `).join("") + : song.singers.map((s) => `
  • ${esc(s)}
  • `).join(""); + el.innerHTML = ` +
    +
    Track
    ${song.track} of ${songs.length} · ${song.sagaTrack} in the saga
    +
    Saga
    ${esc(saga.name)} (${saga.year})
    +
    Story time
    ${esc(song.storyTime.label)}
    +
    The Odyssey
    ${homer}
    +
    + ${song.facts.map((f) => `

    ${esc(f)}

    `).join("")} +

    ${song.parts.length ? "Parts and singers" : "Singers"}

    +
      ${parts}
    `; + return el; +} diff --git a/src/app/panel/chart.ts b/src/app/panel/chart.ts new file mode 100644 index 0000000..73884a1 --- /dev/null +++ b/src/app/panel/chart.ts @@ -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 = { + 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 ` + ${p.name}`; + }).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 ` + + + + + + + + + + + + + + + + ${ports} + + + + + + You are here + + + `; +} diff --git a/src/app/panel/fanworks.ts b/src/app/panel/fanworks.ts new file mode 100644 index 0000000..23c7169 --- /dev/null +++ b/src/app/panel/fanworks.ts @@ -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 = { + 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 + ? `
      ${works.map((w) => `
    • +

      ${TYPE_LABEL[w.type]}

      +

      ${esc(w.title)}

      +

      by ${esc(w.creator)}

      +

      ${esc(w.permission)}

      +
    • `).join("")}
    ` + : `
    + +

    No fan works collected yet. Real ones will be credited and linked to their creators.

    +
    `; + return el; +} diff --git a/src/app/panel/listen.ts b/src/app/panel/listen.ts new file mode 100644 index 0000000..9b6b669 --- /dev/null +++ b/src/app/panel/listen.ts @@ -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