From 8537eb6ce2022badbd6f477eb964026fb493d5a5 Mon Sep 17 00:00:00 2001 From: gosangam Date: Thu, 15 Oct 2026 23:18:05 +0530 Subject: [PATCH] audio player respects group context --- .../(reader)/transcriptions/[slug]/page.tsx | 1 + .../[slug]/play-audio-button.tsx | 40 ++++- .../book/[book]/book-browser.tsx | 87 ++++++----- .../transcriptions/place/[place]/page.tsx | 106 ++++++------- .../type/[type]/type-browser.tsx | 89 ++++++----- .../year/[year]/year-browser.tsx | 35 +++-- components/audio-player/audio-context.tsx | 15 ++ components/audio-player/transcript-queue.tsx | 144 ++++++++++++++++++ 8 files changed, 379 insertions(+), 138 deletions(-) create mode 100644 components/audio-player/transcript-queue.tsx diff --git a/app/(app)/(reader)/transcriptions/[slug]/page.tsx b/app/(app)/(reader)/transcriptions/[slug]/page.tsx index a1c8051d..164d5573 100644 --- a/app/(app)/(reader)/transcriptions/[slug]/page.tsx +++ b/app/(app)/(reader)/transcriptions/[slug]/page.tsx @@ -180,6 +180,7 @@ export default async function TranscriptionPage({ subtitle={[t.date, t.place].filter(Boolean).join(" · ")} sourceUrl={`/transcriptions/${t.slug}`} type={t.type} + fallbackYear={t.year} /> )} diff --git a/app/(app)/(reader)/transcriptions/[slug]/play-audio-button.tsx b/app/(app)/(reader)/transcriptions/[slug]/play-audio-button.tsx index dbeb7abb..46f41921 100644 --- a/app/(app)/(reader)/transcriptions/[slug]/play-audio-button.tsx +++ b/app/(app)/(reader)/transcriptions/[slug]/play-audio-button.tsx @@ -5,6 +5,11 @@ import { type AudioTrack, AudioWaveAnimation, } from "@/components/audio-player"; +import { + rememberedQueue, + useTranscriptQueue, + yearQueue, +} from "@/components/audio-player/transcript-queue"; import { cn } from "@/lib/utils"; import { Play } from "lucide-react"; @@ -17,6 +22,11 @@ type PlayAudioButtonProps = { className?: string; /** Compact icon-only variant for list items */ variant?: "default" | "compact"; + /** + * On a lecture's own page: its year, queued as a fallback when the lecture + * wasn't opened from a list (search, a shared link…). + */ + fallbackYear?: number | null; }; export function PlayAudioButton({ @@ -27,8 +37,18 @@ export function PlayAudioButton({ type, className, variant = "default", + fallbackYear, }: PlayAudioButtonProps) { - const { currentTrack, isPlaying, play, togglePlayPause } = useAudioPlayer(); + const { + currentTrack, + isPlaying, + play, + playPlaylist, + attachQueue, + togglePlayPause, + } = useAudioPlayer(); + // The list this button sits in (a transcription list row), if any. + const listQueue = useTranscriptQueue(); const isThisTrack = currentTrack?.url === audioUrl; const isThisPlaying = isThisTrack && isPlaying; @@ -44,7 +64,25 @@ export function PlayAudioButton({ sourceUrl, type: type ?? "lecture", }; + // Queue the list this recording was found in, so Next / Previous + // follow it: the list the button sits in, or — on a lecture's own + // page — the list it was opened from. + const queue = + listQueue ?? + rememberedQueue(sourceUrl.replace(/^\/transcriptions\//, "")); + const at = queue?.findIndex((q) => q.url === audioUrl) ?? -1; + if (queue && at >= 0 && queue.length > 1) { + playPlaylist(queue, at); + return; + } + // No list to follow: start this lecture now — audio must begin inside + // the tap, or iOS blocks it — then queue its year once that loads. play(track); + if (fallbackYear) { + void yearQueue(fallbackYear).then((tracks) => { + if (tracks && tracks.length > 1) attachQueue(tracks); + }); + } } } diff --git a/app/(app)/(reader)/transcriptions/book/[book]/book-browser.tsx b/app/(app)/(reader)/transcriptions/book/[book]/book-browser.tsx index 3a8fbb4f..ed54f500 100644 --- a/app/(app)/(reader)/transcriptions/book/[book]/book-browser.tsx +++ b/app/(app)/(reader)/transcriptions/book/[book]/book-browser.tsx @@ -7,7 +7,15 @@ import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { ChevronLeft, ChevronRight, Menu, Calendar, Clock, MapPin } from "lucide-react"; +import { + ChevronLeft, + ChevronRight, + Menu, + Calendar, + Clock, + MapPin, +} from "lucide-react"; +import { TranscriptQueue } from "@/components/audio-player/transcript-queue"; import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button"; import { parseChapter, parseVerse } from "@/lib/book-reference"; import { @@ -417,6 +425,13 @@ export function TranscriptionBookBrowser({ contentRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); }; + // Next / Previous run through every chapter's lectures in display order, + // so playback carries on into the next chapter. + const queueItems = useMemo( + () => flat.flatMap((f) => f.chapter.transcriptions), + [flat], + ); + const activeIdx = flat.findIndex((f) => f.chapter.key === activeKey); const active = activeIdx >= 0 ? flat[activeIdx] : null; const prev = activeIdx > 0 ? flat[activeIdx - 1] : null; @@ -463,42 +478,44 @@ export function TranscriptionBookBrowser({ {/* Main content */}
- {/* Top navigation */} - + + {/* Top navigation */} + - {/* Content — every lecture of the active chapter (all years/places) */} - {!active || active.chapter.transcriptions.length === 0 ? ( -
-

- No transcriptions for this chapter -

-
- ) : ( - - - {active.chapter.transcriptions.map((t) => ( - - ))} - - - )} + {/* Content — every lecture of the active chapter (all years/places) */} + {!active || active.chapter.transcriptions.length === 0 ? ( +
+

+ No transcriptions for this chapter +

+
+ ) : ( + + + {active.chapter.transcriptions.map((t) => ( + + ))} + + + )} - {/* Bottom navigation */} - + {/* Bottom navigation */} + +
diff --git a/app/(app)/(reader)/transcriptions/place/[place]/page.tsx b/app/(app)/(reader)/transcriptions/place/[place]/page.tsx index e9e5ee0e..811ab206 100644 --- a/app/(app)/(reader)/transcriptions/place/[place]/page.tsx +++ b/app/(app)/(reader)/transcriptions/place/[place]/page.tsx @@ -9,6 +9,7 @@ import { PageHeader } from "@/components/layout/page-header"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Calendar, Clock } from "lucide-react"; +import { TranscriptQueue } from "@/components/audio-player/transcript-queue"; import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button"; export async function generateMetadata({ @@ -55,58 +56,61 @@ export default async function TranscriptionsByPlacePage({
- - - {transcriptions.map((t) => ( - -
-
-

- {t.name} -

- - {t.type === "lecture" ? "Lecture" : "Conversation"} - + {/* Next / Previous in the player follow this list. */} + + + + {transcriptions.map((t) => ( + +
+
+

+ {t.name} +

+ + {t.type === "lecture" ? "Lecture" : "Conversation"} + +
+
+ {t.date && ( + + + {t.date} + + )} + {t.reference && ( + {t.reference} + )} + {t.durationMinutes && t.durationMinutes > 0 && ( + + + {t.durationMinutes} min + + )} +
-
- {t.date && ( - - - {t.date} - - )} - {t.reference && ( - {t.reference} - )} - {t.durationMinutes && t.durationMinutes > 0 && ( - - - {t.durationMinutes} min - - )} -
-
- {t.audioUrl && ( - - )} - - ))} - - + {t.audioUrl && ( + + )} + + ))} + + +
); diff --git a/app/(app)/(reader)/transcriptions/type/[type]/type-browser.tsx b/app/(app)/(reader)/transcriptions/type/[type]/type-browser.tsx index c3fa265b..ec842fd7 100644 --- a/app/(app)/(reader)/transcriptions/type/[type]/type-browser.tsx +++ b/app/(app)/(reader)/transcriptions/type/[type]/type-browser.tsx @@ -14,6 +14,7 @@ import { Clock, MapPin, } from "lucide-react"; +import { TranscriptQueue } from "@/components/audio-player/transcript-queue"; import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button"; import { Drawer, @@ -481,6 +482,16 @@ export function TranscriptionTypeBrowser({ [activeGroup], ); + // Next / Previous run through the whole list in display order — year by + // year, month by month — so playback carries on past the year on screen. + const queueItems = useMemo( + () => + yearGroups.flatMap((yg) => + buildMonthGroups(yg.transcriptions).flatMap((mg) => mg.transcriptions), + ), + [yearGroups], + ); + const activeYearLabel = activeGroup?.label ?? "Unknown"; const drawerTitle = typeLabel; @@ -520,46 +531,48 @@ export function TranscriptionTypeBrowser({ {/* Main content */}
- {/* Top navigation */} - + + {/* Top navigation */} + - {/* Content — transcriptions grouped by month */} - {monthGroups.length === 0 ? ( -
-

- No transcriptions for this year -

-
- ) : ( - - - {monthGroups.map((mg, i) => ( - - ))} - - - )} + {/* Content — transcriptions grouped by month */} + {monthGroups.length === 0 ? ( +
+

+ No transcriptions for this year +

+
+ ) : ( + + + {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 ( + +
+ {children} +
+
+ ); +}