/** * @license * SPDX-License-Identifier: Apache-2.0 */ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { Grid3X3, Play, Layers, ChevronLeft, ChevronRight, X, FolderOpen, Heart, MessageCircle, Bookmark, MoreHorizontal, Loader2 } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; import { format, parseISO } from 'date-fns'; // --- Utilities --- function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } // --- Types --- interface MediaFile { name: string; url: string; type: 'image' | 'video'; index: number; } interface Post { id: string; date: string; username: string; caption: string; media: MediaFile[]; thumbnail: string; isStory?: boolean; } // --- Components --- const StoryViewer = ({ stories, onClose }: { stories: Post[]; onClose: () => void; }) => { const [currentStoryIndex, setCurrentStoryIndex] = useState(0); const [progress, setProgress] = useState(0); const story = stories[currentStoryIndex]; useEffect(() => { setProgress(0); const duration = 5000; // 5 seconds per story const interval = 50; const step = (interval / duration) * 100; const timer = setInterval(() => { setProgress(prev => { if (prev >= 100) { if (currentStoryIndex < stories.length - 1) { setCurrentStoryIndex(prev => prev + 1); return 0; } else { onClose(); return 100; } } return prev + step; }); }, interval); return () => clearInterval(timer); }, [currentStoryIndex, stories.length, onClose]); const nextStory = () => { if (currentStoryIndex < stories.length - 1) { setCurrentStoryIndex(prev => prev + 1); } else { onClose(); } }; const prevStory = () => { if (currentStoryIndex > 0) { setCurrentStoryIndex(prev => prev - 1); } }; return (
e.stopPropagation()} > {/* Progress Bars */}
{stories.map((_, i) => (
))}
{/* Header */}
{story.username[0]}
{story.username} {format(parseISO(story.date), 'MMM d')}
{/* Media */}
{story.media[0].type === 'video' ? (
{/* Navigation Areas */}
{/* Caption */} {story.caption && (
{story.caption}
)}
); }; // --- Cache for video thumbnails to prevent redundant processing --- const thumbnailCache = new Map(); const VideoThumbnail = ({ url, className }: { url: string; className?: string }) => { const [thumbnail, setThumbnail] = useState(thumbnailCache.get(url) || null); const [isInView, setIsInView] = useState(false); const containerRef = React.useRef(null); useEffect(() => { if (thumbnail || !containerRef.current) return; const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { setIsInView(true); observer.disconnect(); } }, { rootMargin: '200px' } // Start loading before it's actually in view ); observer.observe(containerRef.current); return () => observer.disconnect(); }, [thumbnail]); useEffect(() => { if (thumbnail || !isInView) return; const video = document.createElement('video'); video.src = `${url}#t=0.1`; video.preload = 'metadata'; video.muted = true; video.playsInline = true; const captureFrame = () => { try { const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); if (ctx && video.videoWidth > 0) { ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const dataUrl = canvas.toDataURL('image/jpeg', 0.6); thumbnailCache.set(url, dataUrl); setThumbnail(dataUrl); } } catch (err) { console.error('Failed to capture video frame:', err); } finally { cleanup(); } }; const handleLoadedMetadata = () => { video.currentTime = 0.1; }; const handleSeeked = () => { captureFrame(); }; const handleError = () => { cleanup(); }; const cleanup = () => { video.removeEventListener('loadedmetadata', handleLoadedMetadata); video.removeEventListener('seeked', handleSeeked); video.removeEventListener('error', handleError); video.src = ''; video.load(); }; video.addEventListener('loadedmetadata', handleLoadedMetadata); video.addEventListener('seeked', handleSeeked); video.addEventListener('error', handleError); const timeout = setTimeout(() => { if (!thumbnailCache.has(url)) { cleanup(); } }, 5000); return () => { clearTimeout(timeout); cleanup(); }; }, [url, thumbnail, isInView]); if (!thumbnail) { return (
); } return ( ); }; const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile; className?: string; isFullView?: boolean }) => { const sizingClass = isFullView ? "w-full h-auto block" : "w-full h-full object-cover"; const mediaStyle = { transform: 'translateZ(0)' }; if (file.type === 'video') { return (