// 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 = `
${TABS.map((t) => ``).join("")}
`; const q = (sel: string) => root.querySelector(sel)!; const view = q(".panel__view"); const songEl = q(".panel__song"); const headPlay = q(".pplay"); const tabBtns = [...root.querySelectorAll("[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 = `

${esc(song.title)}

${esc(saga.name)} · ${esc(song.storyTime.label)}

`; q(".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}${on ? "Pause" : "Play"} ${esc(song.title)}`; headPlay.setAttribute("aria-pressed", String(on)); headPlay.title = `${on ? "Pause" : "Play"} ${song.title}`; } const eyebrow = songEl.querySelector(".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(".shell"); if (shell) shell.dataset.panel = open ? "open" : "closed"; root.querySelectorAll("[aria-controls=panel-body]").forEach((b) => b.setAttribute("aria-expanded", String(open))); q(".panel__body").inert = !open; q(".panel__tab").tabIndex = open ? -1 : 0; } /* ----- wiring ----- */ root.addEventListener("click", (e) => { const t = e.target as Element; const pick = t.closest("[data-pick]"); if (pick) { store.set({ motif: store.get().motif === pick.dataset.pick ? null : pick.dataset.pick! }); return; } const to = t.closest("[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("[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(".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; }, }; }