145 lines
4.5 KiB
TypeScript
145 lines
4.5 KiB
TypeScript
"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<AudioTrack[] | null>(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<AudioTrack[] | null> {
|
|
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<QueueSource, "audioUrl"> & {
|
|
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 <Link>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 (
|
|
<QueueContext.Provider value={tracks}>
|
|
<div className="contents" onClickCapture={onClickCapture}>
|
|
{children}
|
|
</div>
|
|
</QueueContext.Provider>
|
|
);
|
|
}
|