"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

{title}

{error}

); } function AudioLoadingState({ title }: { title: string }) { return (

{title}

); } /** * 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 (