About, Listen & lyrics (real player, synced lyrics, Watch), Paths (strip when the timeline is hidden, motif rows, nine-place chart), Fan works. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
230 lines
9.5 KiB
TypeScript
230 lines
9.5 KiB
TypeScript
// 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;
|
||
},
|
||
};
|
||
}
|