mirror of
https://github.com/MODSetter/SurfSense.git
synced 2025-09-01 10:09:08 +00:00
commit
cae5f835af
3 changed files with 400 additions and 223 deletions
|
@ -8,8 +8,8 @@ interface PageProps {
|
|||
}
|
||||
|
||||
export default async function ChatsPage({ params }: PageProps) {
|
||||
// Await params to properly access dynamic route parameters
|
||||
const searchSpaceId = params.search_space_id;
|
||||
// Get search space ID from the route parameter
|
||||
const { search_space_id: searchSpaceId } = params;
|
||||
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center h-[60vh]">
|
||||
|
|
|
@ -8,8 +8,6 @@ interface PageProps {
|
|||
}
|
||||
|
||||
export default async function PodcastsPage({ params }: PageProps) {
|
||||
// Access dynamic route parameters
|
||||
// Need to await params before accessing its properties in an async component
|
||||
const { search_space_id: searchSpaceId } = await Promise.resolve(params);
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,25 +1,24 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
Search, Calendar, Trash2, MoreHorizontal, Podcast,
|
||||
Play, Pause, SkipForward, SkipBack, Volume2, VolumeX
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
Calendar,
|
||||
MoreHorizontal,
|
||||
Pause,
|
||||
Play,
|
||||
Podcast,
|
||||
Search,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Trash2,
|
||||
Volume2, VolumeX
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
// UI Components
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
@ -28,6 +27,13 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
@ -36,6 +42,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface PodcastItem {
|
||||
|
@ -53,14 +60,15 @@ interface PodcastsPageClientProps {
|
|||
|
||||
const pageVariants = {
|
||||
initial: { opacity: 0 },
|
||||
enter: { opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
|
||||
enter: { opacity: 1, transition: { duration: 0.4, ease: 'easeInOut', staggerChildren: 0.1 } },
|
||||
exit: { opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } }
|
||||
};
|
||||
|
||||
const podcastCardVariants = {
|
||||
initial: { y: 20, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { 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 } },
|
||||
exit: { scale: 0.95, y: -20, opacity: 0 },
|
||||
hover: { y: -5, scale: 1.02, transition: { duration: 0.2 } }
|
||||
};
|
||||
|
||||
const MotionCard = motion(Card);
|
||||
|
@ -216,12 +224,17 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
|
|||
const handleVolumeChange = (value: number[]) => {
|
||||
if (audioRef.current) {
|
||||
const newVolume = value[0];
|
||||
|
||||
// Set volume
|
||||
audioRef.current.volume = newVolume;
|
||||
setVolume(newVolume);
|
||||
|
||||
// Handle mute state based on volume
|
||||
if (newVolume === 0) {
|
||||
audioRef.current.muted = true;
|
||||
setIsMuted(true);
|
||||
} else if (isMuted) {
|
||||
} else {
|
||||
audioRef.current.muted = false;
|
||||
setIsMuted(false);
|
||||
}
|
||||
}
|
||||
|
@ -230,8 +243,16 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
|
|||
// Toggle mute
|
||||
const toggleMute = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.muted = !isMuted;
|
||||
setIsMuted(!isMuted);
|
||||
const newMutedState = !isMuted;
|
||||
audioRef.current.muted = newMutedState;
|
||||
setIsMuted(newMutedState);
|
||||
|
||||
// If unmuting, restore previous volume if it was 0
|
||||
if (!newMutedState && volume === 0) {
|
||||
const restoredVolume = 0.5;
|
||||
audioRef.current.volume = restoredVolume;
|
||||
setVolume(restoredVolume);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -264,68 +285,73 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
|
|||
return;
|
||||
}
|
||||
|
||||
// Revoke previous object URL if exists
|
||||
if (currentObjectUrlRef.current) {
|
||||
URL.revokeObjectURL(currentObjectUrlRef.current);
|
||||
currentObjectUrlRef.current = null;
|
||||
// Prevent multiple simultaneous loading requests
|
||||
if (isAudioLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset player state and show loading
|
||||
setCurrentPodcast(podcast);
|
||||
setAudioSrc(undefined);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setIsPlaying(false);
|
||||
setIsAudioLoading(true);
|
||||
|
||||
try {
|
||||
// Reset player state and show loading
|
||||
setCurrentPodcast(podcast);
|
||||
setAudioSrc(undefined);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setIsPlaying(false);
|
||||
setIsAudioLoading(true);
|
||||
|
||||
const token = localStorage.getItem('surfsense_bearer_token');
|
||||
if (!token) {
|
||||
toast.error('Authentication token not found.');
|
||||
setIsAudioLoading(false);
|
||||
return;
|
||||
throw new Error('Authentication token not found.');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch audio stream: ${response.statusText}`);
|
||||
// Revoke previous object URL if exists (only after we've started the new request)
|
||||
if (currentObjectUrlRef.current) {
|
||||
URL.revokeObjectURL(currentObjectUrlRef.current);
|
||||
currentObjectUrlRef.current = null;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
currentObjectUrlRef.current = objectUrl;
|
||||
|
||||
// Wait for React to commit the new `src`
|
||||
setAudioSrc(objectUrl);
|
||||
|
||||
// Use requestAnimationFrame instead of setTimeout for more reliable DOM updates
|
||||
requestAnimationFrame(() => {
|
||||
if (audioRef.current) {
|
||||
// The <audio> element has the new src now
|
||||
audioRef.current.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error playing audio:', error);
|
||||
toast.error('Failed to play audio.');
|
||||
setIsPlaying(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
// Use AbortController to handle timeout or cancellation
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
||||
|
||||
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;
|
||||
|
||||
// Set audio source
|
||||
setAudioSrc(objectUrl);
|
||||
|
||||
// Wait for the audio to be ready before playing
|
||||
// We'll handle actual playback in the onLoadedData event instead of here
|
||||
} 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 or playing podcast:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to load podcast audio.');
|
||||
// Reset state on error
|
||||
setCurrentPodcast(null);
|
||||
setAudioSrc(undefined);
|
||||
} finally {
|
||||
setIsAudioLoading(false);
|
||||
}
|
||||
|
@ -456,7 +482,13 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
|
|||
{/* Podcast Grid */}
|
||||
{!isLoading && !error && filteredPodcasts.length > 0 && (
|
||||
<AnimatePresence mode="wait">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<motion.div
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="enter"
|
||||
exit="exit"
|
||||
>
|
||||
{filteredPodcasts.map((podcast, index) => (
|
||||
<MotionCard
|
||||
key={podcast.id}
|
||||
|
@ -464,71 +496,125 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
|
|||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
whileHover="hover"
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
className={`
|
||||
bg-card/60 dark:bg-card/40 backdrop-blur-lg rounded-xl p-4
|
||||
shadow-lg hover:shadow-xl transition-all duration-300
|
||||
border-border overflow-hidden
|
||||
shadow-md hover:shadow-xl transition-all duration-300
|
||||
border-border overflow-hidden cursor-pointer
|
||||
${currentPodcast?.id === podcast.id ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : ''}
|
||||
`}
|
||||
layout
|
||||
onClick={() => playPodcast(podcast)}
|
||||
>
|
||||
<div
|
||||
className="relative w-full aspect-[16/10] mb-4 rounded-lg overflow-hidden group cursor-pointer"
|
||||
onClick={() => playPodcast(podcast)}
|
||||
className="relative w-full aspect-[16/10] mb-4 rounded-lg overflow-hidden"
|
||||
>
|
||||
{/* Podcast image */}
|
||||
{/* Podcast image with gradient overlay */}
|
||||
<img
|
||||
src={PODCAST_IMAGE_URL}
|
||||
alt="Podcast illustration"
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105 brightness-[0.85] contrast-[1.1]"
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105 brightness-[0.85] contrast-[1.1]"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
{/* Overlay for better contrast with controls */}
|
||||
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/30 transition-colors"></div>
|
||||
{/* Better overlay with gradient for improved text legibility */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-black/10 transition-opacity duration-300"></div>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{/* Loading indicator with improved animation */}
|
||||
{currentPodcast?.id === podcast.id && isAudioLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50 z-10">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
<motion.div
|
||||
className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-md z-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<motion.div
|
||||
className="flex flex-col items-center gap-3"
|
||||
initial={{ scale: 0.9 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", damping: 20 }}
|
||||
>
|
||||
<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>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Play button */}
|
||||
{/* Play button with animations */}
|
||||
{!(currentPodcast?.id === podcast.id && (isPlaying || isAudioLoading)) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-14 w-14 rounded-full
|
||||
bg-background/70 hover:bg-background/90 backdrop-blur-sm scale-90 group-hover:scale-100
|
||||
transition-transform duration-200 z-0 shadow-lg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPodcast(podcast);
|
||||
}}
|
||||
disabled={isAudioLoading}
|
||||
<motion.div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<Play className="h-7 w-7 ml-1" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-16 w-16 rounded-full
|
||||
bg-background/80 hover:bg-background/95 backdrop-blur-md
|
||||
transition-all duration-200 shadow-xl border-0
|
||||
flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPodcast(podcast);
|
||||
}}
|
||||
disabled={isAudioLoading}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 10 }}
|
||||
className="text-primary w-10 h-10 flex items-center justify-center"
|
||||
>
|
||||
<Play className="h-8 w-8 ml-1" />
|
||||
</motion.div>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Pause button */}
|
||||
{/* Pause button with animations */}
|
||||
{currentPodcast?.id === podcast.id && isPlaying && !isAudioLoading && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-14 w-14 rounded-full
|
||||
bg-background/70 hover:bg-background/90 backdrop-blur-sm scale-90 group-hover:scale-100
|
||||
transition-transform duration-200 z-0 shadow-lg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePlayPause();
|
||||
}}
|
||||
disabled={isAudioLoading}
|
||||
<motion.div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<Pause className="h-7 w-7" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-16 w-16 rounded-full
|
||||
bg-background/80 hover:bg-background/95 backdrop-blur-md
|
||||
transition-all duration-200 shadow-xl border-0
|
||||
flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePlayPause();
|
||||
}}
|
||||
disabled={isAudioLoading}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 10 }}
|
||||
className="text-primary w-10 h-10 flex items-center justify-center"
|
||||
>
|
||||
<Pause className="h-8 w-8" />
|
||||
</motion.div>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Now playing indicator */}
|
||||
{currentPodcast?.id === podcast.id && !isAudioLoading && (
|
||||
<div className="absolute top-2 left-2 bg-primary text-primary-foreground text-xs px-2 py-1 rounded-full z-10 flex items-center gap-1.5">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary-foreground opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary-foreground"></span>
|
||||
</span>
|
||||
Now Playing
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
@ -543,10 +629,16 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
|
|||
</div>
|
||||
|
||||
{currentPodcast?.id === podcast.id && !isAudioLoading && (
|
||||
<div className="mb-3 px-1">
|
||||
<motion.div
|
||||
className="mb-3 px-1"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<div
|
||||
className="h-1.5 bg-muted rounded-full cursor-pointer group relative"
|
||||
className="h-1.5 bg-muted rounded-full cursor-pointer group relative overflow-hidden"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!audioRef.current || !duration) return;
|
||||
const container = e.currentTarget;
|
||||
const rect = container.getBoundingClientRect();
|
||||
|
@ -556,86 +648,115 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
|
|||
handleSeek([newTime]);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-primary rounded-full relative transition-all duration-75 ease-linear"
|
||||
<motion.div
|
||||
className="h-full bg-primary rounded-full relative"
|
||||
style={{ width: `${(currentTime / duration) * 100}%` }}
|
||||
transition={{ ease: "linear" }}
|
||||
>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3
|
||||
bg-primary rounded-full shadow-md transform scale-0 translate-x-1/2
|
||||
group-hover:scale-100 transition-transform"
|
||||
<motion.div
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3
|
||||
bg-primary rounded-full shadow-md transform scale-0
|
||||
group-hover:scale-100 transition-transform"
|
||||
whileHover={{ scale: 1.5 }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5 text-xs text-muted-foreground">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{currentPodcast?.id === podcast.id && !isAudioLoading && (
|
||||
<div className="flex items-center justify-between px-2 mt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={skipBackward}
|
||||
className="w-9 h-9 text-muted-foreground hover:text-primary transition-colors"
|
||||
title="Rewind 10 seconds"
|
||||
disabled={!duration}
|
||||
>
|
||||
<SkipBack className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={togglePlayPause}
|
||||
className="w-10 h-10 text-primary hover:bg-primary/10 rounded-full transition-colors"
|
||||
disabled={!duration}
|
||||
>
|
||||
{isPlaying ?
|
||||
<Pause className="w-6 h-6" /> :
|
||||
<Play className="w-6 h-6 ml-0.5" />
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={skipForward}
|
||||
className="w-9 h-9 text-muted-foreground hover:text-primary transition-colors"
|
||||
title="Forward 10 seconds"
|
||||
disabled={!duration}
|
||||
>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<motion.div
|
||||
className="flex items-center justify-between px-2 mt-1"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
skipBackward();
|
||||
}}
|
||||
className="w-9 h-9 text-muted-foreground hover:text-primary transition-colors"
|
||||
title="Rewind 10 seconds"
|
||||
disabled={!duration}
|
||||
>
|
||||
<SkipBack className="w-5 h-5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePlayPause();
|
||||
}}
|
||||
className="w-10 h-10 text-primary hover:bg-primary/10 rounded-full transition-colors"
|
||||
disabled={!duration}
|
||||
>
|
||||
{isPlaying ?
|
||||
<Pause className="w-6 h-6" /> :
|
||||
<Play className="w-6 h-6 ml-0.5" />
|
||||
}
|
||||
</Button>
|
||||
</motion.div>
|
||||
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
skipForward();
|
||||
}}
|
||||
className="w-9 h-9 text-muted-foreground hover:text-primary transition-colors"
|
||||
title="Forward 10 seconds"
|
||||
disabled={!duration}
|
||||
>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="absolute top-2 right-2 z-20">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 bg-background/50 hover:bg-background/80 rounded-full backdrop-blur-sm">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => {
|
||||
setPodcastToDelete({ id: podcast.id, title: podcast.title });
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>Delete Podcast</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 bg-background/50 hover:bg-background/80 rounded-full backdrop-blur-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPodcastToDelete({ id: podcast.id, title: podcast.title });
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>Delete Podcast</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
</MotionCard>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
|
@ -645,27 +766,38 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
|
|||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
className="fixed bottom-0 left-0 right-0 bg-background border-t p-4 shadow-lg z-50"
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur-sm border-t p-4 shadow-lg z-50"
|
||||
>
|
||||
<div className="container mx-auto">
|
||||
<div className="flex flex-col md:flex-row items-center gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-primary/20 rounded-md flex items-center justify-center">
|
||||
<motion.div
|
||||
className="w-12 h-12 bg-primary/20 rounded-md flex items-center justify-center"
|
||||
animate={{ scale: isPlaying ? [1, 1.05, 1] : 1 }}
|
||||
transition={{ repeat: isPlaying ? Infinity : 0, duration: 2 }}
|
||||
>
|
||||
<Podcast className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow min-w-0">
|
||||
<h4 className="font-medium text-sm line-clamp-1">{currentPodcast.title}</h4>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="flex-grow">
|
||||
<div className="flex-grow relative">
|
||||
<Slider
|
||||
value={[currentTime]}
|
||||
min={0}
|
||||
max={duration || 100}
|
||||
step={0.1}
|
||||
onValueChange={handleSeek}
|
||||
className="relative z-10"
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute left-0 top-1/2 h-2 bg-primary/25 rounded-full -translate-y-1/2"
|
||||
style={{ width: `${(currentTime / (duration || 100)) * 100}%` }}
|
||||
transition={{ ease: "linear" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-xs text-muted-foreground whitespace-nowrap">
|
||||
|
@ -675,51 +807,67 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={skipBackward}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={togglePlayPause}
|
||||
className="h-10 w-10 rounded-full"
|
||||
>
|
||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 ml-0.5" />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={skipForward}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="hidden md:flex items-center gap-2 ml-4 w-28">
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleMute}
|
||||
onClick={skipBackward}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{isMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={togglePlayPause}
|
||||
className="h-10 w-10 rounded-full"
|
||||
>
|
||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 ml-0.5" />}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={skipForward}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<div className="hidden md:flex items-center gap-2 ml-4 w-32">
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleMute}
|
||||
className={`h-8 w-8 ${isMuted ? "text-muted-foreground" : "text-primary"}`}
|
||||
>
|
||||
{isMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<Slider
|
||||
value={[isMuted ? 0 : volume]}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onValueChange={handleVolumeChange}
|
||||
className="w-20"
|
||||
/>
|
||||
<div className="relative w-24">
|
||||
<Slider
|
||||
value={[isMuted ? 0 : volume]}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onValueChange={handleVolumeChange}
|
||||
className="w-24"
|
||||
disabled={isMuted}
|
||||
/>
|
||||
<motion.div
|
||||
className={`absolute left-0 bottom-0 h-1 bg-primary/30 rounded-full ${isMuted ? "opacity-50" : ""}`}
|
||||
initial={false}
|
||||
animate={{ width: `${(isMuted ? 0 : volume) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -774,14 +922,45 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
|
|||
<audio
|
||||
ref={audioRef}
|
||||
src={audioSrc}
|
||||
preload="auto"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleMetadataLoaded}
|
||||
onLoadedData={() => {
|
||||
// Only auto-play when audio is fully loaded
|
||||
if (audioRef.current && currentPodcast && audioSrc) {
|
||||
// Small delay to ensure browser is ready to play
|
||||
setTimeout(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error playing audio:', error);
|
||||
// Don't show error if it's just the user navigating away
|
||||
if (error.name !== 'AbortError') {
|
||||
toast.error('Failed to play audio.');
|
||||
}
|
||||
setIsPlaying(false);
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
onError={(e) => {
|
||||
console.error('Audio error:', e);
|
||||
if (audioRef.current?.error?.code !== audioRef.current?.error?.MEDIA_ERR_ABORTED) {
|
||||
toast.error('Error playing audio.');
|
||||
if (audioRef.current?.error) {
|
||||
// Log the specific error code for debugging
|
||||
console.error('Audio error code:', audioRef.current.error.code);
|
||||
|
||||
// Don't show error message for aborted loads
|
||||
if (audioRef.current.error.code !== audioRef.current.error.MEDIA_ERR_ABORTED) {
|
||||
toast.error('Error playing audio. Please try again.');
|
||||
}
|
||||
}
|
||||
// Reset playing state on error
|
||||
setIsPlaying(false);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
|
|
Loading…
Add table
Reference in a new issue