Merge pull request #178 from ritikprajapat21/main

Fix issue #152: Added close functionality to player
This commit is contained in:
Rohan Verma 2025-07-08 12:10:22 -07:00 committed by GitHub
commit d611bd6303
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,7 +1,7 @@
'use client'; "use client";
import { format } from 'date-fns'; import { format } from "date-fns";
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from "framer-motion";
import { import {
Calendar, Calendar,
MoreHorizontal, MoreHorizontal,
@ -12,13 +12,15 @@ import {
SkipBack, SkipBack,
SkipForward, SkipForward,
Trash2, Trash2,
Volume2, VolumeX Volume2,
} from 'lucide-react'; VolumeX,
import { useEffect, useRef, useState } from 'react'; X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
// UI Components // UI Components
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { Card } from '@/components/ui/card'; import { Card } from "@/components/ui/card";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -31,9 +33,9 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from "@/components/ui/dropdown-menu";
import { Input } from '@/components/ui/input'; import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -42,7 +44,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Slider } from '@/components/ui/slider'; import { Slider } from "@/components/ui/slider";
import { toast } from "sonner"; import { toast } from "sonner";
interface PodcastItem { interface PodcastItem {
@ -60,32 +62,47 @@ interface PodcastsPageClientProps {
const pageVariants = { const pageVariants = {
initial: { opacity: 0 }, initial: { opacity: 0 },
enter: { opacity: 1, transition: { duration: 0.4, ease: 'easeInOut', staggerChildren: 0.1 } }, enter: {
exit: { opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } } opacity: 1,
transition: { duration: 0.4, ease: "easeInOut", staggerChildren: 0.1 },
},
exit: { opacity: 0, transition: { duration: 0.3, ease: "easeInOut" } },
}; };
const podcastCardVariants = { const podcastCardVariants = {
initial: { scale: 0.95, y: 20, opacity: 0 }, initial: { scale: 0.95, y: 20, opacity: 0 },
animate: { scale: 1, y: 0, opacity: 1, transition: { type: "spring", stiffness: 300, damping: 25 } }, animate: {
scale: 1,
y: 0,
opacity: 1,
transition: { type: "spring", stiffness: 300, damping: 25 },
},
exit: { scale: 0.95, y: -20, opacity: 0 }, exit: { scale: 0.95, y: -20, opacity: 0 },
hover: { y: -5, scale: 1.02, transition: { duration: 0.2 } } hover: { y: -5, scale: 1.02, transition: { duration: 0.2 } },
}; };
const MotionCard = motion(Card); const MotionCard = motion(Card);
export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClientProps) { export default function PodcastsPageClient({
searchSpaceId,
}: PodcastsPageClientProps) {
const [podcasts, setPodcasts] = useState<PodcastItem[]>([]); const [podcasts, setPodcasts] = useState<PodcastItem[]>([]);
const [filteredPodcasts, setFilteredPodcasts] = useState<PodcastItem[]>([]); const [filteredPodcasts, setFilteredPodcasts] = useState<PodcastItem[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState("");
const [sortOrder, setSortOrder] = useState<string>('newest'); const [sortOrder, setSortOrder] = useState<string>("newest");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [podcastToDelete, setPodcastToDelete] = useState<{ id: number, title: string } | null>(null); const [podcastToDelete, setPodcastToDelete] = useState<{
id: number;
title: string;
} | null>(null);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
// Audio player state // Audio player state
const [currentPodcast, setCurrentPodcast] = useState<PodcastItem | null>(null); const [currentPodcast, setCurrentPodcast] = useState<PodcastItem | null>(
null,
);
const [audioSrc, setAudioSrc] = useState<string | undefined>(undefined); const [audioSrc, setAudioSrc] = useState<string | undefined>(undefined);
const [isAudioLoading, setIsAudioLoading] = useState(false); const [isAudioLoading, setIsAudioLoading] = useState(false);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
@ -97,7 +114,8 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
const currentObjectUrlRef = useRef<string | null>(null); const currentObjectUrlRef = useRef<string | null>(null);
// Add podcast image URL constant // Add podcast image URL constant
const PODCAST_IMAGE_URL = "https://static.vecteezy.com/system/resources/thumbnails/002/157/611/small_2x/illustrations-concept-design-podcast-channel-free-vector.jpg"; const PODCAST_IMAGE_URL =
"https://static.vecteezy.com/system/resources/thumbnails/002/157/611/small_2x/illustrations-concept-design-podcast-channel-free-vector.jpg";
// Fetch podcasts from API // Fetch podcasts from API
useEffect(() => { useEffect(() => {
@ -106,10 +124,10 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
setIsLoading(true); setIsLoading(true);
// Get token from localStorage // Get token from localStorage
const token = localStorage.getItem('surfsense_bearer_token'); const token = localStorage.getItem("surfsense_bearer_token");
if (!token) { if (!token) {
setError('Authentication token not found. Please log in again.'); setError("Authentication token not found. Please log in again.");
setIsLoading(false); setIsLoading(false);
return; return;
} }
@ -119,16 +137,18 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/`, `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/`,
{ {
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', "Content-Type": "application/json",
},
cache: "no-store",
}, },
cache: 'no-store',
}
); );
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => null); const errorData = await response.json().catch(() => null);
throw new Error(`Failed to fetch podcasts: ${response.status} ${errorData?.detail || ''}`); throw new Error(
`Failed to fetch podcasts: ${response.status} ${errorData?.detail || ""}`,
);
} }
const data: PodcastItem[] = await response.json(); const data: PodcastItem[] = await response.json();
@ -136,8 +156,10 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
setFilteredPodcasts(data); setFilteredPodcasts(data);
setError(null); setError(null);
} catch (error) { } catch (error) {
console.error('Error fetching podcasts:', error); console.error("Error fetching podcasts:", error);
setError(error instanceof Error ? error.message : 'Unknown error occurred'); setError(
error instanceof Error ? error.message : "Unknown error occurred",
);
setPodcasts([]); setPodcasts([]);
setFilteredPodcasts([]); setFilteredPodcasts([]);
} finally { } finally {
@ -155,14 +177,14 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
// Filter by search term // Filter by search term
if (searchQuery) { if (searchQuery) {
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
result = result.filter(podcast => result = result.filter((podcast) =>
podcast.title.toLowerCase().includes(query) podcast.title.toLowerCase().includes(query),
); );
} }
// Filter by search space // Filter by search space
result = result.filter(podcast => result = result.filter(
podcast.search_space_id === parseInt(searchSpaceId) (podcast) => podcast.search_space_id === parseInt(searchSpaceId),
); );
// Sort podcasts // Sort podcasts
@ -170,7 +192,7 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
const dateA = new Date(a.created_at).getTime(); const dateA = new Date(a.created_at).getTime();
const dateB = new Date(b.created_at).getTime(); const dateB = new Date(b.created_at).getTime();
return sortOrder === 'newest' ? dateB - dateA : dateA - dateB; return sortOrder === "newest" ? dateB - dateA : dateA - dateB;
}); });
setFilteredPodcasts(result); setFilteredPodcasts(result);
@ -212,6 +234,17 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
} }
}; };
// To close player
const closePlayer = () => {
if (isPlaying) {
audioRef.current?.pause();
}
setIsPlaying(false);
setAudioSrc(undefined);
setCurrentTime(0);
setCurrentPodcast(null);
};
// Seek to position // Seek to position
const handleSeek = (value: number[]) => { const handleSeek = (value: number[]) => {
if (audioRef.current) { if (audioRef.current) {
@ -259,14 +292,20 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
// Skip forward 10 seconds // Skip forward 10 seconds
const skipForward = () => { const skipForward = () => {
if (audioRef.current) { if (audioRef.current) {
audioRef.current.currentTime = Math.min(audioRef.current.duration, audioRef.current.currentTime + 10); audioRef.current.currentTime = Math.min(
audioRef.current.duration,
audioRef.current.currentTime + 10,
);
} }
}; };
// Skip backward 10 seconds // Skip backward 10 seconds
const skipBackward = () => { const skipBackward = () => {
if (audioRef.current) { if (audioRef.current) {
audioRef.current.currentTime = Math.max(0, audioRef.current.currentTime - 10); audioRef.current.currentTime = Math.max(
0,
audioRef.current.currentTime - 10,
);
} }
}; };
@ -274,7 +313,7 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
const formatTime = (time: number) => { const formatTime = (time: number) => {
const minutes = Math.floor(time / 60); const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60); const seconds = Math.floor(time % 60);
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
}; };
// Play podcast - Fetch blob and set object URL // Play podcast - Fetch blob and set object URL
@ -299,9 +338,9 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
setIsPlaying(false); setIsPlaying(false);
setIsAudioLoading(true); setIsAudioLoading(true);
const token = localStorage.getItem('surfsense_bearer_token'); const token = localStorage.getItem("surfsense_bearer_token");
if (!token) { if (!token) {
throw new Error('Authentication token not found.'); throw new Error("Authentication token not found.");
} }
// Revoke previous object URL if exists (only after we've started the new request) // Revoke previous object URL if exists (only after we've started the new request)
@ -319,14 +358,16 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`, `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`,
{ {
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
},
signal: controller.signal,
}, },
signal: controller.signal
}
); );
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch audio stream: ${response.statusText}`); throw new Error(
`Failed to fetch audio stream: ${response.statusText}`,
);
} }
const blob = await response.blob(); const blob = await response.blob();
@ -339,16 +380,20 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
// Wait for the audio to be ready before playing // Wait for the audio to be ready before playing
// We'll handle actual playback in the onLoadedData event instead of here // We'll handle actual playback in the onLoadedData event instead of here
} catch (error) { } catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') { if (error instanceof DOMException && error.name === "AbortError") {
throw new Error('Request timed out. Please try again.'); throw new Error("Request timed out. Please try again.");
} }
throw error; throw error;
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
} catch (error) { } catch (error) {
console.error('Error fetching or playing podcast:', error); console.error("Error fetching or playing podcast:", error);
toast.error(error instanceof Error ? error.message : 'Failed to load podcast audio.'); toast.error(
error instanceof Error
? error.message
: "Failed to load podcast audio.",
);
// Reset state on error // Reset state on error
setCurrentPodcast(null); setCurrentPodcast(null);
setAudioSrc(undefined); setAudioSrc(undefined);
@ -363,19 +408,22 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
setIsDeleting(true); setIsDeleting(true);
try { try {
const token = localStorage.getItem('surfsense_bearer_token'); const token = localStorage.getItem("surfsense_bearer_token");
if (!token) { if (!token) {
setIsDeleting(false); setIsDeleting(false);
return; return;
} }
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastToDelete.id}`, { const response = await fetch(
method: 'DELETE', `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastToDelete.id}`,
{
method: "DELETE",
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', "Content-Type": "application/json",
} },
}); },
);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to delete podcast: ${response.statusText}`); throw new Error(`Failed to delete podcast: ${response.statusText}`);
@ -386,7 +434,9 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
setPodcastToDelete(null); setPodcastToDelete(null);
// Update local state by removing the deleted podcast // Update local state by removing the deleted podcast
setPodcasts(prevPodcasts => prevPodcasts.filter(podcast => podcast.id !== podcastToDelete.id)); setPodcasts((prevPodcasts) =>
prevPodcasts.filter((podcast) => podcast.id !== podcastToDelete.id),
);
// If the current playing podcast is deleted, stop playback // If the current playing podcast is deleted, stop playback
if (currentPodcast && currentPodcast.id === podcastToDelete.id) { if (currentPodcast && currentPodcast.id === podcastToDelete.id) {
@ -397,10 +447,12 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
setIsPlaying(false); setIsPlaying(false);
} }
toast.success('Podcast deleted successfully'); toast.success("Podcast deleted successfully");
} catch (error) { } catch (error) {
console.error('Error deleting podcast:', error); console.error("Error deleting podcast:", error);
toast.error(error instanceof Error ? error.message : 'Failed to delete podcast'); toast.error(
error instanceof Error ? error.message : "Failed to delete podcast",
);
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
} }
@ -455,7 +507,9 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
<div className="flex items-center justify-center h-40"> <div className="flex items-center justify-center h-40">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div> <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
<p className="text-sm text-muted-foreground">Loading podcasts...</p> <p className="text-sm text-muted-foreground">
Loading podcasts...
</p>
</div> </div>
</div> </div>
)} )}
@ -473,8 +527,8 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
<h3 className="font-medium">No podcasts found</h3> <h3 className="font-medium">No podcasts found</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{searchQuery {searchQuery
? 'Try adjusting your search filters' ? "Try adjusting your search filters"
: 'Generate podcasts from your chats to get started'} : "Generate podcasts from your chats to get started"}
</p> </p>
</div> </div>
)} )}
@ -502,14 +556,12 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
bg-card/60 dark:bg-card/40 backdrop-blur-lg rounded-xl p-4 bg-card/60 dark:bg-card/40 backdrop-blur-lg rounded-xl p-4
shadow-md hover:shadow-xl transition-all duration-300 shadow-md hover:shadow-xl transition-all duration-300
border-border overflow-hidden cursor-pointer border-border overflow-hidden cursor-pointer
${currentPodcast?.id === podcast.id ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : ''} ${currentPodcast?.id === podcast.id ? "ring-2 ring-primary ring-offset-2 ring-offset-background" : ""}
`} `}
layout layout
onClick={() => playPodcast(podcast)} onClick={() => playPodcast(podcast)}
> >
<div <div className="relative w-full aspect-[16/10] mb-4 rounded-lg overflow-hidden">
className="relative w-full aspect-[16/10] mb-4 rounded-lg overflow-hidden"
>
{/* Podcast image with gradient overlay */} {/* Podcast image with gradient overlay */}
<img <img
src={PODCAST_IMAGE_URL} src={PODCAST_IMAGE_URL}
@ -537,13 +589,18 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
transition={{ type: "spring", damping: 20 }} transition={{ type: "spring", damping: 20 }}
> >
<div className="h-14 w-14 rounded-full border-4 border-primary/30 border-t-primary animate-spin"></div> <div className="h-14 w-14 rounded-full border-4 border-primary/30 border-t-primary animate-spin"></div>
<p className="text-sm text-foreground font-medium">Loading podcast...</p> <p className="text-sm text-foreground font-medium">
Loading podcast...
</p>
</motion.div> </motion.div>
</motion.div> </motion.div>
)} )}
{/* Play button with animations */} {/* Play button with animations */}
{!(currentPodcast?.id === podcast.id && (isPlaying || isAudioLoading)) && ( {!(
currentPodcast?.id === podcast.id &&
(isPlaying || isAudioLoading)
) && (
<motion.div <motion.div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10" className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
whileHover={{ scale: 1.1 }} whileHover={{ scale: 1.1 }}
@ -565,7 +622,11 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
<motion.div <motion.div
initial={{ scale: 0.8 }} initial={{ scale: 0.8 }}
animate={{ scale: 1 }} animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 400, damping: 10 }} transition={{
type: "spring",
stiffness: 400,
damping: 10,
}}
className="text-primary w-10 h-10 flex items-center justify-center" className="text-primary w-10 h-10 flex items-center justify-center"
> >
<Play className="h-8 w-8 ml-1" /> <Play className="h-8 w-8 ml-1" />
@ -575,7 +636,9 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
)} )}
{/* Pause button with animations */} {/* Pause button with animations */}
{currentPodcast?.id === podcast.id && isPlaying && !isAudioLoading && ( {currentPodcast?.id === podcast.id &&
isPlaying &&
!isAudioLoading && (
<motion.div <motion.div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10" className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
whileHover={{ scale: 1.1 }} whileHover={{ scale: 1.1 }}
@ -597,7 +660,11 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
<motion.div <motion.div
initial={{ scale: 0.8 }} initial={{ scale: 0.8 }}
animate={{ scale: 1 }} animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 400, damping: 10 }} transition={{
type: "spring",
stiffness: 400,
damping: 10,
}}
className="text-primary w-10 h-10 flex items-center justify-center" className="text-primary w-10 h-10 flex items-center justify-center"
> >
<Pause className="h-8 w-8" /> <Pause className="h-8 w-8" />
@ -619,12 +686,15 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
</div> </div>
<div className="mb-3 px-1"> <div className="mb-3 px-1">
<h3 className="text-base font-semibold text-foreground truncate" title={podcast.title}> <h3
{podcast.title || 'Untitled Podcast'} className="text-base font-semibold text-foreground truncate"
title={podcast.title}
>
{podcast.title || "Untitled Podcast"}
</h3> </h3>
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1.5"> <p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1.5">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
{format(new Date(podcast.created_at), 'MMM d, yyyy')} {format(new Date(podcast.created_at), "MMM d, yyyy")}
</p> </p>
</div> </div>
@ -643,14 +713,19 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
const container = e.currentTarget; const container = e.currentTarget;
const rect = container.getBoundingClientRect(); const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left; const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width)); const percentage = Math.max(
0,
Math.min(1, x / rect.width),
);
const newTime = percentage * duration; const newTime = percentage * duration;
handleSeek([newTime]); handleSeek([newTime]);
}} }}
> >
<motion.div <motion.div
className="h-full bg-primary rounded-full relative" className="h-full bg-primary rounded-full relative"
style={{ width: `${(currentTime / duration) * 100}%` }} style={{
width: `${(currentTime / duration) * 100}%`,
}}
transition={{ ease: "linear" }} transition={{ ease: "linear" }}
> >
<motion.div <motion.div
@ -675,7 +750,10 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }} transition={{ delay: 0.2 }}
> >
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}> <motion.div
whileHover={{ scale: 1.2 }}
whileTap={{ scale: 0.95 }}
>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -690,7 +768,10 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
<SkipBack className="w-5 h-5" /> <SkipBack className="w-5 h-5" />
</Button> </Button>
</motion.div> </motion.div>
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}> <motion.div
whileHover={{ scale: 1.2 }}
whileTap={{ scale: 0.95 }}
>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -701,13 +782,17 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
className="w-10 h-10 text-primary hover:bg-primary/10 rounded-full transition-colors" className="w-10 h-10 text-primary hover:bg-primary/10 rounded-full transition-colors"
disabled={!duration} disabled={!duration}
> >
{isPlaying ? {isPlaying ? (
<Pause className="w-6 h-6" /> : <Pause className="w-6 h-6" />
) : (
<Play className="w-6 h-6 ml-0.5" /> <Play className="w-6 h-6 ml-0.5" />
} )}
</Button> </Button>
</motion.div> </motion.div>
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}> <motion.div
whileHover={{ scale: 1.2 }}
whileTap={{ scale: 0.95 }}
>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -743,7 +828,10 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setPodcastToDelete({ id: podcast.id, title: podcast.title }); setPodcastToDelete({
id: podcast.id,
title: podcast.title,
});
setDeleteDialogOpen(true); setDeleteDialogOpen(true);
}} }}
> >
@ -753,7 +841,6 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
</MotionCard> </MotionCard>
))} ))}
</motion.div> </motion.div>
@ -775,14 +862,19 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
<motion.div <motion.div
className="w-12 h-12 bg-primary/20 rounded-md flex items-center justify-center" className="w-12 h-12 bg-primary/20 rounded-md flex items-center justify-center"
animate={{ scale: isPlaying ? [1, 1.05, 1] : 1 }} animate={{ scale: isPlaying ? [1, 1.05, 1] : 1 }}
transition={{ repeat: isPlaying ? Infinity : 0, duration: 2 }} transition={{
repeat: isPlaying ? Infinity : 0,
duration: 2,
}}
> >
<Podcast className="h-6 w-6 text-primary" /> <Podcast className="h-6 w-6 text-primary" />
</motion.div> </motion.div>
</div> </div>
<div className="flex-grow min-w-0"> <div className="flex-grow min-w-0">
<h4 className="font-medium text-sm line-clamp-1">{currentPodcast.title}</h4> <h4 className="font-medium text-sm line-clamp-1">
{currentPodcast.title}
</h4>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<div className="flex-grow relative"> <div className="flex-grow relative">
@ -796,7 +888,9 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
/> />
<motion.div <motion.div
className="absolute left-0 top-1/2 h-2 bg-primary/25 rounded-full -translate-y-1/2" className="absolute left-0 top-1/2 h-2 bg-primary/25 rounded-full -translate-y-1/2"
style={{ width: `${(currentTime / (duration || 100)) * 100}%` }} style={{
width: `${(currentTime / (duration || 100)) * 100}%`,
}}
transition={{ ease: "linear" }} transition={{ ease: "linear" }}
/> />
</div> </div>
@ -807,7 +901,10 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}> <motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -818,18 +915,28 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
</Button> </Button>
</motion.div> </motion.div>
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}> <motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
<Button <Button
variant="default" variant="default"
size="icon" size="icon"
onClick={togglePlayPause} onClick={togglePlayPause}
className="h-10 w-10 rounded-full" className="h-10 w-10 rounded-full"
> >
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 ml-0.5" />} {isPlaying ? (
<Pause className="h-5 w-5" />
) : (
<Play className="h-5 w-5 ml-0.5" />
)}
</Button> </Button>
</motion.div> </motion.div>
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}> <motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -841,14 +948,21 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
</motion.div> </motion.div>
<div className="hidden md:flex items-center gap-2 ml-4 w-32"> <div className="hidden md:flex items-center gap-2 ml-4 w-32">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}> <motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={toggleMute} onClick={toggleMute}
className={`h-8 w-8 ${isMuted ? "text-muted-foreground" : "text-primary"}`} className={`h-8 w-8 ${isMuted ? "text-muted-foreground" : "text-primary"}`}
> >
{isMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />} {isMuted ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Button> </Button>
</motion.div> </motion.div>
@ -869,6 +983,20 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
/> />
</div> </div>
</div> </div>
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
<Button
variant="default"
size="icon"
onClick={closePlayer}
className="h-10 w-10 rounded-full"
>
<X />
</Button>
</motion.div>
</div> </div>
</div> </div>
</div> </div>
@ -885,7 +1013,9 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
<span>Delete Podcast</span> <span>Delete Podcast</span>
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to delete <span className="font-medium">{podcastToDelete?.title}</span>? This action cannot be undone. Are you sure you want to delete{" "}
<span className="font-medium">{podcastToDelete?.title}</span>?
This action cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end"> <DialogFooter className="flex gap-2 sm:justify-end">
@ -931,15 +1061,16 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
// Small delay to ensure browser is ready to play // Small delay to ensure browser is ready to play
setTimeout(() => { setTimeout(() => {
if (audioRef.current) { if (audioRef.current) {
audioRef.current.play() audioRef.current
.play()
.then(() => { .then(() => {
setIsPlaying(true); setIsPlaying(true);
}) })
.catch(error => { .catch((error) => {
console.error('Error playing audio:', error); console.error("Error playing audio:", error);
// Don't show error if it's just the user navigating away // Don't show error if it's just the user navigating away
if (error.name !== 'AbortError') { if (error.name !== "AbortError") {
toast.error('Failed to play audio.'); toast.error("Failed to play audio.");
} }
setIsPlaying(false); setIsPlaying(false);
}); });
@ -949,14 +1080,17 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
}} }}
onEnded={() => setIsPlaying(false)} onEnded={() => setIsPlaying(false)}
onError={(e) => { onError={(e) => {
console.error('Audio error:', e); console.error("Audio error:", e);
if (audioRef.current?.error) { if (audioRef.current?.error) {
// Log the specific error code for debugging // Log the specific error code for debugging
console.error('Audio error code:', audioRef.current.error.code); console.error("Audio error code:", audioRef.current.error.code);
// Don't show error message for aborted loads // Don't show error message for aborted loads
if (audioRef.current.error.code !== audioRef.current.error.MEDIA_ERR_ABORTED) { if (
toast.error('Error playing audio. Please try again.'); audioRef.current.error.code !==
audioRef.current.error.MEDIA_ERR_ABORTED
) {
toast.error("Error playing audio. Please try again.");
} }
} }
// Reset playing state on error // Reset playing state on error