feat: Added Podcast Feature and its actually fast.

- Fully Async
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2025-05-05 23:18:12 -07:00
parent 10d56acaa8
commit b4bee887bd
19 changed files with 1676 additions and 75 deletions

View file

@ -0,0 +1,44 @@
"""Change podcast_content to podcast_transcript with JSON type
Revision ID: 6
Revises: 5
Create Date: 2023-08-15 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSON
# revision identifiers, used by Alembic.
revision: str = '6'
down_revision: Union[str, None] = '5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Drop the old column and create a new one with the new name and type
# We need to do this because PostgreSQL doesn't support direct column renames with type changes
op.add_column('podcasts', sa.Column('podcast_transcript', JSON, nullable=False, server_default='{}'))
# Copy data from old column to new column
# Convert text to JSON by storing it as a JSON string value
op.execute("UPDATE podcasts SET podcast_transcript = jsonb_build_object('text', podcast_content) WHERE podcast_content != ''")
# Drop the old column
op.drop_column('podcasts', 'podcast_content')
def downgrade() -> None:
# Add back the original column
op.add_column('podcasts', sa.Column('podcast_content', sa.Text(), nullable=False, server_default=''))
# Copy data from JSON column back to text column
# Extract the 'text' field if it exists, otherwise use empty string
op.execute("UPDATE podcasts SET podcast_content = COALESCE((podcast_transcript->>'text'), '')")
# Drop the new column
op.drop_column('podcasts', 'podcast_transcript')

View file

@ -0,0 +1,28 @@
"""Remove is_generated column from podcasts table
Revision ID: 7
Revises: 6
Create Date: 2023-08-15 01:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7'
down_revision: Union[str, None] = '6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Drop the is_generated column
op.drop_column('podcasts', 'is_generated')
def downgrade() -> None:
# Add back the is_generated column with its original constraints
op.add_column('podcasts', sa.Column('is_generated', sa.Boolean(), nullable=False, server_default='false'))

View file

@ -6,18 +6,26 @@ from .state import State
from .nodes import create_merged_podcast_audio, create_podcast_transcript
# Define a new graph
workflow = StateGraph(State, config_schema=Configuration)
# Add the node to the graph
workflow.add_node("create_podcast_transcript", create_podcast_transcript)
workflow.add_node("create_merged_podcast_audio", create_merged_podcast_audio)
def build_graph():
# Define a new graph
workflow = StateGraph(State, config_schema=Configuration)
# Set the entrypoint as `call_model`
workflow.add_edge("__start__", "create_podcast_transcript")
workflow.add_edge("create_podcast_transcript", "create_merged_podcast_audio")
workflow.add_edge("create_merged_podcast_audio", "__end__")
# Add the node to the graph
workflow.add_node("create_podcast_transcript", create_podcast_transcript)
workflow.add_node("create_merged_podcast_audio", create_merged_podcast_audio)
# Compile the workflow into an executable graph
graph = workflow.compile()
graph.name = "Surfsense Podcaster" # This defines the custom name in LangSmith
# Set the entrypoint as `call_model`
workflow.add_edge("__start__", "create_podcast_transcript")
workflow.add_edge("create_podcast_transcript", "create_merged_podcast_audio")
workflow.add_edge("create_merged_podcast_audio", "__end__")
# Compile the workflow into an executable graph
graph = workflow.compile()
graph.name = "Surfsense Podcaster" # This defines the custom name in LangSmith
return graph
# Compile the graph once when the module is loaded
graph = build_graph()

View file

@ -28,7 +28,7 @@ async def create_podcast_transcript(state: State, config: RunnableConfig) -> Dic
# Create the messages
messages = [
SystemMessage(content=prompt),
HumanMessage(content=state.source_content)
HumanMessage(content=f"<source_content>{state.source_content}</source_content>")
]
# Generate the podcast transcript

View file

