import { Heart, Download, Share2, MessageCircle, Edit3, Trash2, Layers, Loader2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { useState, useEffect, useRef } from "react"; import MarkdownRenderer from "@/components/MarkdownRenderer"; import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default'; import { useNavigate, useLocation } from "react-router-dom"; import { T, translate } from "@/i18n"; import type { MuxResolution } from "@/types"; import EditVideoModal from "@/components/EditVideoModal"; import { detectMediaType, MEDIA_TYPES } from "@/lib/mediaRegistry"; import UserAvatarBlock from "@/components/UserAvatarBlock"; import { formatDate, isLikelyFilename } from "@/utils/textUtils"; import { MediaPlayer, MediaProvider, type MediaPlayerInstance } from '@vidstack/react'; // Import Vidstack styles import '@vidstack/react/player/styles/default/theme.css'; import '@vidstack/react/player/styles/default/layouts/video.css'; interface VideoCardProps { videoId: string; videoUrl: string; thumbnailUrl?: string; title: string; author: string; authorId: string; likes: number; comments: number; isLiked?: boolean; description?: string | null; onClick?: (videoId: string) => void; onLike?: () => void; onDelete?: () => void; maxResolution?: MuxResolution; authorAvatarUrl?: string | null; showContent?: boolean; created_at?: string; job?: any; variant?: 'grid' | 'feed'; showPlayButton?: boolean; apiUrl?: string; } const VideoCard = ({ videoId, videoUrl, thumbnailUrl, title, author, authorId, likes, comments, isLiked = false, description, onClick, onLike, onDelete, maxResolution = '270p', authorAvatarUrl, showContent = true, showPlayButton = false, created_at, job, variant = 'grid', apiUrl }: VideoCardProps) => { const { user } = useAuth(); const navigate = useNavigate(); const location = useLocation(); const [localIsLiked, setLocalIsLiked] = useState(isLiked); const [localLikes, setLocalLikes] = useState(likes); const [isDeleting, setIsDeleting] = useState(false); const [versionCount, setVersionCount] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const player = useRef(null); // Stop playback on navigation & Cleanup useEffect(() => { console.log(`[VideoCard ${videoId}] Mounted`); const handleNavigation = () => { if (isPlaying) { console.log(`[VideoCard ${videoId}] Navigation detected - stopping`); } setIsPlaying(false); player.current?.pause(); }; handleNavigation(); return () => { console.log(`[VideoCard ${videoId}] Unmounting - pausing player`); player.current?.pause(); }; }, [location.pathname]); const [processingStatus, setProcessingStatus] = useState<'active' | 'completed' | 'failed' | 'unknown'>('unknown'); const [progress, setProgress] = useState(0); const [videoMeta, setVideoMeta] = useState(null); const isOwner = user?.id === authorId; const mediaType = detectMediaType(videoUrl); const isInternalVideo = mediaType === MEDIA_TYPES.VIDEO_INTERN; // Handle poster URL based on media type const posterUrl = (() => { if (!thumbnailUrl) return undefined; if (isInternalVideo) { return thumbnailUrl; // Use direct thumbnail for internal videos } // Default to Mux behavior return `${thumbnailUrl}?width=640&height=640&fit_mode=smartcrop&time=0`; })(); // Add max_resolution query parameter to video URL for bandwidth optimization // See: https://www.mux.com/docs/guides/control-playback-resolution const getVideoUrlWithResolution = (url: string) => { if (isInternalVideo) return url; // Internal videos handle quality differently (via HLS/presets in future) try { const urlObj = new URL(url); urlObj.searchParams.set('max_resolution', maxResolution); return urlObj.toString(); } catch { // If URL parsing fails, append as query string const separator = url.includes('?') ? '&' : '?'; return `${url}${separator}max_resolution=${maxResolution}`; } }; const playbackUrl = getVideoUrlWithResolution(videoUrl); // Fetch version count for owners only useEffect(() => { const fetchVersionCount = async () => { if (!isOwner || !user) return; return; }; fetchVersionCount(); }, [videoId, isOwner, user]); // Handle Video Processing Status (SSE) useEffect(() => { if (!isInternalVideo) return; // 1. Use verified job data from server if available (e.g. from Feed) if (job) { if (job.status === 'completed') { setProcessingStatus('completed'); // If we have a verified resultUrl, we might want to use it? // But the parent component passes `videoUrl`, so we assume parent updated it or logic below handles it. return; } if (job.status === 'failed') { setProcessingStatus('failed'); return; } // If active, we fall through to start SSE below, but init state first setProcessingStatus('active'); setProgress(job.progress || 0); } // Extract Job ID from URL // Format: .../api/videos/jobs/:jobId/... (regex: /api/videos/jobs/([^/]+)\//) const match = videoUrl.match(/\/api\/videos\/jobs\/([^\/]+)\//) || (job ? [null, job.id] : null); if (!match) return; const jobId = match[1]; // Use VITE_SERVER_IMAGE_API_URL or default. Do NOT infer from videoUrl if it logic fails. let baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://localhost:3333'; // Fallback: If videoUrl is internal (same origin), use that origin. // But for Supabase URLs, we MUST use the API server URL. if (videoUrl.startsWith('/') || videoUrl.includes(window.location.origin)) { baseUrl = window.location.origin; } let eventSource: EventSource | null = null; let isMounted = true; const checkStatusAndConnect = async () => { if (!jobId) return; try { const res = await fetch(`${baseUrl}/api/videos/jobs/${jobId}`); if (!res.ok) throw new Error('Failed to fetch job'); const data = await res.json(); if (!isMounted) return; if (data.status === 'completed') { setProcessingStatus('completed'); return; } else if (data.status === 'failed') { setProcessingStatus('failed'); return; } // Only connect SSE if still active/created/waiting setProcessingStatus('active'); const sseUrl = `${baseUrl}/api/videos/jobs/${jobId}/progress`; eventSource = new EventSource(sseUrl); eventSource.addEventListener('progress', (e) => { if (!isMounted) return; try { const data = JSON.parse((e as MessageEvent).data); if (data.progress !== undefined) { setProgress(Math.round(data.progress)); } if (data.status) { if (data.status === 'completed') { setProcessingStatus('completed'); eventSource?.close(); } else if (data.status === 'failed') { setProcessingStatus('failed'); eventSource?.close(); } else { setProcessingStatus('active'); } } } catch (err) { console.error('SSE Parse Error', err); } }); eventSource.onerror = (e) => { eventSource?.close(); // Fallback check handled by initial check logic or user refresh // But we can retry once }; } catch (error) { console.error('Error checking video status:', error); if (isMounted) setProcessingStatus('unknown'); } }; checkStatusAndConnect(); return () => { isMounted = false; if (eventSource) { eventSource.close(); } }; }, [isInternalVideo, videoUrl, job]); const handleCancelProcessing = async (e: React.MouseEvent) => { e.stopPropagation(); if (!confirm('Cancel this upload?')) return; const match = videoUrl.match(/\/api\/videos\/jobs\/([^\/]+)\//); if (!match) return; const jobId = match[1]; // Use VITE_SERVER_IMAGE_API_URL or default let baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://localhost:3333'; if (videoUrl.startsWith('/') || videoUrl.includes(window.location.origin)) { baseUrl = window.location.origin; } try { await fetch(`${baseUrl}/api/videos/jobs/${jobId}`, { method: 'DELETE' }); toast.info(translate('Upload cancelled')); // Trigger delete to remove from UI onDelete?.(); } catch (err) { console.error('Failed to cancel', err); toast.error(translate('Failed to cancel upload')); } }; const handleLike = async (e: React.MouseEvent) => { e.stopPropagation(); if (!user) { toast.error(translate('Please sign in to like videos')); return; } try { if (localIsLiked) { // Unlike - need to implement video likes table toast.info('Video likes coming soon'); } else { // Like - need to implement video likes table toast.info('Video likes coming soon'); } onLike?.(); } catch (error) { console.error('Error toggling like:', error); toast.error(translate('Failed to update like')); } }; const handleDelete = async (e: React.MouseEvent) => { e.stopPropagation(); if (!user || !isOwner) { toast.error(translate('You can only delete your own videos')); return; } if (!confirm(translate('Are you sure you want to delete this video? This action cannot be undone.'))) { return; } setIsDeleting(true); try { // First get the video details for storage cleanup const { data: video, error: fetchError } = await supabase .from('pictures') .select('image_url') .eq('id', videoId) .single(); if (fetchError) throw fetchError; // Delete from database (this will cascade delete likes and comments due to foreign keys) const { error: deleteError } = await supabase .from('pictures') .delete() .eq('id', videoId); if (deleteError) throw deleteError; // Try to delete from storage as well (videos use image_url field for HLS URL) if (video?.image_url) { const urlParts = video.image_url.split('/'); const fileName = urlParts[urlParts.length - 1]; const userIdFromUrl = urlParts[urlParts.length - 2]; const { error: storageError } = await supabase.storage .from('videos') .remove([`${userIdFromUrl}/${fileName}`]); if (storageError) { console.error('Error deleting from storage:', storageError); // Don't show error to user as the main deletion succeeded } } toast.success(translate('Video deleted successfully')); onDelete?.(); // Trigger refresh of the parent component } catch (error) { console.error('Error deleting video:', error); toast.error(translate('Failed to delete video')); } finally { setIsDeleting(false); } }; const handleDownload = async () => { try { const link = document.createElement('a'); link.href = videoUrl; link.download = `${title}.mp4`; document.body.appendChild(link); link.click(); document.body.removeChild(link); toast.success(translate('Video download started')); } catch (error) { console.error('Error downloading video:', error); toast.error(translate('Failed to download video')); } }; const handleClick = (e: React.MouseEvent) => { console.log('Video clicked'); e.preventDefault(); e.stopPropagation(); onClick?.(videoId); }; // Handle global stop-video event useEffect(() => { const handleStopVideo = (e: Event) => { const customEvent = e as CustomEvent; if (customEvent.detail?.sourceId !== videoId && isPlaying) { console.log(`[VideoCard ${videoId}] Stopping due to global event`); setIsPlaying(false); player.current?.pause(); } }; window.addEventListener('stop-video', handleStopVideo); return () => window.removeEventListener('stop-video', handleStopVideo); }, [isPlaying, videoId]); const handlePlayClick = (e: React.MouseEvent) => { console.log('Play clicked'); e.preventDefault(); e.stopPropagation(); // Stop other videos window.dispatchEvent(new CustomEvent('stop-video', { detail: { sourceId: videoId } })); setIsPlaying(true); }; return (
{/* Video Player - usage square aspect to match PhotoCard unless variant is feed */}
{!isPlaying ? ( // Show thumbnail with play button overlay <> {title} {/* Processing Overlay */} {isInternalVideo && processingStatus !== 'completed' && processingStatus !== 'unknown' && (
{processingStatus === 'failed' ? (
Processing Failed
) : (
Processing {progress}%
)}
)} {/* Play Button Overlay */} {showPlayButton && (!isInternalVideo || processingStatus === 'completed' || processingStatus === 'unknown') && ( )} ) : ( // Show MediaPlayer when playing )}
{/* Desktop Hover Overlay - hidden on mobile, and hidden in feed variant. Also hidden when playing to avoid blocking controls. */} {showContent && variant === 'grid' && !isPlaying && (
{localLikes} {comments} {isOwner && ( <> {versionCount > 1 && (
{versionCount}
)} )}

{title}

{description && (
)}
)} {/* Mobile/Feed Content - always visible below video */} {showContent && (variant === 'feed' || (variant === 'grid' && true)) && (
{/* Row 1: Avatar + Actions */}
{localLikes > 0 && ( {localLikes} )} {comments > 0 && ( {comments} )} {isOwner && ( <> )}
{/* Likes */} {/* Caption / Description section */}
{(!isLikelyFilename(title) && title) && (
{title}
)} {description && (
)} {created_at && (
{formatDate(created_at)}
)}
)} {showEditModal && ( { setShowEditModal(false); onDelete?.(); // Trigger refresh }} /> )}
); }; export default VideoCard;