audio player respects group context
This commit is contained in:
@@ -180,6 +180,7 @@ export default async function TranscriptionPage({
|
|||||||
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
|
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
|
||||||
sourceUrl={`/transcriptions/${t.slug}`}
|
sourceUrl={`/transcriptions/${t.slug}`}
|
||||||
type={t.type}
|
type={t.type}
|
||||||
|
fallbackYear={t.year}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import {
|
|||||||
type AudioTrack,
|
type AudioTrack,
|
||||||
AudioWaveAnimation,
|
AudioWaveAnimation,
|
||||||
} from "@/components/audio-player";
|
} from "@/components/audio-player";
|
||||||
|
import {
|
||||||
|
rememberedQueue,
|
||||||
|
useTranscriptQueue,
|
||||||
|
yearQueue,
|
||||||
|
} from "@/components/audio-player/transcript-queue";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Play } from "lucide-react";
|
import { Play } from "lucide-react";
|
||||||
|
|
||||||
@@ -17,6 +22,11 @@ type PlayAudioButtonProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/** Compact icon-only variant for list items */
|
/** Compact icon-only variant for list items */
|
||||||
variant?: "default" | "compact";
|
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({
|
export function PlayAudioButton({
|
||||||
@@ -27,8 +37,18 @@ export function PlayAudioButton({
|
|||||||
type,
|
type,
|
||||||
className,
|
className,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
|
fallbackYear,
|
||||||
}: PlayAudioButtonProps) {
|
}: 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 isThisTrack = currentTrack?.url === audioUrl;
|
||||||
const isThisPlaying = isThisTrack && isPlaying;
|
const isThisPlaying = isThisTrack && isPlaying;
|
||||||
@@ -44,7 +64,25 @@ export function PlayAudioButton({
|
|||||||
sourceUrl,
|
sourceUrl,
|
||||||
type: type ?? "lecture",
|
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);
|
play(track);
|
||||||
|
if (fallbackYear) {
|
||||||
|
void yearQueue(fallbackYear).then((tracks) => {
|
||||||
|
if (tracks && tracks.length > 1) attachQueue(tracks);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
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 { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
||||||
import { parseChapter, parseVerse } from "@/lib/book-reference";
|
import { parseChapter, parseVerse } from "@/lib/book-reference";
|
||||||
import {
|
import {
|
||||||
@@ -417,6 +425,13 @@ export function TranscriptionBookBrowser({
|
|||||||
contentRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
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 activeIdx = flat.findIndex((f) => f.chapter.key === activeKey);
|
||||||
const active = activeIdx >= 0 ? flat[activeIdx] : null;
|
const active = activeIdx >= 0 ? flat[activeIdx] : null;
|
||||||
const prev = activeIdx > 0 ? flat[activeIdx - 1] : null;
|
const prev = activeIdx > 0 ? flat[activeIdx - 1] : null;
|
||||||
@@ -463,42 +478,44 @@ export function TranscriptionBookBrowser({
|
|||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||||
{/* Top navigation */}
|
<TranscriptQueue items={queueItems}>
|
||||||
<ChapterNav
|
{/* Top navigation */}
|
||||||
prev={prev}
|
<ChapterNav
|
||||||
next={next}
|
prev={prev}
|
||||||
sections={sections}
|
next={next}
|
||||||
activeKey={activeKey}
|
sections={sections}
|
||||||
onSelect={handleSelect}
|
activeKey={activeKey}
|
||||||
drawerTitle={title}
|
onSelect={handleSelect}
|
||||||
/>
|
drawerTitle={title}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Content — every lecture of the active chapter (all years/places) */}
|
{/* Content — every lecture of the active chapter (all years/places) */}
|
||||||
{!active || active.chapter.transcriptions.length === 0 ? (
|
{!active || active.chapter.transcriptions.length === 0 ? (
|
||||||
<div className="py-12 text-center text-muted-foreground">
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
<p className="text-lg font-medium">
|
<p className="text-lg font-medium">
|
||||||
No transcriptions for this chapter
|
No transcriptions for this chapter
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardContent className="p-0 divide-y divide-border">
|
<CardContent className="p-0 divide-y divide-border">
|
||||||
{active.chapter.transcriptions.map((t) => (
|
{active.chapter.transcriptions.map((t) => (
|
||||||
<LectureRow key={t.slug} t={t} />
|
<LectureRow key={t.slug} t={t} />
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom navigation */}
|
{/* Bottom navigation */}
|
||||||
<ChapterNav
|
<ChapterNav
|
||||||
prev={prev}
|
prev={prev}
|
||||||
next={next}
|
next={next}
|
||||||
sections={sections}
|
sections={sections}
|
||||||
activeKey={activeKey}
|
activeKey={activeKey}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
drawerTitle={title}
|
drawerTitle={title}
|
||||||
/>
|
/>
|
||||||
|
</TranscriptQueue>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PageHeader } from "@/components/layout/page-header";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Calendar, Clock } from "lucide-react";
|
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";
|
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
@@ -55,58 +56,61 @@ export default async function TranscriptionsByPlacePage({
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
<Card className="overflow-hidden">
|
{/* Next / Previous in the player follow this list. */}
|
||||||
<CardContent className="p-0 divide-y divide-border">
|
<TranscriptQueue items={transcriptions}>
|
||||||
{transcriptions.map((t) => (
|
<Card className="overflow-hidden">
|
||||||
<Link
|
<CardContent className="p-0 divide-y divide-border">
|
||||||
key={t.slug}
|
{transcriptions.map((t) => (
|
||||||
href={`/transcriptions/${t.slug}`}
|
<Link
|
||||||
className="flex items-center px-5 py-3.5 gap-3 hover:bg-muted transition-colors"
|
key={t.slug}
|
||||||
>
|
href={`/transcriptions/${t.slug}`}
|
||||||
<div className="min-w-0 flex-1">
|
className="flex items-center px-5 py-3.5 gap-3 hover:bg-muted transition-colors"
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
>
|
||||||
<p className="font-medium text-foreground text-sm">
|
<div className="min-w-0 flex-1">
|
||||||
{t.name}
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
</p>
|
<p className="font-medium text-foreground text-sm">
|
||||||
<Badge
|
{t.name}
|
||||||
variant={t.type === "lecture" ? "default" : "secondary"}
|
</p>
|
||||||
className="text-[10px] px-1.5 py-0"
|
<Badge
|
||||||
>
|
variant={t.type === "lecture" ? "default" : "secondary"}
|
||||||
{t.type === "lecture" ? "Lecture" : "Conversation"}
|
className="text-[10px] px-1.5 py-0"
|
||||||
</Badge>
|
>
|
||||||
|
{t.type === "lecture" ? "Lecture" : "Conversation"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1 text-xs text-muted-foreground">
|
||||||
|
{t.date && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="size-3" />
|
||||||
|
{t.date}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{t.reference && (
|
||||||
|
<span className="text-primary/70">{t.reference}</span>
|
||||||
|
)}
|
||||||
|
{t.durationMinutes && t.durationMinutes > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="size-3" />
|
||||||
|
{t.durationMinutes} min
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1 text-xs text-muted-foreground">
|
{t.audioUrl && (
|
||||||
{t.date && (
|
<PlayAudioButton
|
||||||
<span className="flex items-center gap-1">
|
audioUrl={t.audioUrl}
|
||||||
<Calendar className="size-3" />
|
title={t.name}
|
||||||
{t.date}
|
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
|
||||||
</span>
|
sourceUrl={`/transcriptions/${t.slug}`}
|
||||||
)}
|
type={t.type}
|
||||||
{t.reference && (
|
variant="compact"
|
||||||
<span className="text-primary/70">{t.reference}</span>
|
/>
|
||||||
)}
|
)}
|
||||||
{t.durationMinutes && t.durationMinutes > 0 && (
|
</Link>
|
||||||
<span className="flex items-center gap-1">
|
))}
|
||||||
<Clock className="size-3" />
|
</CardContent>
|
||||||
{t.durationMinutes} min
|
</Card>
|
||||||
</span>
|
</TranscriptQueue>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{t.audioUrl && (
|
|
||||||
<PlayAudioButton
|
|
||||||
audioUrl={t.audioUrl}
|
|
||||||
title={t.name}
|
|
||||||
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
|
|
||||||
sourceUrl={`/transcriptions/${t.slug}`}
|
|
||||||
type={t.type}
|
|
||||||
variant="compact"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
MapPin,
|
MapPin,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { TranscriptQueue } from "@/components/audio-player/transcript-queue";
|
||||||
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -481,6 +482,16 @@ export function TranscriptionTypeBrowser({
|
|||||||
[activeGroup],
|
[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 activeYearLabel = activeGroup?.label ?? "Unknown";
|
||||||
const drawerTitle = typeLabel;
|
const drawerTitle = typeLabel;
|
||||||
|
|
||||||
@@ -520,46 +531,48 @@ export function TranscriptionTypeBrowser({
|
|||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||||
{/* Top navigation */}
|
<TranscriptQueue items={queueItems}>
|
||||||
<YearNav
|
{/* Top navigation */}
|
||||||
prev={prev}
|
<YearNav
|
||||||
next={next}
|
prev={prev}
|
||||||
groups={yearGroups}
|
next={next}
|
||||||
activeYear={activeYear}
|
groups={yearGroups}
|
||||||
onSelect={handleYearSelect}
|
activeYear={activeYear}
|
||||||
drawerTitle={drawerTitle}
|
onSelect={handleYearSelect}
|
||||||
/>
|
drawerTitle={drawerTitle}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Content — transcriptions grouped by month */}
|
{/* Content — transcriptions grouped by month */}
|
||||||
{monthGroups.length === 0 ? (
|
{monthGroups.length === 0 ? (
|
||||||
<div className="py-12 text-center text-muted-foreground">
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
<p className="text-lg font-medium">
|
<p className="text-lg font-medium">
|
||||||
No transcriptions for this year
|
No transcriptions for this year
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardContent className="p-0 divide-y divide-border">
|
<CardContent className="p-0 divide-y divide-border">
|
||||||
{monthGroups.map((mg, i) => (
|
{monthGroups.map((mg, i) => (
|
||||||
<MonthSection
|
<MonthSection
|
||||||
key={mg.month}
|
key={mg.month}
|
||||||
group={mg}
|
group={mg}
|
||||||
defaultOpen={i === 0 || monthGroups.length <= 3}
|
defaultOpen={i === 0 || monthGroups.length <= 3}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom navigation */}
|
{/* Bottom navigation */}
|
||||||
<YearNav
|
<YearNav
|
||||||
prev={prev}
|
prev={prev}
|
||||||
next={next}
|
next={next}
|
||||||
groups={yearGroups}
|
groups={yearGroups}
|
||||||
activeYear={activeYear}
|
activeYear={activeYear}
|
||||||
onSelect={handleYearSelect}
|
onSelect={handleYearSelect}
|
||||||
drawerTitle={drawerTitle}
|
drawerTitle={drawerTitle}
|
||||||
/>
|
/>
|
||||||
|
</TranscriptQueue>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Calendar, ChevronRight, Clock, MapPin } from "lucide-react";
|
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";
|
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
||||||
|
|
||||||
type TranscriptionItem = {
|
type TranscriptionItem = {
|
||||||
@@ -169,6 +170,12 @@ export function TranscriptionYearBrowser({
|
|||||||
return groups;
|
return groups;
|
||||||
}, [transcriptions]);
|
}, [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(
|
const lectureCount = transcriptions.filter(
|
||||||
(t) => t.type === "lecture",
|
(t) => t.type === "lecture",
|
||||||
).length;
|
).length;
|
||||||
@@ -202,19 +209,21 @@ export function TranscriptionYearBrowser({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="overflow-hidden">
|
<TranscriptQueue items={queueItems}>
|
||||||
<CardContent className="p-0 divide-y divide-border">
|
<Card className="overflow-hidden">
|
||||||
{monthGroups.map((mg, i) => (
|
<CardContent className="p-0 divide-y divide-border">
|
||||||
<MonthSection
|
{monthGroups.map((mg, i) => (
|
||||||
key={mg.month}
|
<MonthSection
|
||||||
month={mg.month}
|
key={mg.month}
|
||||||
label={mg.label}
|
month={mg.month}
|
||||||
items={mg.items}
|
label={mg.label}
|
||||||
defaultOpen={i === 0 || monthGroups.length <= 3}
|
items={mg.items}
|
||||||
/>
|
defaultOpen={i === 0 || monthGroups.length <= 3}
|
||||||
))}
|
/>
|
||||||
</CardContent>
|
))}
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TranscriptQueue>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ type AudioPlayerState = {
|
|||||||
type AudioPlayerActions = {
|
type AudioPlayerActions = {
|
||||||
play: (track: AudioTrack) => void;
|
play: (track: AudioTrack) => void;
|
||||||
playPlaylist: (tracks: AudioTrack[], startIndex?: number) => 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;
|
pause: () => void;
|
||||||
resume: () => void;
|
resume: () => void;
|
||||||
togglePlayPause: () => void;
|
togglePlayPause: () => void;
|
||||||
@@ -469,6 +475,14 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
[saveCurrent],
|
[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(() => {
|
const pause = useCallback(() => {
|
||||||
audioRef.current?.pause();
|
audioRef.current?.pause();
|
||||||
saveCurrent();
|
saveCurrent();
|
||||||
@@ -630,6 +644,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
currentIndex,
|
currentIndex,
|
||||||
play,
|
play,
|
||||||
playPlaylist,
|
playPlaylist,
|
||||||
|
attachQueue,
|
||||||
pause,
|
pause,
|
||||||
resume,
|
resume,
|
||||||
togglePlayPause,
|
togglePlayPause,
|
||||||
|
|||||||
@@ -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