@ -106,6 +106,6 @@ Output:
}}
</examples>
Transform the source material into a lively and engaging podcast conversation. Craft dialogue that showcases authentic host chemistry and natural interaction (including occasional disagreement, building on points, or asking follow-up questions). Use varied speech patterns reflecting real human conversation, ensuring the final script effectively educates *and* entertains the listener while keeping within a 3-minute audio duration.
Transform the source material into a lively and engaging podcast conversation. Craft dialogue that showcases authentic host chemistry and natural interaction (including occasional disagreement, building on points, or asking follow-up questions). Use varied speech patterns reflecting real human conversation, ensuring the final script effectively educates *and* entertains the listener while keeping within a 5-minute audio duration.
</podcast_generation_system>
"""

View file

@ -110,8 +110,7 @@ class Podcast(BaseModel, TimestampMixin):
__tablename__ = "podcasts"
title = Column(String, nullable=False, index=True)
is_generated = Column(Boolean, nullable=False, default=False)
podcast_content = Column(Text, nullable=False, default="")
podcast_transcript = Column(JSON, nullable=False, default={})
file_location = Column(String(500), nullable=False, default="")
search_space_id = Column(Integer, ForeignKey("searchspaces.id", ondelete='CASCADE'), nullable=False)

View file

@ -1,12 +1,16 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from typing import List
from app.db import get_async_session, User, SearchSpace, Podcast
from app.schemas import PodcastCreate, PodcastUpdate, PodcastRead
from app.db import get_async_session, User, SearchSpace, Podcast, Chat
from app.schemas import PodcastCreate, PodcastUpdate, PodcastRead, PodcastGenerateRequest
from app.users import current_active_user
from app.utils.check_ownership import check_ownership
from app.tasks.podcast_tasks import generate_chat_podcast
from fastapi.responses import StreamingResponse
import os
from pathlib import Path
router = APIRouter()
@ -119,4 +123,121 @@ async def delete_podcast(
raise he
except SQLAlchemyError:
await session.rollback()
raise HTTPException(status_code=500, detail="Database error occurred while deleting podcast")
raise HTTPException(status_code=500, detail="Database error occurred while deleting podcast")
async def generate_chat_podcast_with_new_session(
chat_id: int,
search_space_id: int,
podcast_title: str = "SurfSense Podcast"
):
"""Create a new session and process chat podcast generation."""
from app.db import async_session_maker
async with async_session_maker() as session:
try:
await generate_chat_podcast(session, chat_id, search_space_id, podcast_title)
except Exception as e:
import logging
logging.error(f"Error generating podcast from chat: {str(e)}")
@router.post("/podcasts/generate/")
async def generate_podcast(
request: PodcastGenerateRequest,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
fastapi_background_tasks: BackgroundTasks = BackgroundTasks()
):
try:
# Check if the user owns the search space
await check_ownership(session, SearchSpace, request.search_space_id, user)
if request.type == "CHAT":
# Verify that all chat IDs belong to this user and search space
query = select(Chat).filter(
Chat.id.in_(request.ids),
Chat.search_space_id == request.search_space_id
).join(SearchSpace).filter(SearchSpace.user_id == user.id)
result = await session.execute(query)
valid_chats = result.scalars().all()
valid_chat_ids = [chat.id for chat in valid_chats]
# If any requested ID is not in valid IDs, raise error immediately
if len(valid_chat_ids) != len(request.ids):
raise HTTPException(
status_code=403,
detail="One or more chat IDs do not belong to this user or search space"
)
# Only add a single task with the first chat ID
for chat_id in valid_chat_ids:
fastapi_background_tasks.add_task(
generate_chat_podcast_with_new_session,
chat_id,
request.search_space_id,
request.podcast_title
)
return {
"message": "Podcast generation started",
}
except HTTPException as he:
raise he
except IntegrityError as e:
await session.rollback()
raise HTTPException(status_code=400, detail="Podcast generation failed due to constraint violation")
except SQLAlchemyError as e:
await session.rollback()
raise HTTPException(status_code=500, detail="Database error occurred while generating podcast")
except Exception as e:
await session.rollback()
raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(e)}")
@router.get("/podcasts/{podcast_id}/stream")
async def stream_podcast(
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user)
):
"""Stream a podcast audio file."""
try:
# Get the podcast and check if user has access
result = await session.execute(
select(Podcast)
.join(SearchSpace)
.filter(Podcast.id == podcast_id, SearchSpace.user_id == user.id)
)
podcast = result.scalars().first()
if not podcast:
raise HTTPException(
status_code=404,
detail="Podcast not found or you don't have permission to access it"
)
# Get the file path
file_path = podcast.file_location
# Check if the file exists
if not os.path.isfile(file_path):
raise HTTPException(status_code=404, detail="Podcast audio file not found")
# Define a generator function to stream the file
def iterfile():
with open(file_path, mode="rb") as file_like:
yield from file_like
# Return a streaming response with appropriate headers
return StreamingResponse(
iterfile(),
media_type="audio/mpeg",
headers={
"Accept-Ranges": "bytes",
"Content-Disposition": f"inline; filename={Path(file_path).name}"
}
)
except HTTPException as he:
raise he
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error streaming podcast: {str(e)}")

View file

@ -10,7 +10,7 @@ from .documents import (
DocumentRead,
)
from .chunks import ChunkBase, ChunkCreate, ChunkUpdate, ChunkRead
from .podcasts import PodcastBase, PodcastCreate, PodcastUpdate, PodcastRead
from .podcasts import PodcastBase, PodcastCreate, PodcastUpdate, PodcastRead, PodcastGenerateRequest
from .chats import ChatBase, ChatCreate, ChatUpdate, ChatRead, AISDKChatRequest
from .search_source_connector import SearchSourceConnectorBase, SearchSourceConnectorCreate, SearchSourceConnectorUpdate, SearchSourceConnectorRead
@ -39,6 +39,7 @@ __all__ = [
"PodcastCreate",
"PodcastUpdate",
"PodcastRead",
"PodcastGenerateRequest",
"ChatBase",
"ChatCreate",
"ChatUpdate",

View file

@ -1,8 +1,10 @@
from typing import Any, Dict, List, Optional
from pydantic import BaseModel
from sqlalchemy import JSON
from .base import IDModel, TimestampModel
from app.db import ChatType
from pydantic import BaseModel
from .base import IDModel, TimestampModel
class ChatBase(BaseModel):
type: ChatType

View file

@ -1,10 +1,10 @@
from pydantic import BaseModel
from typing import Any, List, Literal
from .base import IDModel, TimestampModel
class PodcastBase(BaseModel):
title: str
is_generated: bool = False
podcast_content: str = ""
podcast_transcript: List[Any]
file_location: str = ""
search_space_id: int
@ -16,4 +16,10 @@ class PodcastUpdate(PodcastBase):
class PodcastRead(PodcastBase, IDModel, TimestampModel):
class Config:
from_attributes = True
from_attributes = True
class PodcastGenerateRequest(BaseModel):
type: Literal["DOCUMENT", "CHAT"]
ids: List[int]
search_space_id: int
podcast_title: str = "SurfSense Podcast"

View file

@ -0,0 +1,94 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas import PodcastGenerateRequest
from typing import List
from sqlalchemy import select
from app.db import Chat, Podcast
from app.agents.podcaster.graph import graph as podcaster_graph
from surfsense_backend.app.agents.podcaster.state import State
async def generate_document_podcast(
session: AsyncSession,
document_id: int,
search_space_id: int,
user_id: int
):
# TODO: Need to fetch the document chunks, then concatenate them and pass them to the podcast generation model
pass
async def generate_chat_podcast(
session: AsyncSession,
chat_id: int,
search_space_id: int,
podcast_title: str
):
# Fetch the chat with the specified ID
query = select(Chat).filter(
Chat.id == chat_id,
Chat.search_space_id == search_space_id
)
result = await session.execute(query)
chat = result.scalars().first()
if not chat:
raise ValueError(f"Chat with id {chat_id} not found in search space {search_space_id}")
# Create chat history structure
chat_history_str = "<chat_history>"
for message in chat.messages:
if message["role"] == "user":
chat_history_str += f"<user_message>{message['content']}</user_message>"
elif message["role"] == "assistant":
# Last annotation type will always be "ANSWER" here
answer_annotation = message["annotations"][-1]
answer_text = ""
if answer_annotation["type"] == "ANSWER":
answer_text = answer_annotation["content"]
# If content is a list, join it into a single string
if isinstance(answer_text, list):
answer_text = "\n".join(answer_text)
chat_history_str += f"<assistant_message>{answer_text}</assistant_message>"
chat_history_str += "</chat_history>"
# Pass it to the SurfSense Podcaster
config = {
"configurable": {
"podcast_title" : "Surfsense",
}
}
# Initialize state with database session and streaming service
initial_state = State(
source_content=chat_history_str,
)
# Run the graph directly
result = await podcaster_graph.ainvoke(initial_state, config=config)
# Convert podcast transcript entries to serializable format
serializable_transcript = []
for entry in result["podcast_transcript"]:
serializable_transcript.append({
"speaker_id": entry.speaker_id,
"dialog": entry.dialog
})
# Create a new podcast entry
podcast = Podcast(
title=f"{podcast_title}",
podcast_transcript=serializable_transcript,
file_location=result["final_podcast_file_path"],
search_space_id=search_space_id
)
# Add to session and commit
session.add(podcast)
await session.commit()
await session.refresh(podcast)
return podcast

View file

@ -3,7 +3,7 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useSearchParams } from 'next/navigation';
import { MessageCircleMore, Search, Calendar, Tag, Trash2, ExternalLink, MoreHorizontal } from 'lucide-react';
import { MessageCircleMore, Search, Calendar, Tag, Trash2, ExternalLink, MoreHorizontal, Radio, CheckCircle, Circle, Podcast } from 'lucide-react';
import { format } from 'date-fns';
// UI Components
@ -42,6 +42,9 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
interface Chat {
created_at: string;
@ -92,6 +95,18 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
const [chatToDelete, setChatToDelete] = useState<{ id: number, title: string } | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// New state for podcast generation
const [selectedChats, setSelectedChats] = useState<number[]>([]);
const [selectionMode, setSelectionMode] = useState(false);
const [podcastDialogOpen, setPodcastDialogOpen] = useState(false);
const [podcastTitle, setPodcastTitle] = useState("");
const [isGeneratingPodcast, setIsGeneratingPodcast] = useState(false);
// New state for individual podcast generation
const [currentChatIndex, setCurrentChatIndex] = useState(0);
const [podcastTitles, setPodcastTitles] = useState<{[key: number]: string}>({});
const [processingChat, setProcessingChat] = useState<Chat | null>(null);
const chatsPerPage = 9;
const searchParams = useSearchParams();
@ -234,6 +249,177 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
// Get unique chat types for filter dropdown
const chatTypes = ['all', ...Array.from(new Set(chats.map(chat => chat.type)))];
// Generate individual podcasts from selected chats
const handleGeneratePodcast = async () => {
if (selectedChats.length === 0) {
toast.error("Please select at least one chat");
return;
}
const currentChatId = selectedChats[currentChatIndex];
const currentTitle = podcastTitles[currentChatId] || podcastTitle;
if (!currentTitle.trim()) {
toast.error("Please enter a podcast title");
return;
}
setIsGeneratingPodcast(true);
try {
const token = localStorage.getItem('surfsense_bearer_token');
if (!token) {
toast.error("Authentication error. Please log in again.");
setIsGeneratingPodcast(false);
return;
}
// Create payload for single chat
const payload = {
type: "CHAT",
ids: [currentChatId], // Single chat ID
search_space_id: parseInt(searchSpaceId),
podcast_title: currentTitle
};
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/generate/`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || "Failed to generate podcast");
}
const data = await response.json();
toast.success(`Podcast "${currentTitle}" generation started!`);
// Move to the next chat or finish
if (currentChatIndex < selectedChats.length - 1) {
// Set up for next chat
setCurrentChatIndex(currentChatIndex + 1);
// Find the next chat from the chats array
const nextChatId = selectedChats[currentChatIndex + 1];
const nextChat = chats.find(chat => chat.id === nextChatId) || null;
setProcessingChat(nextChat);
// Default title for the next chat
if (!podcastTitles[nextChatId]) {
setPodcastTitle(nextChat?.title || `Podcast from Chat ${nextChatId}`);
} else {
setPodcastTitle(podcastTitles[nextChatId]);
}
setIsGeneratingPodcast(false);
} else {
// All done
finishPodcastGeneration();
}
} catch (error) {
console.error('Error generating podcast:', error);
toast.error(error instanceof Error ? error.message : 'Failed to generate podcast');
setIsGeneratingPodcast(false);
}
};
// Helper to finish the podcast generation process
const finishPodcastGeneration = () => {
toast.success("All podcasts are being generated! Check the podcasts tab to see them when ready.");
setPodcastDialogOpen(false);
setSelectedChats([]);
setSelectionMode(false);
setCurrentChatIndex(0);
setPodcastTitles({});
setProcessingChat(null);
setPodcastTitle("");
setIsGeneratingPodcast(false);
};
// Start podcast generation flow
const startPodcastGeneration = () => {
if (selectedChats.length === 0) {
toast.error("Please select at least one chat");
return;
}
// Reset the state for podcast generation
setCurrentChatIndex(0);
setPodcastTitles({});
// Set up for the first chat
const firstChatId = selectedChats[0];
const firstChat = chats.find(chat => chat.id === firstChatId) || null;
setProcessingChat(firstChat);
// Set default title for the first chat
setPodcastTitle(firstChat?.title || `Podcast from Chat ${firstChatId}`);
setPodcastDialogOpen(true);
};
// Update the title for the current chat
const updateCurrentChatTitle = (title: string) => {
const currentChatId = selectedChats[currentChatIndex];
setPodcastTitle(title);
setPodcastTitles(prev => ({
...prev,
[currentChatId]: title
}));
};
// Skip generating a podcast for the current chat
const skipCurrentChat = () => {
if (currentChatIndex < selectedChats.length - 1) {
// Move to the next chat
setCurrentChatIndex(currentChatIndex + 1);
// Find the next chat
const nextChatId = selectedChats[currentChatIndex + 1];
const nextChat = chats.find(chat => chat.id === nextChatId) || null;
setProcessingChat(nextChat);
// Set default title for the next chat
if (!podcastTitles[nextChatId]) {
setPodcastTitle(nextChat?.title || `Podcast from Chat ${nextChatId}`);
} else {
setPodcastTitle(podcastTitles[nextChatId]);
}
} else {
// All done (all skipped)
finishPodcastGeneration();
}
};
// Toggle chat selection
const toggleChatSelection = (chatId: number) => {
setSelectedChats(prev =>
prev.includes(chatId)
? prev.filter(id => id !== chatId)
: [...prev, chatId]
);
};
// Select all visible chats
const selectAllVisibleChats = () => {
const visibleChatIds = currentChats.map(chat => chat.id);
setSelectedChats(prev => {
const allSelected = visibleChatIds.every(id => prev.includes(id));
return allSelected
? prev.filter(id => !visibleChatIds.includes(id)) // Deselect all visible if all are selected
: [...new Set([...prev, ...visibleChatIds])]; // Add all visible, ensuring no duplicates
});
};
// Cancel selection mode
const cancelSelectionMode = () => {
setSelectionMode(false);
setSelectedChats([]);
};
return (
<motion.div
className="container p-6 mx-auto"
@ -278,18 +464,62 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
</Select>
</div>
<div>
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Sort order" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="newest">Newest First</SelectItem>
<SelectItem value="oldest">Oldest First</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div className="flex items-center gap-2">
{selectionMode ? (
<>
<Button
variant="outline"
size="sm"
onClick={selectAllVisibleChats}
className="gap-1"
>
<CheckCircle className="h-4 w-4" />
{currentChats.every(chat => selectedChats.includes(chat.id))
? "Deselect All"
: "Select All"}
</Button>
<Button
variant="default"
size="sm"
onClick={startPodcastGeneration}
className="gap-1"
disabled={selectedChats.length === 0}
>
<Podcast className="h-4 w-4" />
Generate Podcast ({selectedChats.length})
</Button>
<Button
variant="ghost"
size="sm"
onClick={cancelSelectionMode}
>
Cancel
</Button>
</>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={() => setSelectionMode(true)}
className="gap-1"
>
<Podcast className="h-4 w-4" />
Podcaster
</Button>
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Sort order" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="newest">Newest First</SelectItem>
<SelectItem value="oldest">Oldest First</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</>
)}
</div>
</div>
@ -334,44 +564,69 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
animate="animate"
exit="exit"
transition={{ duration: 0.2, delay: index * 0.05 }}
className="overflow-hidden hover:shadow-md transition-shadow"
className={`overflow-hidden hover:shadow-md transition-shadow
${selectionMode && selectedChats.includes(chat.id)
? 'ring-2 ring-primary ring-offset-2' : ''}`}
onClick={() => selectionMode ? toggleChatSelection(chat.id) : null}
>
<CardHeader className="pb-3">
<div className="flex justify-between items-start">
<div className="space-y-1">
<CardTitle className="line-clamp-1">{chat.title || `Chat ${chat.id}`}</CardTitle>
<CardDescription>
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
<span>{format(new Date(chat.created_at), 'MMM d, yyyy')}</span>
</span>
</CardDescription>
<div className="space-y-1 flex items-start gap-2">
{selectionMode && (
<div className="mt-1">
{selectedChats.includes(chat.id)
? <CheckCircle className="h-4 w-4 text-primary" />
: <Circle className="h-4 w-4 text-muted-foreground" />}
</div>
)}
<div>
<CardTitle className="line-clamp-1">{chat.title || `Chat ${chat.id}`}</CardTitle>
<CardDescription>
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
<span>{format(new Date(chat.created_at), 'MMM d, yyyy')}</span>
</span>
</CardDescription>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => window.location.href = `/dashboard/${chat.search_space_id}/researcher/${chat.id}`}>
<ExternalLink className="mr-2 h-4 w-4" />
<span>View Chat</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => {
setChatToDelete({ id: chat.id, title: chat.title || `Chat ${chat.id}` });
setDeleteDialogOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete Chat</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{!selectionMode && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => window.location.href = `/dashboard/${chat.search_space_id}/researcher/${chat.id}`}>
<ExternalLink className="mr-2 h-4 w-4" />
<span>View Chat</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedChats([chat.id]);
setPodcastTitle(chat.title || `Chat ${chat.id}`);
setPodcastDialogOpen(true);
}}
>
<Podcast className="mr-2 h-4 w-4" />
<span>Generate Podcast</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
setChatToDelete({ id: chat.id, title: chat.title || `Chat ${chat.id}` });
setDeleteDialogOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete Chat</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</CardHeader>
<CardContent>
@ -505,6 +760,104 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
</DialogFooter>
</DialogContent>
</Dialog>
{/* Podcast Generation Dialog */}
<Dialog
open={podcastDialogOpen}
onOpenChange={(isOpen: boolean) => {
if (!isOpen) {
// Cancel the process if dialog is closed
setPodcastDialogOpen(false);
setSelectedChats([]);
setSelectionMode(false);
setCurrentChatIndex(0);
setPodcastTitles({});
setProcessingChat(null);
setPodcastTitle("");
} else {
setPodcastDialogOpen(true);
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Podcast className="h-5 w-5 text-primary" />
<span>Generate Podcast {currentChatIndex + 1} of {selectedChats.length}</span>
</DialogTitle>
<DialogDescription>
{selectedChats.length > 1 ? (
<>Creating individual podcasts for each selected chat. Currently processing: <span className="font-medium">{processingChat?.title || `Chat ${selectedChats[currentChatIndex]}`}</span></>
) : (
<>Create a podcast from this chat. The podcast will be available in the podcasts section once generated.</>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="podcast-title">Podcast Title</Label>
<Input
id="podcast-title"
placeholder="Enter podcast title"
value={podcastTitle}
onChange={(e) => updateCurrentChatTitle(e.target.value)}
/>
</div>
{selectedChats.length > 1 && (
<div className="w-full bg-muted rounded-full h-2.5 mt-4">
<div
className="bg-primary h-2.5 rounded-full transition-all duration-300"
style={{ width: `${((currentChatIndex) / selectedChats.length) * 100}%` }}
></div>
</div>
)}
</div>
<DialogFooter className="flex gap-2 sm:justify-end">
{selectedChats.length > 1 && !isGeneratingPodcast && (
<Button
variant="outline"
onClick={skipCurrentChat}
className="gap-1"
>
Skip
</Button>
)}
<Button
variant="outline"
onClick={() => {
setPodcastDialogOpen(false);
setCurrentChatIndex(0);
setPodcastTitles({});
setProcessingChat(null);
}}
disabled={isGeneratingPodcast}
>
Cancel
</Button>
<Button
variant="default"
onClick={handleGeneratePodcast}
disabled={isGeneratingPodcast}
className="gap-2"
>
{isGeneratingPodcast ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Generating...
</>
) : (
<>
<Podcast className="h-4 w-4" />
Generate Podcast
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</motion.div>
);
}

View file

@ -73,6 +73,13 @@ export default function DashboardLayout({
},
],
},
{
title: "Podcasts",
url: `/dashboard/${search_space_id}/podcasts`,
icon: "Podcast",
items: [
],
}
// TODO: Add research synthesizer's
// {
// title: "Research Synthesizer's",

View file

@ -0,0 +1,22 @@
import { Suspense } from 'react';
import PodcastsPageClient from './podcasts-client';
interface PageProps {
params: {
search_space_id: string;
};
}
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 (
<Suspense fallback={<div className="flex items-center justify-center h-[60vh]">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
</div>}>
<PodcastsPageClient searchSpaceId={searchSpaceId} />
</Suspense>
);
}

View file

@ -0,0 +1,787 @@
'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
} from 'lucide-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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
interface Podcast {
id: number;
title: string;
created_at: string;
file_location: string;
podcast_transcript: any[];
search_space_id: number;
}
interface PodcastsPageClientProps {
searchSpaceId: string;
}
const pageVariants = {
initial: { opacity: 0 },
enter: { opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
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 }
};
const MotionCard = motion(Card);
export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClientProps) {
const [podcasts, setPodcasts] = useState<Podcast[]>([]);
const [filteredPodcasts, setFilteredPodcasts] = useState<Podcast[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [sortOrder, setSortOrder] = useState<string>('newest');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [podcastToDelete, setPodcastToDelete] = useState<{ id: number, title: string } | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Audio player state
const [currentPodcast, setCurrentPodcast] = useState<Podcast | null>(null);
const [audioSrc, setAudioSrc] = useState<string | undefined>(undefined);
const [isAudioLoading, setIsAudioLoading] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(0.7);
const [isMuted, setIsMuted] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const currentObjectUrlRef = useRef<string | null>(null);
// 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";
// Fetch podcasts from API
useEffect(() => {
const fetchPodcasts = async () => {
try {
setIsLoading(true);
// Get token from localStorage
const token = localStorage.getItem('surfsense_bearer_token');
if (!token) {
setError('Authentication token not found. Please log in again.');
setIsLoading(false);
return;
}
// Fetch all podcasts for this search space
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/`,
{
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
cache: 'no-store',
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(`Failed to fetch podcasts: ${response.status} ${errorData?.detail || ''}`);
}
const data: Podcast[] = await response.json();
setPodcasts(data);
setFilteredPodcasts(data);
setError(null);
} catch (error) {
console.error('Error fetching podcasts:', error);
setError(error instanceof Error ? error.message : 'Unknown error occurred');
setPodcasts([]);
setFilteredPodcasts([]);
} finally {
setIsLoading(false);
}
};
fetchPodcasts();
}, [searchSpaceId]);
// Filter and sort podcasts based on search query and sort order
useEffect(() => {
let result = [...podcasts];
// Filter by search term
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter(podcast =>
podcast.title.toLowerCase().includes(query)
);
}
// Filter by search space
result = result.filter(podcast =>
podcast.search_space_id === parseInt(searchSpaceId)
);
// Sort podcasts
result.sort((a, b) => {
const dateA = new Date(a.created_at).getTime();
const dateB = new Date(b.created_at).getTime();
return sortOrder === 'newest' ? dateB - dateA : dateA - dateB;
});
setFilteredPodcasts(result);
}, [podcasts, searchQuery, sortOrder, searchSpaceId]);
// Cleanup object URL on unmount or when currentPodcast changes
useEffect(() => {
return () => {
if (currentObjectUrlRef.current) {
URL.revokeObjectURL(currentObjectUrlRef.current);
currentObjectUrlRef.current = null;
}
};
}, []);
// Audio player time update handler
const handleTimeUpdate = () => {
if (audioRef.current) {
setCurrentTime(audioRef.current.currentTime);
}
};
// Audio player metadata loaded handler
const handleMetadataLoaded = () => {
if (audioRef.current) {
setDuration(audioRef.current.duration);
}
};
// Play/pause toggle
const togglePlayPause = () => {
if (audioRef.current) {
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
// Seek to position
const handleSeek = (value: number[]) => {
if (audioRef.current) {
audioRef.current.currentTime = value[0];
setCurrentTime(value[0]);
}
};
// Volume change
const handleVolumeChange = (value: number[]) => {
if (audioRef.current) {
const newVolume = value[0];
audioRef.current.volume = newVolume;
setVolume(newVolume);
if (newVolume === 0) {
setIsMuted(true);
} else if (isMuted) {
setIsMuted(false);
}
}
};
// Toggle mute
const toggleMute = () => {
if (audioRef.current) {
audioRef.current.muted = !isMuted;
setIsMuted(!isMuted);
}
};
// Skip forward 10 seconds
const skipForward = () => {
if (audioRef.current) {
audioRef.current.currentTime = Math.min(audioRef.current.duration, audioRef.current.currentTime + 10);
}
};
// Skip backward 10 seconds
const skipBackward = () => {
if (audioRef.current) {
audioRef.current.currentTime = Math.max(0, audioRef.current.currentTime - 10);
}
};
// Format time in MM:SS
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
};
// Play podcast - Fetch blob and set object URL
const playPodcast = async (podcast: Podcast) => {
// If the same podcast is selected, just toggle play/pause
if (currentPodcast && currentPodcast.id === podcast.id) {
togglePlayPause();
return;
}
// Revoke previous object URL if exists
if (currentObjectUrlRef.current) {
URL.revokeObjectURL(currentObjectUrlRef.current);
currentObjectUrlRef.current = null;
}
// Reset player state and show loading
setCurrentPodcast(podcast);
setAudioSrc(undefined);
setCurrentTime(0);
setDuration(0);
setIsPlaying(false);
setIsAudioLoading(true);
try {
const token = localStorage.getItem('surfsense_bearer_token');
if (!token) {
toast.error('Authentication token not found.');
setIsAudioLoading(false);
return;
}
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}`);
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
currentObjectUrlRef.current = objectUrl;
setAudioSrc(objectUrl);
// Let the audio element load the new src
setTimeout(() => {
if (audioRef.current) {
audioRef.current.load();
audioRef.current.play()
.then(() => {
setIsPlaying(true);
})
.catch(error => {
console.error('Error playing audio:', error);
toast.error('Failed to play audio.');
setIsPlaying(false);
});
}
}, 50);
} catch (error) {
console.error('Error fetching or playing podcast:', error);
toast.error(error instanceof Error ? error.message : 'Failed to load podcast audio.');
setCurrentPodcast(null);
} finally {
setIsAudioLoading(false);
}
};
// Function to handle podcast deletion
const handleDeletePodcast = async () => {
if (!podcastToDelete) return;
setIsDeleting(true);
try {
const token = localStorage.getItem('surfsense_bearer_token');
if (!token) {
setIsDeleting(false);
return;
}
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastToDelete.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
}
});
if (!response.ok) {
throw new Error(`Failed to delete podcast: ${response.statusText}`);
}
// Close dialog and refresh podcasts
setDeleteDialogOpen(false);
setPodcastToDelete(null);
// Update local state by removing the deleted podcast
setPodcasts(prevPodcasts => prevPodcasts.filter(podcast => podcast.id !== podcastToDelete.id));
// If the current playing podcast is deleted, stop playback
if (currentPodcast && currentPodcast.id === podcastToDelete.id) {
if (audioRef.current) {
audioRef.current.pause();
}
setCurrentPodcast(null);
setIsPlaying(false);
}
toast.success('Podcast deleted successfully');
} catch (error) {
console.error('Error deleting podcast:', error);
toast.error(error instanceof Error ? error.message : 'Failed to delete podcast');
} finally {
setIsDeleting(false);
}
};
return (
<motion.div
className="container p-6 mx-auto"
initial="initial"
animate="enter"
exit="exit"
variants={pageVariants}
>
<div className="flex flex-col space-y-4 md:space-y-6">
<div className="flex flex-col space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Podcasts</h1>
<p className="text-muted-foreground">Listen to generated podcasts.</p>
</div>
{/* Filter and Search Bar */}
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:justify-between md:space-y-0">
<div className="flex flex-1 items-center gap-2">
<div className="relative w-full md:w-80">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search podcasts..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div>
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Sort order" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="newest">Newest First</SelectItem>
<SelectItem value="oldest">Oldest First</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
{/* Status Messages */}
{isLoading && (
<div className="flex items-center justify-center h-40">
<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>
<p className="text-sm text-muted-foreground">Loading podcasts...</p>
</div>
</div>
)}
{error && !isLoading && (
<div className="border border-destructive/50 text-destructive p-4 rounded-md">
<h3 className="font-medium">Error loading podcasts</h3>
<p className="text-sm">{error}</p>
</div>
)}
{!isLoading && !error && filteredPodcasts.length === 0 && (
<div className="flex flex-col items-center justify-center h-40 gap-2 text-center">
<Podcast className="h-8 w-8 text-muted-foreground" />
<h3 className="font-medium">No podcasts found</h3>
<p className="text-sm text-muted-foreground">
{searchQuery
? 'Try adjusting your search filters'
: 'Generate podcasts from your chats to get started'}
</p>
</div>
)}
{/* 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">
{filteredPodcasts.map((podcast, index) => (
<MotionCard
key={podcast.id}
variants={podcastCardVariants}
initial="initial"
animate="animate"
exit="exit"
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
${currentPodcast?.id === podcast.id ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : ''}
`}
layout
>
<div
className="relative w-full aspect-[16/10] mb-4 rounded-lg overflow-hidden group cursor-pointer"
onClick={() => playPodcast(podcast)}
>
{/* Podcast image */}
<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]"
loading="lazy"
/>
{/* Overlay for better contrast with controls */}
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/30 transition-colors"></div>
{/* Loading indicator */}
{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>
)}
{/* Play button */}
{!(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}
>
<Play className="h-7 w-7 ml-1" />
</Button>
)}
{/* Pause button */}
{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}
>
<Pause className="h-7 w-7" />
</Button>
)}
</div>
<div className="mb-3 px-1">
<h3 className="text-base font-semibold text-foreground truncate" title={podcast.title}>
{podcast.title || 'Untitled Podcast'}
</h3>
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1.5">
<Calendar className="h-3 w-3" />
{format(new Date(podcast.created_at), 'MMM d, yyyy')}
</p>
</div>
{currentPodcast?.id === podcast.id && !isAudioLoading && (
<div className="mb-3 px-1">
<div
className="h-1.5 bg-muted rounded-full cursor-pointer group relative"
onClick={(e) => {
if (!audioRef.current || !duration) return;
const container = e.currentTarget;
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width));
const newTime = percentage * duration;
handleSeek([newTime]);
}}
>
<div
className="h-full bg-primary rounded-full relative transition-all duration-75 ease-linear"
style={{ width: `${(currentTime / duration) * 100}%` }}
>
<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"
/>
</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>
)}
{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>
)}
<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>
</div>
</MotionCard>
))}
</div>
</AnimatePresence>
)}
{/* Current Podcast Player (Fixed at bottom) */}
{currentPodcast && !isAudioLoading && audioSrc && (
<motion.div
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"
>
<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">
<Podcast className="h-6 w-6 text-primary" />
</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">
<Slider
value={[currentTime]}
min={0}
max={duration || 100}
step={0.1}
onValueChange={handleSeek}
/>
</div>
<div className="flex-shrink-0 text-xs text-muted-foreground whitespace-nowrap">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
</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">
<Button
variant="ghost"
size="icon"
onClick={toggleMute}
className="h-8 w-8"
>
{isMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</Button>
<Slider
value={[isMuted ? 0 : volume]}
min={0}
max={1}
step={0.01}
onValueChange={handleVolumeChange}
className="w-20"
/>
</div>
</div>
</div>
</div>
</motion.div>
)}
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
<span>Delete Podcast</span>
</DialogTitle>
<DialogDescription>
Are you sure you want to delete <span className="font-medium">{podcastToDelete?.title}</span>? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeletePodcast}
disabled={isDeleting}
className="gap-2"
>
{isDeleting ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Deleting...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Delete
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Hidden audio element for playback */}
<audio
ref={audioRef}
src={audioSrc}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleMetadataLoaded}
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.');
}
}}
/>
</motion.div>
);
}

View file

@ -14,6 +14,7 @@ import {
Info,
ExternalLink,
Trash2,
Podcast,
type LucideIcon,
} from "lucide-react"
@ -45,7 +46,8 @@ export const iconMap: Record<string, LucideIcon> = {
AlertCircle,
Info,
ExternalLink,
Trash2
Trash2,
Podcast
}
const defaultData = {

View file

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View file

@ -28,6 +28,7 @@
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.3.4",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",

View file

@ -47,6 +47,9 @@ importers:
'@radix-ui/react-separator':
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-slider':
specifier: ^1.3.4
version: 1.3.4(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-slot':
specifier: ^1.1.2
version: 1.1.2(@types/react@19.0.10)(react@19.0.0)
@ -960,6 +963,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-collection@1.1.6':
resolution: {integrity: sha512-PbhRFK4lIEw9ADonj48tiYWzkllz81TM7KVYyyMMw2cwHO7D5h4XKEblL8NlaRisTK3QTe6tBEhDccFUryxHBQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-compose-refs@1.0.0':
resolution: {integrity: sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==}
peerDependencies:
@ -1480,6 +1496,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.2':
resolution: {integrity: sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.2':
resolution: {integrity: sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==}
peerDependencies:
@ -1545,6 +1574,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-slider@1.3.4':
resolution: {integrity: sha512-Cp6hEmQtRJFci285vkdIJ+HCDLTRDk+25VhFwa1fcubywjMUE3PynBgtN5RLudOgSCYMlT4jizCXdmV+8J7Y2w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.0.0':
resolution: {integrity: sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==}
peerDependencies:
@ -1577,6 +1619,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-slot@1.2.2':
resolution: {integrity: sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-tabs@1.1.3':
resolution: {integrity: sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==}
peerDependencies:
@ -5094,6 +5145,18 @@ snapshots:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-collection@1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-primitive': 2.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-slot': 1.2.2(@types/react@19.0.10)(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-compose-refs@1.0.0(react@19.0.0)':
dependencies:
'@babel/runtime': 7.26.9
@ -5654,6 +5717,15 @@ snapshots:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-primitive@2.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/react-slot': 1.2.2(@types/react@19.0.10)(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-roving-focus@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
@ -5743,6 +5815,25 @@ snapshots:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-slider@1.3.4(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.2
'@radix-ui/react-collection': 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-primitive': 2.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.0.10)(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-slot@1.0.0(react@19.0.0)':
dependencies:
'@babel/runtime': 7.26.9
@ -5771,6 +5862,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.10
'@radix-ui/react-slot@1.2.2(@types/react@19.0.10)(react@19.0.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0)
react: 19.0.0
optionalDependencies:
'@types/react': 19.0.10
'@radix-ui/react-tabs@1.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.1