"use client";
import { useParams, usePathname } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { z } from "zod";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Audio } from "@/components/tool-ui/audio";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { baseApiService } from "@/lib/apis/base-api.service";
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { buildBackendUrl } from "@/lib/env-config";
import { speakerLabel } from "./schema";
// Public snapshots predate the transcript.turns shape and keep their own.
const publicPodcastDetailsSchema = z.object({
podcast_transcript: z.array(z.object({ speaker_id: z.number(), dialog: z.string() })).nullish(),
});
interface TranscriptLine {
// Transcripts are immutable once fetched, so a turn's position identifies it.
key: string;
label: string;
text: string;
}
export function PodcastErrorState({ title, error }: { title: string; error: string }) {
return (
Podcast Generation Failed
);
}
function AudioLoadingState({ title }: { title: string }) {
return (
);
}
/**
* Streams the rendered episode and shows its transcript. Works in two modes:
* authenticated (lifecycle stream + detail endpoints) and public shared chat
* (share-token snapshot endpoints), detected from the route.
*/
export function PodcastPlayer({
podcastId,
title,
durationMs,
}: {
podcastId: number;
title: string;
durationMs?: number;
}) {
const params = useParams();
const pathname = usePathname();
const isPublicRoute = pathname?.startsWith("/public/");
const shareToken = isPublicRoute && typeof params?.token === "string" ? params.token : null;
const [audioSrc, setAudioSrc] = useState(null);
const [transcriptLines, setTranscriptLines] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const objectUrlRef = useRef(null);
useEffect(() => {
return () => {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
}
};
}, []);
const loadPodcast = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000);
try {
let audioBlob: Blob;
let lines: TranscriptLine[] = [];
if (shareToken) {
const [blob, details] = await Promise.all([
baseApiService.getBlob(`/api/v1/public/${shareToken}/podcasts/${podcastId}/stream`),
baseApiService.get(`/api/v1/public/${shareToken}/podcasts/${podcastId}`),
]);
audioBlob = blob;
const parsed = publicPodcastDetailsSchema.safeParse(details);
lines = (parsed.success ? (parsed.data.podcast_transcript ?? []) : []).map(
(entry, turn) => ({
key: `turn-${turn}`,
label: `Speaker ${entry.speaker_id + 1}`,
text: entry.dialog,
})
);
} else {
const [audioResponse, detail] = await Promise.all([
authenticatedFetch(buildBackendUrl(`/api/v1/podcasts/${podcastId}/stream`), {
method: "GET",
signal: controller.signal,
}),
podcastsApiService.getDetail(podcastId),
]);
if (!audioResponse.ok) {
throw new Error(`Failed to load audio: ${audioResponse.status}`);
}
audioBlob = await audioResponse.blob();
lines = (detail.transcript?.turns ?? []).map((entry, turn) => ({
key: `turn-${turn}`,
label: speakerLabel(detail.spec, entry.speaker),
text: entry.text,
}));
}
const objectUrl = URL.createObjectURL(audioBlob);
objectUrlRef.current = objectUrl;
setAudioSrc(objectUrl);
setTranscriptLines(lines);
} finally {
clearTimeout(timeoutId);
}
} catch (err) {
console.error("Error loading podcast:", err);
if (err instanceof DOMException && err.name === "AbortError") {
setError("Request timed out. Please try again.");
} else {
setError(err instanceof Error ? err.message : "Failed to load podcast");
}
} finally {
setIsLoading(false);
}
}, [podcastId, shareToken]);
useEffect(() => {
loadPodcast();
}, [loadPodcast]);
if (isLoading) {
return ;
}
if (error || !audioSrc) {
return ;
}
const hasTranscript = transcriptLines && transcriptLines.length > 0;
return (
{hasTranscript ? (
View transcript
{transcriptLines.map((line) => (
{line.label}:{" "}
{line.text}
))}
) : null}
);
}