+ ) : (
+
+
+ {monthGroups.map((mg, i) => (
+
+ ))}
+
+
+ )}
- {/* Bottom navigation */}
-
+ {/* Bottom navigation */}
+
+
>
diff --git a/app/(app)/(reader)/transcriptions/year/[year]/year-browser.tsx b/app/(app)/(reader)/transcriptions/year/[year]/year-browser.tsx
index 11f441e2..9fdef74a 100644
--- a/app/(app)/(reader)/transcriptions/year/[year]/year-browser.tsx
+++ b/app/(app)/(reader)/transcriptions/year/[year]/year-browser.tsx
@@ -7,6 +7,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { Calendar, ChevronRight, Clock, MapPin } from "lucide-react";
+import { TranscriptQueue } from "@/components/audio-player/transcript-queue";
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
type TranscriptionItem = {
@@ -169,6 +170,12 @@ export function TranscriptionYearBrowser({
return groups;
}, [transcriptions]);
+ // Display order — month by month — is the order Next / Previous play in.
+ const queueItems = useMemo(
+ () => monthGroups.flatMap((g) => g.items),
+ [monthGroups],
+ );
+
const lectureCount = transcriptions.filter(
(t) => t.type === "lecture",
).length;
@@ -202,19 +209,21 @@ export function TranscriptionYearBrowser({
) : (
-
-
- {monthGroups.map((mg, i) => (
-
- ))}
-
-
+
+
+
+ {monthGroups.map((mg, i) => (
+
+ ))}
+
+
+
)}
>
diff --git a/components/audio-player/audio-context.tsx b/components/audio-player/audio-context.tsx
index 5c089764..e982d49f 100644
--- a/components/audio-player/audio-context.tsx
+++ b/components/audio-player/audio-context.tsx
@@ -60,6 +60,12 @@ type AudioPlayerState = {
type AudioPlayerActions = {
play: (track: AudioTrack) => void;
playPlaylist: (tracks: AudioTrack[], startIndex?: number) => void;
+ /**
+ * Gives the track that's already playing a queue to sit in, without
+ * restarting it — for a queue that arrives after playback began. Ignored
+ * if the current track isn't in it (the user moved on meanwhile).
+ */
+ attachQueue: (tracks: AudioTrack[]) => void;
pause: () => void;
resume: () => void;
togglePlayPause: () => void;
@@ -469,6 +475,14 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
[saveCurrent],
);
+ const attachQueue = useCallback((tracks: AudioTrack[]) => {
+ const current = currentTrackRef.current;
+ const idx = current ? tracks.findIndex((t) => t.url === current.url) : -1;
+ if (idx < 0) return;
+ setPlaylist(tracks);
+ setCurrentIndex(idx);
+ }, []);
+
const pause = useCallback(() => {
audioRef.current?.pause();
saveCurrent();
@@ -630,6 +644,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
currentIndex,
play,
playPlaylist,
+ attachQueue,
pause,
resume,
togglePlayPause,
diff --git a/components/audio-player/transcript-queue.tsx b/components/audio-player/transcript-queue.tsx
new file mode 100644
index 00000000..a4d7ee96
--- /dev/null
+++ b/components/audio-player/transcript-queue.tsx
@@ -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(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 (
+
+