mirror of
https://github.com/MODSetter/SurfSense.git
synced 2025-09-02 18:49:09 +00:00
Merge pull request #178 from ritikprajapat21/main
Fix issue #152: Added close functionality to player
This commit is contained in:
commit
d611bd6303
1 changed files with 1035 additions and 901 deletions
|
@ -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
|
||||||
|
|
Loading…
Add table
Reference in a new issue