diff --git a/app/api/upload/audio/[filename]/route.ts b/app/api/upload/audio/[filename]/route.ts index 94b357f..d46c147 100644 --- a/app/api/upload/audio/[filename]/route.ts +++ b/app/api/upload/audio/[filename]/route.ts @@ -1,11 +1,26 @@ import { NextResponse } from 'next/server'; import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; -import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; import { apiErrors } from '@/lib/api-response'; // Only allow UUID filenames with safe extensions const SAFE_FILENAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i; +// Map extensions to content types +const CONTENT_TYPE_MAP: Record = { + webm: 'audio/webm', + m4a: 'audio/mp4', + mp4: 'audio/mp4', + mp3: 'audio/mpeg', + ogg: 'audio/ogg', + wav: 'audio/wav', +}; + +function getContentType(filename: string): string { + const ext = filename.split('.').pop()?.toLowerCase() || ''; + return CONTENT_TYPE_MAP[ext] || 'audio/webm'; +} + export async function GET( _request: Request, { params }: { params: Promise<{ filename: string }> } @@ -20,29 +35,51 @@ export async function GET( const key = `voice/${filename}`; - const response = await r2Client.send( + // Get file metadata to determine content type + const headResponse = await r2Client.send( + new HeadObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: key, + }) + ); + + // Use the stored content-type or infer from filename extension + const contentType = headResponse.ContentType || getContentType(filename); + + // Get the object + const objectResponse = await r2Client.send( new GetObjectCommand({ Bucket: R2_BUCKET_NAME, Key: key, }) ); - if (!response.Body) { - return apiErrors.notFound('File'); + // Handle the body properly - AWS SDK returns a stream + const body = objectResponse.Body; + if (!body) { + return apiErrors.internalError('Empty file'); } - const contentType = response.ContentType || 'audio/webm'; - const contentLength = response.ContentLength; + // Convert stream to Uint8Array + const chunks: Uint8Array[] = []; + // @ts-expect-error - body is an iterable + for await (const chunk of body) { + chunks.push(chunk); + } + const uint8Array = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0)); + let offset = 0; + for (const chunk of chunks) { + uint8Array.set(chunk, offset); + offset += chunk.length; + } - // Stream the response body directly instead of buffering in memory - const stream = response.Body.transformToWebStream(); - - return new NextResponse(stream, { + // Create response with proper content-type + return new NextResponse(uint8Array, { status: 200, headers: { 'Content-Type': contentType, - ...(contentLength ? { 'Content-Length': String(contentLength) } : {}), 'Cache-Control': 'public, max-age=31536000, immutable', + 'Accept-Ranges': 'bytes', }, }); } catch (error: unknown) { diff --git a/app/layout.tsx b/app/layout.tsx index c6f43e4..fc696b4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,6 +7,8 @@ import "./globals.css"; const jetbrainsMono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-sans', + display: 'swap', + preload: true, }); export const metadata: Metadata = { diff --git a/components/video-card.tsx b/components/video-card.tsx index 21bed82..d4df724 100644 --- a/components/video-card.tsx +++ b/components/video-card.tsx @@ -187,6 +187,8 @@ export function VideoCard({ video, projectId }: VideoCardProps) { fill sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" className="object-cover transition-transform group-hover:scale-105" + placeholder="blur" + blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMCwsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAIAAoDASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAAUH/8QAIhAAAQMDBQADAAAAAAAAAAAAAQIDBAAFEQYSITFBE1FR/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAYEQADAQEAAAAAAAAAAAAAAAAAAQIhMf/aAAwDAQACEQMRAD8Adu3bgZt8NqM2y6sNJCQTjJ+dKz/9k=" />
diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index 457292a..739b793 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -198,6 +198,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const [showResumePrompt, setShowResumePrompt] = useState(false); const progressSaveTimerRef = useRef | null>(null); const lastSavedProgressRef = useRef(0); + + // YouTube API loading state + const [isApiLoaded, setIsApiLoaded] = useState(false); const [progressFetchKey, setProgressFetchKey] = useState(0); const [replyingTo, setReplyingTo] = useState(null); @@ -374,16 +377,30 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi fetchTags(); }, [projectId]); + // Load YouTube API immediately on component mount (async, non-blocking) useEffect(() => { - if (window.YT) return; + // Already loaded + if (isApiLoaded) return; + + // Already in progress + if (window.YT) { + setIsApiLoaded(true); + return; + } + const tag = document.createElement('script'); tag.src = 'https://www.youtube.com/iframe_api'; const firstScriptTag = document.getElementsByTagName('script')[0]; firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag); - }, []); + + window.onYouTubeIframeAPIReady = () => { + setIsApiLoaded(true); + }; + }, [isApiLoaded]); useEffect(() => { if (!activeVersion || activeVersion.providerId !== 'youtube') return; + if (!isApiLoaded) return; setIsReady(false); setCurrentTime(0); @@ -428,7 +445,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi clearTimeout(timeout); window.onYouTubeIframeAPIReady = undefined; }; - }, [activeVersionId]); + }, [activeVersionId, isApiLoaded]); // Save detected duration to DB if the version doesn't have one stored useEffect(() => { diff --git a/lib/video-providers/vimeo.ts b/lib/video-providers/vimeo.ts index f517ab7..fa4b49f 100644 --- a/lib/video-providers/vimeo.ts +++ b/lib/video-providers/vimeo.ts @@ -65,14 +65,10 @@ export const vimeoProvider: VideoProvider = { const cached = getCachedMetadata(cacheKey); if (cached) return cached; try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout - const response = await fetch( `https://vimeo.com/api/oembed.json?url=https://vimeo.com/${videoId}`, - { signal: controller.signal } + { signal: AbortSignal.timeout(5000) } ); - clearTimeout(timeoutId); if (!response.ok) { throw new Error('Failed to fetch video metadata'); diff --git a/lib/video-providers/youtube.ts b/lib/video-providers/youtube.ts index 5054f17..a02b989 100644 --- a/lib/video-providers/youtube.ts +++ b/lib/video-providers/youtube.ts @@ -64,14 +64,10 @@ export const youtubeProvider: VideoProvider = { // Using oEmbed API - no API key required // For production, you might want to use YouTube Data API for more data try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout - const response = await fetch( `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`, - { signal: controller.signal } + { signal: AbortSignal.timeout(5000) } ); - clearTimeout(timeoutId); if (!response.ok) { throw new Error('Failed to fetch video metadata');