audio player respects group context
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user