"use client"; /** * TranscriptQueue — makes the audio player's Next / Previous follow the list * a recording was found in. * * The transcription lists (by place, year, book, type) each wrap their rows * in this. It does two things with the list, in the order it's displayed: * * 1. Play buttons inside it queue the whole list, starting at the row * pressed — so Next plays the next lecture *in that list*. * 2. Opening a lecture from it remembers the list (sessionStorage) for that * lecture's page, so pressing Play there queues the same list. * * A lecture reached any other way (search, a shared link) falls back to its * year in date order — the same order as the Previous / Next links on its * page and the Year view. Only recordings with audio are ever queued. */ import { createContext, useCallback, useContext, useMemo } from "react"; import type { AudioTrack } from "./audio-context"; /** The fields every transcription list row already has. */ export type QueueSource = { slug: string; name: string; type: string; date: string | null; place: string | null; audioUrl: string | null; }; const STORAGE_KEY = "transcript-queue"; const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080"; /** The track a row plays — identical whether built here or by the button. */ export function transcriptTrack( t: QueueSource & { audioUrl: string }, ): AudioTrack { return { url: t.audioUrl, title: t.name, subtitle: [t.date, t.place].filter(Boolean).join(" · "), sourceUrl: `/transcriptions/${t.slug}`, type: t.type ?? "lecture", }; } const QueueContext = createContext(null); /** The list a play button sits in, or null outside any list. */ export function useTranscriptQueue() { return useContext(QueueContext); } /** * The list remembered for this lecture's page, if it was opened from one. * Only the lecture actually clicked qualifies, so an old list never leaks * onto a page reached some other way. */ export function rememberedQueue(slug: string): AudioTrack[] | null { try { const raw = sessionStorage.getItem(STORAGE_KEY); if (!raw) return null; const saved = JSON.parse(raw) as { slug: string; tracks: AudioTrack[] }; return saved.slug === slug ? saved.tracks : null; } catch { return null; } } /** * The fallback queue: every recording from that year, in the Year view's * order (by month and day, undated months last). Null if it can't be loaded. */ export async function yearQueue(year: number): Promise { try { const res = await fetch(`${BFF_BASE}/api/v1/transcriptions/year/${year}`); if (!res.ok) return null; const json = (await res.json()) as { data?: (Omit & { month?: number | null; audio_url?: string | null; })[]; }; const rows = (json.data ?? []).map((r, i) => ({ ...r, audioUrl: r.audio_url ?? null, // Known months first, keeping the BFF's date order within them. sortKey: (r.month && r.month >= 1 && r.month <= 12 ? 0 : 1) * 1e6 + i, })); rows.sort((a, b) => a.sortKey - b.sortKey); return rows .filter((t): t is typeof t & { audioUrl: string } => !!t.audioUrl) .map(transcriptTrack); } catch { return null; } } export function TranscriptQueue({ items, children, }: { /** Rows in display order. */ items: QueueSource[]; children: React.ReactNode; }) { const tracks = useMemo( () => items .filter((t): t is QueueSource & { audioUrl: string } => !!t.audioUrl) .map(transcriptTrack), [items], ); // Event delegation: the rows are plain s (some rendered on the // server), so catch the click on the way down rather than threading a // handler into every row. const onClickCapture = useCallback( (e: React.MouseEvent) => { const a = (e.target as HTMLElement).closest("a"); const href = a?.getAttribute("href"); if (!href?.startsWith("/transcriptions/")) return; const slug = href.slice("/transcriptions/".length).split(/[?#]/)[0]; if (!tracks.some((t) => t.sourceUrl === `/transcriptions/${slug}`)) return; try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ slug, tracks })); } catch { /* storage full or blocked — the page just plays the one lecture */ } }, [tracks], ); return (
{children}
); }