diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx index 868080dbd..0e3485464 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx @@ -1,12 +1,13 @@ "use client"; -import { AlertCircle, Pencil, Podcast, RefreshCw } from "lucide-react"; +import { AlertCircle, Pencil, Play, Podcast, RefreshCw } from "lucide-react"; import { useCallback, useContext, useTransition } from "react"; import { cn } from "@/lib/utils"; import { chatInterfaceContext } from "../ChatInterface"; import { getPodcastStalenessMessage, isPodcastStale } from "../PodcastUtils"; import type { GeneratePodcastRequest } from "./ChatPanelContainer"; import { ConfigModal } from "./ConfigModal"; +import { PodcastPlayer } from "./PodcastPlayer"; interface ChatPanelViewProps { generatePodcast: (request: GeneratePodcastRequest) => Promise; @@ -112,6 +113,25 @@ export function ChatPanelView(props: ChatPanelViewProps) { )} +
+ {isChatPannelOpen ? ( + + ) : podcast ? ( + + ) : null} +
); } diff --git a/surfsense_web/components/chat/ChatPanel/PodcastPlayer/PodcastPlayer.tsx b/surfsense_web/components/chat/ChatPanel/PodcastPlayer/PodcastPlayer.tsx new file mode 100644 index 000000000..e17306f66 --- /dev/null +++ b/surfsense_web/components/chat/ChatPanel/PodcastPlayer/PodcastPlayer.tsx @@ -0,0 +1,321 @@ +"use client"; + +import { Pause, Play, Podcast, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react"; +import { motion } from "motion/react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; +import { Button } from "@/components/ui/button"; +import { Slider } from "@/components/ui/slider"; +import { PodcastPlayerCompactSkeleton } from "./PodcastPlayerCompactSkeleton"; + +interface PodcastPlayerProps { + podcast: PodcastItem | null; + isLoading?: boolean; + onClose?: () => void; + compact?: boolean; +} + +export function PodcastPlayer({ + podcast, + isLoading = false, + onClose, + compact = false, +}: PodcastPlayerProps) { + const [audioSrc, setAudioSrc] = useState(undefined); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [volume, setVolume] = useState(0.7); + const [isMuted, setIsMuted] = useState(false); + const [isFetching, setIsFetching] = useState(false); + const audioRef = useRef(null); + const currentObjectUrlRef = useRef(null); + + // Cleanup object URL on unmount + useEffect(() => { + return () => { + if (currentObjectUrlRef.current) { + URL.revokeObjectURL(currentObjectUrlRef.current); + currentObjectUrlRef.current = null; + } + }; + }, []); + + // Load podcast audio when podcast changes + useEffect(() => { + if (!podcast) { + setAudioSrc(undefined); + setCurrentTime(0); + setDuration(0); + setIsPlaying(false); + setIsFetching(false); + return; + } + + const loadPodcast = async () => { + setIsFetching(true); + try { + const token = localStorage.getItem("surfsense_bearer_token"); + if (!token) { + throw new Error("Authentication token not found."); + } + + // Revoke previous object URL if exists + if (currentObjectUrlRef.current) { + URL.revokeObjectURL(currentObjectUrlRef.current); + currentObjectUrlRef.current = null; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + signal: controller.signal, + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch audio stream: ${response.statusText}`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + currentObjectUrlRef.current = objectUrl; + setAudioSrc(objectUrl); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error("Request timed out. Please try again."); + } + throw error; + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + console.error("Error fetching podcast:", error); + toast.error(error instanceof Error ? error.message : "Failed to load podcast audio."); + setAudioSrc(undefined); + } finally { + setIsFetching(false); + } + }; + + loadPodcast(); + }, [podcast]); + + const handleTimeUpdate = () => { + if (audioRef.current) { + setCurrentTime(audioRef.current.currentTime); + } + }; + + const handleMetadataLoaded = () => { + if (audioRef.current) { + setDuration(audioRef.current.duration); + } + }; + + const togglePlayPause = () => { + if (audioRef.current) { + if (isPlaying) { + audioRef.current.pause(); + } else { + audioRef.current.play(); + } + setIsPlaying(!isPlaying); + } + }; + + const handleSeek = (value: number[]) => { + if (audioRef.current) { + audioRef.current.currentTime = value[0]; + setCurrentTime(value[0]); + } + }; + + const handleVolumeChange = (value: number[]) => { + if (audioRef.current) { + const newVolume = value[0]; + audioRef.current.volume = newVolume; + setVolume(newVolume); + + if (newVolume === 0) { + audioRef.current.muted = true; + setIsMuted(true); + } else { + audioRef.current.muted = false; + setIsMuted(false); + } + } + }; + + const toggleMute = () => { + if (audioRef.current) { + const newMutedState = !isMuted; + audioRef.current.muted = newMutedState; + setIsMuted(newMutedState); + + if (!newMutedState && volume === 0) { + const restoredVolume = 0.5; + audioRef.current.volume = restoredVolume; + setVolume(restoredVolume); + } + } + }; + + const skipForward = () => { + if (audioRef.current) { + audioRef.current.currentTime = Math.min( + audioRef.current.duration, + audioRef.current.currentTime + 10 + ); + } + }; + + const skipBackward = () => { + if (audioRef.current) { + audioRef.current.currentTime = Math.max(0, audioRef.current.currentTime - 10); + } + }; + + const formatTime = (time: number) => { + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`; + }; + + // Show skeleton while fetching + if (isFetching && compact) { + return ; + } + + if (!podcast || !audioSrc) { + return null; + } + + if (compact) { + return ( + <> +
+
+ + + +

{podcast.title}

+ {onClose && ( + + + + )} +
+ +
+ +
+ {formatTime(currentTime)} / {formatTime(duration)} +
+
+ +
+ + + + + + + + + + + + + + + +
+
+ + + + ); + } + + return null; +} diff --git a/surfsense_web/components/chat/ChatPanel/PodcastPlayer/PodcastPlayerCompactSkeleton.tsx b/surfsense_web/components/chat/ChatPanel/PodcastPlayer/PodcastPlayerCompactSkeleton.tsx new file mode 100644 index 000000000..d7007dadd --- /dev/null +++ b/surfsense_web/components/chat/ChatPanel/PodcastPlayer/PodcastPlayerCompactSkeleton.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Podcast } from "lucide-react"; +import { motion } from "motion/react"; + +export function PodcastPlayerCompactSkeleton() { + return ( +
+ {/* Header with icon and title */} +
+ + + + {/* Title skeleton */} +
+
+ + {/* Progress bar skeleton */} +
+
+
+
+ + {/* Controls skeleton */} +
+
+
+
+
+
+
+ ); +} diff --git a/surfsense_web/components/chat/ChatPanel/PodcastPlayer/index.ts b/surfsense_web/components/chat/ChatPanel/PodcastPlayer/index.ts new file mode 100644 index 000000000..55c19f934 --- /dev/null +++ b/surfsense_web/components/chat/ChatPanel/PodcastPlayer/index.ts @@ -0,0 +1,2 @@ +export { PodcastPlayer } from "./PodcastPlayer"; +export { PodcastPlayerCompactSkeleton } from "./PodcastPlayerCompactSkeleton";