audio player respects group context

This commit is contained in:
gosangam
2026-10-15 23:18:05 +05:30
parent 09dc734613
commit 8537eb6ce2
8 changed files with 379 additions and 138 deletions
+15
View File
@@ -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,
@@ -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>
);
}