Chore: rename api->camera-management-api and web->management-dashboard-web-app; update compose, ignore, README references
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* ApiStatusIndicator Component
|
||||
*
|
||||
* A component that displays the connection status of the video streaming API
|
||||
* and provides helpful information when the API is not accessible.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
|
||||
interface ApiStatusIndicatorProps {
|
||||
className?: string;
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
export const ApiStatusIndicator: React.FC<ApiStatusIndicatorProps> = ({
|
||||
className = '',
|
||||
showDetails = false,
|
||||
}) => {
|
||||
const [isOnline, setIsOnline] = useState<boolean | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [lastChecked, setLastChecked] = useState<Date | null>(null);
|
||||
|
||||
const checkApiStatus = async () => {
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const status = await videoApiService.healthCheck();
|
||||
setIsOnline(status);
|
||||
setLastChecked(new Date());
|
||||
} catch (error) {
|
||||
setIsOnline(false);
|
||||
setLastChecked(new Date());
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkApiStatus();
|
||||
|
||||
// Check status every 30 seconds
|
||||
const interval = setInterval(checkApiStatus, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (isChecking) return 'bg-yellow-500';
|
||||
if (isOnline === null) return 'bg-gray-500';
|
||||
return isOnline ? 'bg-green-500' : 'bg-red-500';
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (isChecking) return 'Checking...';
|
||||
if (isOnline === null) return 'Unknown';
|
||||
return isOnline ? 'Connected' : 'Disconnected';
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-white"></div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOnline) {
|
||||
return (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
if (!showDetails) {
|
||||
return (
|
||||
<div className={`inline-flex items-center ${className}`}>
|
||||
<div className={`w-2 h-2 rounded-full ${getStatusColor()} mr-2`}></div>
|
||||
<span className="text-sm text-gray-600">{getStatusText()}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white border border-gray-200 rounded-lg p-4 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-900">Video API Status</h3>
|
||||
<button
|
||||
onClick={checkApiStatus}
|
||||
disabled={isChecking}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 disabled:opacity-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mb-2">
|
||||
<div className={`w-3 h-3 rounded-full ${getStatusColor()} mr-2 flex items-center justify-center text-white`}>
|
||||
{getStatusIcon()}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{getStatusText()}</span>
|
||||
</div>
|
||||
|
||||
{lastChecked && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Last checked: {lastChecked.toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOnline === false && (
|
||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div className="text-sm text-red-800">
|
||||
<strong>Connection Failed</strong>
|
||||
<p className="mt-1">
|
||||
Cannot connect to the USDA Vision Camera System. Please ensure:
|
||||
</p>
|
||||
<ul className="mt-2 list-disc list-inside space-y-1">
|
||||
<li>The vision system is running</li>
|
||||
<li>The API is accessible at the configured URL</li>
|
||||
<li>Network connectivity is available</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Pagination Component
|
||||
*
|
||||
* A reusable pagination component that matches the dashboard template's styling patterns.
|
||||
* Provides page navigation with first/last, previous/next, and numbered page buttons.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { type PaginationProps } from '../types';
|
||||
|
||||
export const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
showFirstLast = true,
|
||||
showPrevNext = true,
|
||||
maxVisiblePages = 5,
|
||||
className = '',
|
||||
}) => {
|
||||
// Don't render if there's only one page or no pages
|
||||
if (totalPages <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate visible page numbers
|
||||
const getVisiblePages = (): number[] => {
|
||||
const pages: number[] = [];
|
||||
const halfVisible = Math.floor(maxVisiblePages / 2);
|
||||
|
||||
let startPage = Math.max(1, currentPage - halfVisible);
|
||||
let endPage = Math.min(totalPages, currentPage + halfVisible);
|
||||
|
||||
// Adjust if we're near the beginning or end
|
||||
if (endPage - startPage + 1 < maxVisiblePages) {
|
||||
if (startPage === 1) {
|
||||
endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
|
||||
} else {
|
||||
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
const visiblePages = getVisiblePages();
|
||||
const isFirstPage = currentPage === 1;
|
||||
const isLastPage = currentPage === totalPages;
|
||||
|
||||
// Button base classes matching dashboard template
|
||||
const baseButtonClasses = "inline-flex items-center justify-center px-3 py-2 text-sm font-medium transition-all duration-200 rounded-lg border";
|
||||
|
||||
// Active page button classes
|
||||
const activeButtonClasses = "bg-brand-500 text-white border-brand-500 hover:bg-brand-600 shadow-theme-sm";
|
||||
|
||||
// Inactive page button classes
|
||||
const inactiveButtonClasses = "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:border-gray-400 shadow-theme-xs";
|
||||
|
||||
// Disabled button classes
|
||||
const disabledButtonClasses = "bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed opacity-50";
|
||||
|
||||
const handlePageClick = (page: number) => {
|
||||
if (page !== currentPage && page >= 1 && page <= totalPages) {
|
||||
onPageChange(page);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center space-x-1 ${className}`}>
|
||||
{/* First Page Button */}
|
||||
{showFirstLast && !isFirstPage && (
|
||||
<button
|
||||
onClick={() => handlePageClick(1)}
|
||||
className={`${baseButtonClasses} ${inactiveButtonClasses}`}
|
||||
aria-label="Go to first page"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Previous Page Button */}
|
||||
{showPrevNext && (
|
||||
<button
|
||||
onClick={() => handlePageClick(currentPage - 1)}
|
||||
disabled={isFirstPage}
|
||||
className={`${baseButtonClasses} ${isFirstPage ? disabledButtonClasses : inactiveButtonClasses}`}
|
||||
aria-label="Go to previous page"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Page Number Buttons */}
|
||||
{visiblePages.map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => handlePageClick(page)}
|
||||
className={`${baseButtonClasses} ${page === currentPage ? activeButtonClasses : inactiveButtonClasses
|
||||
} min-w-[40px]`}
|
||||
aria-label={`Go to page ${page}`}
|
||||
aria-current={page === currentPage ? 'page' : undefined}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Next Page Button */}
|
||||
{showPrevNext && (
|
||||
<button
|
||||
onClick={() => handlePageClick(currentPage + 1)}
|
||||
disabled={isLastPage}
|
||||
className={`${baseButtonClasses} ${isLastPage ? disabledButtonClasses : inactiveButtonClasses}`}
|
||||
aria-label="Go to next page"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Last Page Button */}
|
||||
{showFirstLast && !isLastPage && (
|
||||
<button
|
||||
onClick={() => handlePageClick(totalPages)}
|
||||
className={`${baseButtonClasses} ${inactiveButtonClasses}`}
|
||||
aria-label="Go to last page"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Page info component to show current page and total
|
||||
export const PageInfo: React.FC<{
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
className?: string;
|
||||
}> = ({ currentPage, totalPages, totalItems, itemsPerPage, className = '' }) => {
|
||||
const startItem = (currentPage - 1) * itemsPerPage + 1;
|
||||
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
|
||||
return (
|
||||
<div className={`text-sm text-gray-600 ${className}`}>
|
||||
Showing {startItem} to {endItem} of {totalItems} results (Page {currentPage} of {totalPages})
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* PerformanceDashboard Component
|
||||
*
|
||||
* A development tool for monitoring video streaming performance.
|
||||
* Only shown in development mode.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { performanceMonitor, thumbnailCache } from '../utils';
|
||||
|
||||
interface PerformanceDashboardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PerformanceDashboard: React.FC<PerformanceDashboardProps> = ({
|
||||
className = '',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [cacheStats, setCacheStats] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const updateStats = () => {
|
||||
setStats({
|
||||
overall: performanceMonitor.getStats(),
|
||||
getVideos: performanceMonitor.getStats('get_videos'),
|
||||
getThumbnail: performanceMonitor.getStats('get_thumbnail'),
|
||||
recentMetrics: performanceMonitor.getRecentMetrics(5),
|
||||
});
|
||||
setCacheStats(thumbnailCache.getStats());
|
||||
};
|
||||
|
||||
updateStats();
|
||||
const interval = setInterval(updateStats, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Only show in development
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={`fixed bottom-4 right-4 bg-blue-600 text-white p-2 rounded-full shadow-lg hover:bg-blue-700 transition-colors z-50 ${className}`}
|
||||
title="Open Performance Dashboard"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 0l-2 2a1 1 0 101.414 1.414L8 10.414l1.293 1.293a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-4 right-4 bg-white border border-gray-200 rounded-lg shadow-xl p-4 max-w-md w-80 max-h-96 overflow-y-auto z-50 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Performance</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="space-y-4">
|
||||
{/* Overall Stats */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Overall</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>Operations: {stats.overall.totalOperations}</div>
|
||||
<div>Success: {(stats.overall.successRate * 100).toFixed(1)}%</div>
|
||||
<div>Avg: {stats.overall.averageDuration.toFixed(0)}ms</div>
|
||||
<div>Max: {stats.overall.maxDuration.toFixed(0)}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Loading Stats */}
|
||||
{stats.getVideos.totalOperations > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Video Loading</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>Calls: {stats.getVideos.totalOperations}</div>
|
||||
<div>Success: {(stats.getVideos.successRate * 100).toFixed(1)}%</div>
|
||||
<div>Avg: {stats.getVideos.averageDuration.toFixed(0)}ms</div>
|
||||
<div>Max: {stats.getVideos.maxDuration.toFixed(0)}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail Stats */}
|
||||
{stats.getThumbnail.totalOperations > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Thumbnails</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>Calls: {stats.getThumbnail.totalOperations}</div>
|
||||
<div>Success: {(stats.getThumbnail.successRate * 100).toFixed(1)}%</div>
|
||||
<div>Avg: {stats.getThumbnail.averageDuration.toFixed(0)}ms</div>
|
||||
<div>Max: {stats.getThumbnail.maxDuration.toFixed(0)}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cache Stats */}
|
||||
{cacheStats && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Cache</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>Cached: {cacheStats.size}</div>
|
||||
<div>Memory: {(cacheStats.totalMemory / 1024 / 1024).toFixed(1)}MB</div>
|
||||
<div>Hits: {cacheStats.totalAccess}</div>
|
||||
<div>Avg Size: {(cacheStats.averageSize / 1024).toFixed(0)}KB</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Operations */}
|
||||
{stats.recentMetrics.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Recent</h4>
|
||||
<div className="space-y-1">
|
||||
{stats.recentMetrics.map((metric: any, index: number) => (
|
||||
<div key={index} className="flex justify-between text-xs">
|
||||
<span className={metric.success ? 'text-green-600' : 'text-red-600'}>
|
||||
{metric.operation}
|
||||
</span>
|
||||
<span>{metric.duration?.toFixed(0)}ms</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
performanceMonitor.clear();
|
||||
thumbnailCache.clear();
|
||||
}}
|
||||
className="flex-1 px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log(performanceMonitor.getReport());
|
||||
console.log('Cache Stats:', thumbnailCache.getStats());
|
||||
}}
|
||||
className="flex-1 px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
|
||||
>
|
||||
Log Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* VideoCard Component
|
||||
*
|
||||
* A reusable card component for displaying video information with thumbnail, metadata, and actions.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { type VideoCardProps } from '../types';
|
||||
import { VideoThumbnail } from './VideoThumbnail';
|
||||
import {
|
||||
formatFileSize,
|
||||
formatVideoDate,
|
||||
getRelativeTime,
|
||||
getFormatDisplayName,
|
||||
getStatusBadgeClass,
|
||||
getResolutionString,
|
||||
} from '../utils/videoUtils';
|
||||
|
||||
export const VideoCard: React.FC<VideoCardProps> = ({
|
||||
video,
|
||||
onClick,
|
||||
showMetadata = true,
|
||||
className = '',
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick(video);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbnailClick = () => {
|
||||
handleClick();
|
||||
};
|
||||
|
||||
const cardClasses = [
|
||||
'bg-white rounded-xl border border-gray-200 overflow-hidden transition-all hover:shadow-theme-md',
|
||||
onClick ? 'cursor-pointer hover:border-gray-300' : '',
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className={cardClasses} onClick={onClick ? handleClick : undefined}>
|
||||
{/* Thumbnail */}
|
||||
<div className="relative">
|
||||
<VideoThumbnail
|
||||
fileId={video.file_id}
|
||||
width={320}
|
||||
height={180}
|
||||
alt={`Thumbnail for ${video.filename}`}
|
||||
onClick={onClick ? handleThumbnailClick : undefined}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="absolute top-2 left-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusBadgeClass(video.status)}`}>
|
||||
{video.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Format Badge */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{getFormatDisplayName(video.format)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Streamable Indicator */}
|
||||
{video.is_streamable ? (
|
||||
<div className="absolute bottom-2 left-2">
|
||||
<div className="bg-green-500 text-white text-xs px-2 py-1 rounded flex items-center">
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Streamable
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute bottom-2 left-2">
|
||||
<div className="bg-yellow-500 text-white text-xs px-2 py-1 rounded flex items-center">
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Processing
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conversion Needed Indicator */}
|
||||
{video.needs_conversion && (
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<div className="bg-yellow-500 text-white text-xs px-2 py-1 rounded flex items-center">
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Needs Conversion
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2 truncate" title={video.filename}>
|
||||
{video.filename}
|
||||
</h3>
|
||||
|
||||
{/* Camera Name */}
|
||||
<div className="flex items-center text-sm text-gray-600 mb-2">
|
||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{video.camera_name}
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm text-gray-600 mb-3">
|
||||
<div>
|
||||
<span className="font-medium">Size:</span> {formatFileSize(video.file_size_bytes)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Created:</span> {getRelativeTime(video.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata (if available and requested) */}
|
||||
{showMetadata && 'metadata' in video && video.metadata && (
|
||||
<div className="border-t pt-3 mt-3 border-gray-100">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm text-gray-600">
|
||||
<div>
|
||||
<span className="font-medium">Duration:</span> {Math.round(video.metadata.duration_seconds)}s
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Resolution:</span> {getResolutionString(video.metadata.width, video.metadata.height)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">FPS:</span> {video.metadata.fps}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Codec:</span> {video.metadata.codec}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between items-center mt-4 pt-3 border-t border-gray-100">
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatVideoDate(video.created_at)}
|
||||
</div>
|
||||
|
||||
{onClick && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClick();
|
||||
}}
|
||||
className="inline-flex items-center px-3 py-1.5 text-xs font-medium transition rounded-lg border border-transparent bg-brand-500 text-white hover:bg-brand-600 shadow-theme-xs"
|
||||
>
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Play
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* VideoDebugger Component
|
||||
*
|
||||
* A development tool for debugging video streaming issues.
|
||||
* Provides direct access to test video URLs and diagnose problems.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
|
||||
interface VideoDebuggerProps {
|
||||
fileId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const VideoDebugger: React.FC<VideoDebuggerProps> = ({
|
||||
fileId,
|
||||
className = '',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [testResults, setTestResults] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const streamingUrl = videoApiService.getStreamingUrl(fileId);
|
||||
const thumbnailUrl = videoApiService.getThumbnailUrl(fileId);
|
||||
|
||||
const runDiagnostics = async () => {
|
||||
setIsLoading(true);
|
||||
const results: any = {
|
||||
timestamp: new Date().toISOString(),
|
||||
fileId,
|
||||
streamingUrl,
|
||||
thumbnailUrl,
|
||||
tests: {}
|
||||
};
|
||||
|
||||
try {
|
||||
// Test 1: Video Info
|
||||
try {
|
||||
const videoInfo = await videoApiService.getVideoInfo(fileId);
|
||||
results.tests.videoInfo = { success: true, data: videoInfo };
|
||||
} catch (error) {
|
||||
results.tests.videoInfo = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
|
||||
// Test 2: Streaming Info
|
||||
try {
|
||||
const streamingInfo = await videoApiService.getStreamingInfo(fileId);
|
||||
results.tests.streamingInfo = { success: true, data: streamingInfo };
|
||||
} catch (error) {
|
||||
results.tests.streamingInfo = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
|
||||
// Test 3: HEAD request to streaming URL
|
||||
try {
|
||||
const response = await fetch(streamingUrl, { method: 'HEAD' });
|
||||
results.tests.streamingHead = {
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries())
|
||||
};
|
||||
} catch (error) {
|
||||
results.tests.streamingHead = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
|
||||
// Test 4: Range request test
|
||||
try {
|
||||
const response = await fetch(streamingUrl, {
|
||||
headers: { 'Range': 'bytes=0-1023' }
|
||||
});
|
||||
results.tests.rangeRequest = {
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
supportsRanges: response.headers.get('accept-ranges') === 'bytes',
|
||||
contentRange: response.headers.get('content-range')
|
||||
};
|
||||
} catch (error) {
|
||||
results.tests.rangeRequest = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
|
||||
// Test 5: Thumbnail test
|
||||
try {
|
||||
const response = await fetch(thumbnailUrl, { method: 'HEAD' });
|
||||
results.tests.thumbnail = {
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
contentType: response.headers.get('content-type')
|
||||
};
|
||||
} catch (error) {
|
||||
results.tests.thumbnail = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
results.error = error instanceof Error ? error.message : 'Unknown error';
|
||||
}
|
||||
|
||||
setTestResults(results);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// Only show in development
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={`px-3 py-1 text-xs bg-yellow-100 text-yellow-800 rounded hover:bg-yellow-200 transition-colors ${className}`}
|
||||
title="Open Video Debugger"
|
||||
>
|
||||
🔧 Debug
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white border border-gray-200 rounded-lg shadow-lg p-4 max-w-2xl ${className}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Video Debugger</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Basic Info */}
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-2">Basic Info</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
<div><strong>File ID:</strong> {fileId}</div>
|
||||
<div><strong>Streaming URL:</strong> <a href={streamingUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">{streamingUrl}</a></div>
|
||||
<div><strong>Thumbnail URL:</strong> <a href={thumbnailUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">{thumbnailUrl}</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-2">Quick Actions</h4>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={runDiagnostics}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Running...' : 'Run Diagnostics'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(streamingUrl, '_blank')}
|
||||
className="px-3 py-1 text-sm bg-green-600 text-white rounded hover:bg-green-700"
|
||||
>
|
||||
Open Video
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(thumbnailUrl, '_blank')}
|
||||
className="px-3 py-1 text-sm bg-purple-600 text-white rounded hover:bg-purple-700"
|
||||
>
|
||||
Open Thumbnail
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Results */}
|
||||
{testResults && (
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-2">Diagnostic Results</h4>
|
||||
<div className="bg-gray-50 rounded p-3 text-xs font-mono max-h-64 overflow-y-auto">
|
||||
<pre>{JSON.stringify(testResults, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Native Video Test */}
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-2">Native Video Test</h4>
|
||||
<video
|
||||
controls
|
||||
width="100%"
|
||||
height="200"
|
||||
className="border rounded"
|
||||
onLoadStart={() => console.log('Native video load started')}
|
||||
onLoadedData={() => console.log('Native video data loaded')}
|
||||
onError={(e) => console.error('Native video error:', e)}
|
||||
>
|
||||
<source src={streamingUrl} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* VideoErrorBoundary Component
|
||||
*
|
||||
* Error boundary specifically designed for video streaming components.
|
||||
* Provides user-friendly error messages and recovery options.
|
||||
*/
|
||||
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: React.ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class VideoErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
errorInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
});
|
||||
|
||||
// Call the onError callback if provided
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo);
|
||||
}
|
||||
|
||||
// Log error for debugging
|
||||
console.error('Video streaming error:', error, errorInfo);
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Use custom fallback if provided
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// Default error UI
|
||||
return (
|
||||
<div className="min-h-[400px] flex items-center justify-center bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="text-center max-w-md mx-auto p-6">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Video System Error
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 mb-4">
|
||||
Something went wrong with the video streaming component. This might be due to:
|
||||
</p>
|
||||
|
||||
<ul className="text-sm text-gray-500 text-left mb-6 space-y-1">
|
||||
<li>• Network connectivity issues</li>
|
||||
<li>• Video API server problems</li>
|
||||
<li>• Corrupted video files</li>
|
||||
<li>• Browser compatibility issues</li>
|
||||
</ul>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="w-full px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error details for debugging (only in development) */}
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<details className="mt-6 text-left">
|
||||
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
|
||||
Show Error Details
|
||||
</summary>
|
||||
<div className="mt-2 p-3 bg-gray-100 rounded text-xs font-mono text-gray-700 overflow-auto max-h-32">
|
||||
<div className="font-semibold mb-1">Error:</div>
|
||||
<div className="mb-2">{this.state.error.message}</div>
|
||||
<div className="font-semibold mb-1">Stack:</div>
|
||||
<div>{this.state.error.stack}</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// Higher-order component for easier usage
|
||||
export function withVideoErrorBoundary<P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
fallback?: ReactNode
|
||||
) {
|
||||
return function WrappedComponent(props: P) {
|
||||
return (
|
||||
<VideoErrorBoundary fallback={fallback}>
|
||||
<Component {...props} />
|
||||
</VideoErrorBoundary>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* VideoList Component
|
||||
*
|
||||
* A reusable component for displaying a list/grid of videos with filtering, sorting, and pagination.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { type VideoListProps, type VideoListFilters, type VideoListSortOptions } from '../types';
|
||||
import { useVideoList } from '../hooks/useVideoList';
|
||||
import { VideoCard } from './VideoCard';
|
||||
import { Pagination, PageInfo } from './Pagination';
|
||||
|
||||
export const VideoList: React.FC<VideoListProps> = ({
|
||||
filters,
|
||||
sortOptions,
|
||||
limit = 20,
|
||||
onVideoSelect,
|
||||
className = '',
|
||||
}) => {
|
||||
const [localFilters, setLocalFilters] = useState<VideoListFilters>(filters || {});
|
||||
const [localSort, setLocalSort] = useState<VideoListSortOptions>(
|
||||
sortOptions || { field: 'created_at', direction: 'desc' }
|
||||
);
|
||||
|
||||
const {
|
||||
videos,
|
||||
totalCount,
|
||||
currentPage,
|
||||
totalPages,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
loadMore,
|
||||
hasMore,
|
||||
goToPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
updateFilters,
|
||||
updateSort,
|
||||
} = useVideoList({
|
||||
initialParams: {
|
||||
camera_name: localFilters.cameraName,
|
||||
start_date: localFilters.dateRange?.start,
|
||||
end_date: localFilters.dateRange?.end,
|
||||
limit,
|
||||
include_metadata: true,
|
||||
page: 1, // Start with page 1
|
||||
},
|
||||
autoFetch: true,
|
||||
});
|
||||
|
||||
// Update filters when props change (without causing infinite loops)
|
||||
useEffect(() => {
|
||||
if (filters) {
|
||||
setLocalFilters(filters);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
// Update sort when props change (without causing infinite loops)
|
||||
useEffect(() => {
|
||||
if (sortOptions) {
|
||||
setLocalSort(sortOptions);
|
||||
}
|
||||
}, [sortOptions]);
|
||||
|
||||
const handleVideoClick = (video: any) => {
|
||||
if (onVideoSelect) {
|
||||
onVideoSelect(video);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (hasMore && loading !== 'loading') {
|
||||
loadMore();
|
||||
}
|
||||
};
|
||||
|
||||
const containerClasses = [
|
||||
'video-list',
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
if (loading === 'loading' && videos.length === 0) {
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Loading videos...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<svg className="w-12 h-12 text-red-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Error Loading Videos</h3>
|
||||
<p className="text-gray-600 mb-4">{error.message}</p>
|
||||
<button
|
||||
onClick={refetch}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (videos.length === 0) {
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<svg className="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No Videos Found</h3>
|
||||
<p className="text-gray-600">No videos match your current filters.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{/* Top Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-white rounded-xl border border-gray-200 shadow-theme-sm">
|
||||
{/* Page Info */}
|
||||
<PageInfo
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalCount}
|
||||
itemsPerPage={limit}
|
||||
className="text-sm text-gray-600"
|
||||
/>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={goToPage}
|
||||
showFirstLast={true}
|
||||
showPrevNext={true}
|
||||
maxVisiblePages={5}
|
||||
className="justify-center sm:justify-end"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="text-sm text-gray-600">
|
||||
{totalPages > 0 ? (
|
||||
<>Showing page {currentPage} of {totalPages} ({totalCount} total videos)</>
|
||||
) : (
|
||||
<>Showing {videos.length} of {totalCount} videos</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={refetch}
|
||||
disabled={loading === 'loading'}
|
||||
className="inline-flex items-center px-3 py-2 text-sm font-medium transition rounded-lg border bg-white text-gray-700 border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed shadow-theme-xs"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{loading === 'loading' ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Video Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{videos.map((video) => (
|
||||
<VideoCard
|
||||
key={video.file_id}
|
||||
video={video}
|
||||
onClick={onVideoSelect ? handleVideoClick : undefined}
|
||||
showMetadata={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom Pagination */}
|
||||
{totalPages > 1 && videos.length > 0 && (
|
||||
<div className="mt-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-white rounded-xl border border-gray-200 shadow-theme-sm">
|
||||
{/* Page Info */}
|
||||
<PageInfo
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalCount}
|
||||
itemsPerPage={limit}
|
||||
className="text-sm text-gray-600"
|
||||
/>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={goToPage}
|
||||
showFirstLast={true}
|
||||
showPrevNext={true}
|
||||
maxVisiblePages={5}
|
||||
className="justify-center sm:justify-end"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading Indicator */}
|
||||
{loading === 'loading' && (
|
||||
<div className="flex justify-center mt-8">
|
||||
<div className="text-sm text-gray-600 flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-brand-500 mr-2"></div>
|
||||
Loading videos...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* VideoModal Component
|
||||
*
|
||||
* A modal component for displaying videos in fullscreen with detailed information.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { type VideoFile } from '../types';
|
||||
import { VideoPlayer } from './VideoPlayer';
|
||||
import { VideoDebugger } from './VideoDebugger';
|
||||
import { useVideoInfo } from '../hooks/useVideoInfo';
|
||||
import {
|
||||
formatFileSize,
|
||||
formatVideoDate,
|
||||
getFormatDisplayName,
|
||||
getStatusBadgeClass,
|
||||
getResolutionString,
|
||||
formatDuration,
|
||||
isWebCompatible,
|
||||
} from '../utils/videoUtils';
|
||||
|
||||
interface VideoModalProps {
|
||||
video: VideoFile | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const VideoModal: React.FC<VideoModalProps> = ({
|
||||
video,
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const { videoInfo, streamingInfo, loading, error } = useVideoInfo(
|
||||
video?.file_id || null,
|
||||
{ autoFetch: isOpen && !!video }
|
||||
);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen || !video) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[999999] overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-75 transition-opacity"
|
||||
onClick={handleBackdropClick}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<div className="relative bg-white rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="text-xl font-semibold text-gray-900 truncate pr-4">
|
||||
{video.filename}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded p-1"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col lg:flex-row max-h-[calc(90vh-80px)]">
|
||||
{/* Video Player */}
|
||||
<div className="flex-1 bg-black">
|
||||
<VideoPlayer
|
||||
fileId={video.file_id}
|
||||
controls={true}
|
||||
className="w-full h-full min-h-[300px] lg:min-h-[400px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sidebar with Video Info */}
|
||||
<div className="w-full lg:w-80 bg-gray-50 overflow-y-auto">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Status and Format */}
|
||||
<div className="flex items-center space-x-2 flex-wrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusBadgeClass(video.status)}`}>
|
||||
{video.status}
|
||||
</span>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${isWebCompatible(video.format)
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-orange-100 text-orange-800'
|
||||
}`}>
|
||||
{getFormatDisplayName(video.format)}
|
||||
</span>
|
||||
{isWebCompatible(video.format) && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Web Compatible
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Basic Information</h3>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Camera:</dt>
|
||||
<dd className="text-gray-900">{video.camera_name}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">File Size:</dt>
|
||||
<dd className="text-gray-900">{formatFileSize(video.file_size_bytes)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Created:</dt>
|
||||
<dd className="text-gray-900">{formatVideoDate(video.created_at)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Streamable:</dt>
|
||||
<dd className="text-gray-900">{video.is_streamable ? 'Yes' : 'No'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Video Metadata */}
|
||||
{videoInfo?.metadata && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Video Details</h3>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Duration:</dt>
|
||||
<dd className="text-gray-900">{formatDuration(videoInfo.metadata.duration_seconds)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Resolution:</dt>
|
||||
<dd className="text-gray-900">
|
||||
{getResolutionString(videoInfo.metadata.width, videoInfo.metadata.height)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Frame Rate:</dt>
|
||||
<dd className="text-gray-900">{videoInfo.metadata.fps} fps</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Codec:</dt>
|
||||
<dd className="text-gray-900">{videoInfo.metadata.codec}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Aspect Ratio:</dt>
|
||||
<dd className="text-gray-900">{videoInfo.metadata.aspect_ratio.toFixed(2)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming Info */}
|
||||
{streamingInfo && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Streaming Details</h3>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Content Type:</dt>
|
||||
<dd className="text-gray-900">{streamingInfo.content_type}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Range Requests:</dt>
|
||||
<dd className="text-gray-900">{streamingInfo.supports_range_requests ? 'Supported' : 'Not Supported'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Chunk Size:</dt>
|
||||
<dd className="text-gray-900">{formatFileSize(streamingInfo.chunk_size_bytes)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading === 'loading' && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||
<span className="ml-2 text-sm text-gray-600">Loading video details...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<svg className="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Error loading video details</h3>
|
||||
<p className="text-sm text-red-700 mt-1">{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Debugger (development only) */}
|
||||
<VideoDebugger fileId={video.file_id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* VideoPlayer Component
|
||||
*
|
||||
* A reusable video player component with full controls and customization options.
|
||||
* Uses the useVideoPlayer hook for state management and provides a clean interface.
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useState, useEffect } from 'react';
|
||||
import { useVideoPlayer } from '../hooks/useVideoPlayer';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
import { type VideoPlayerProps } from '../types';
|
||||
import { formatDuration, getVideoMimeType } from '../utils/videoUtils';
|
||||
|
||||
export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(({
|
||||
fileId,
|
||||
autoPlay = false,
|
||||
controls = true,
|
||||
width = '100%',
|
||||
height = 'auto',
|
||||
className = '',
|
||||
onPlay,
|
||||
onPause,
|
||||
onEnded,
|
||||
onError,
|
||||
}, forwardedRef) => {
|
||||
const [videoInfo, setVideoInfo] = useState<{ filename?: string; mimeType: string; isStreamable?: boolean }>({
|
||||
mimeType: 'video/mp4' // Default to MP4
|
||||
});
|
||||
|
||||
const { state, actions, ref } = useVideoPlayer({
|
||||
autoPlay,
|
||||
onPlay,
|
||||
onPause,
|
||||
onEnded,
|
||||
onError,
|
||||
});
|
||||
|
||||
// Combine refs
|
||||
React.useImperativeHandle(forwardedRef, () => ref.current!, [ref]);
|
||||
|
||||
const streamingUrl = videoApiService.getStreamingUrl(fileId);
|
||||
|
||||
// Fetch video info to determine MIME type and streamability
|
||||
useEffect(() => {
|
||||
const fetchVideoInfo = async () => {
|
||||
try {
|
||||
const info = await videoApiService.getVideoInfo(fileId);
|
||||
if (info.file_id) {
|
||||
// Extract filename from file_id or use a default pattern
|
||||
const filename = info.file_id.includes('.') ? info.file_id : `${info.file_id}.mp4`;
|
||||
const mimeType = getVideoMimeType(filename);
|
||||
setVideoInfo({
|
||||
filename,
|
||||
mimeType,
|
||||
isStreamable: info.is_streamable
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch video info, using default MIME type:', error);
|
||||
// Keep default MP4 MIME type, assume not streamable
|
||||
setVideoInfo(prev => ({ ...prev, isStreamable: false }));
|
||||
}
|
||||
};
|
||||
|
||||
fetchVideoInfo();
|
||||
}, [fileId]);
|
||||
|
||||
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!ref.current) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left;
|
||||
const percentage = clickX / rect.width;
|
||||
const newTime = percentage * state.duration;
|
||||
|
||||
actions.seek(newTime);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
actions.setVolume(parseFloat(e.target.value));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`video-player relative ${className}`} style={{ width, height }}>
|
||||
{/* Video Element */}
|
||||
<video
|
||||
ref={ref}
|
||||
className="w-full h-full bg-black"
|
||||
controls={!controls || state.error} // Use native controls if custom controls are disabled or there's an error
|
||||
style={{ width, height }}
|
||||
playsInline // Important for iOS compatibility
|
||||
preload="metadata" // Load metadata first for better UX
|
||||
>
|
||||
<source src={streamingUrl} type={videoInfo.mimeType} />
|
||||
{/* Fallback for MP4 if original format fails */}
|
||||
{videoInfo.mimeType !== 'video/mp4' && (
|
||||
<source src={streamingUrl} type="video/mp4" />
|
||||
)}
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{state.isLoading && (
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<div className="text-white text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<div className="text-lg">Loading video...</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Overlay */}
|
||||
{state.error && (
|
||||
<div className="absolute inset-0 bg-black bg-opacity-75 flex items-center justify-center">
|
||||
<div className="text-red-400 text-center">
|
||||
<div className="text-lg mb-2">Playback Error</div>
|
||||
<div className="text-sm">{state.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Controls */}
|
||||
{controls && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black to-transparent p-4">
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-3">
|
||||
<div
|
||||
className="w-full h-2 bg-gray-600 rounded cursor-pointer"
|
||||
onClick={handleSeek}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded"
|
||||
style={{
|
||||
width: `${state.duration > 0 ? (state.currentTime / state.duration) * 100 : 0}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control Bar */}
|
||||
<div className="flex items-center justify-between text-white">
|
||||
{/* Left Controls */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Play/Pause Button */}
|
||||
<button
|
||||
onClick={actions.togglePlay}
|
||||
className="p-2 hover:bg-white hover:bg-opacity-20 rounded"
|
||||
disabled={state.isLoading}
|
||||
>
|
||||
{state.isPlaying ? (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Skip Backward */}
|
||||
<button
|
||||
onClick={() => actions.skip(-10)}
|
||||
className="p-2 hover:bg-white hover:bg-opacity-20 rounded"
|
||||
title="Skip backward 10s"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M15.707 15.707a1 1 0 01-1.414 0l-5-5a1 1 0 010-1.414l5-5a1 1 0 111.414 1.414L11.414 9H17a1 1 0 110 2h-5.586l3.293 3.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Skip Forward */}
|
||||
<button
|
||||
onClick={() => actions.skip(10)}
|
||||
className="p-2 hover:bg-white hover:bg-opacity-20 rounded"
|
||||
title="Skip forward 10s"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0l5 5a1 1 0 010 1.414l-5 5a1 1 0 01-1.414-1.414L8.586 11H3a1 1 0 110-2h5.586L4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Time Display */}
|
||||
<div className="text-sm">
|
||||
{formatDuration(state.currentTime)} / {formatDuration(state.duration)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Controls */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Volume Control */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={actions.toggleMute}
|
||||
className="p-2 hover:bg-white hover:bg-opacity-20 rounded"
|
||||
>
|
||||
{state.isMuted || state.volume === 0 ? (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.617.776L4.83 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.83l3.553-3.776a1 1 0 011.617.776zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 11-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.617.776L4.83 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.83l3.553-3.776a1 1 0 011.617.776zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 11-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={state.volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-20 h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Button */}
|
||||
<button
|
||||
onClick={actions.toggleFullscreen}
|
||||
className="p-2 hover:bg-white hover:bg-opacity-20 rounded"
|
||||
>
|
||||
{state.isFullscreen ? (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 11-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 111.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 111.414-1.414L15 13.586V12a1 1 0 011-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 11-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 111.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 111.414-1.414L15 13.586V12a1 1 0 011-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
VideoPlayer.displayName = 'VideoPlayer';
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* VideoThumbnail Component
|
||||
*
|
||||
* A reusable component for displaying video thumbnails with loading states and error handling.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
import { thumbnailCache } from '../utils/thumbnailCache';
|
||||
import { type VideoThumbnailProps } from '../types';
|
||||
|
||||
export const VideoThumbnail: React.FC<VideoThumbnailProps> = ({
|
||||
fileId,
|
||||
timestamp = 0,
|
||||
width = 320,
|
||||
height = 240,
|
||||
alt = 'Video thumbnail',
|
||||
className = '',
|
||||
onClick,
|
||||
}) => {
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadThumbnail = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Check cache first
|
||||
const cachedUrl = thumbnailCache.get(fileId, timestamp, width, height);
|
||||
if (cachedUrl && isMounted) {
|
||||
setThumbnailUrl(cachedUrl);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch from API if not cached
|
||||
const blob = await videoApiService.getThumbnailBlob(fileId, {
|
||||
timestamp,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
if (isMounted) {
|
||||
// Store in cache and get URL
|
||||
const url = thumbnailCache.set(fileId, timestamp, width, height, blob);
|
||||
setThumbnailUrl(url);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load thumbnail');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadThumbnail();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
// Note: We don't revoke the URL here since it's managed by the cache
|
||||
};
|
||||
}, [fileId, timestamp, width, height]);
|
||||
|
||||
// Note: URL cleanup is now handled by the thumbnail cache
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick && !isLoading && !error) {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
const containerClasses = [
|
||||
'relative overflow-hidden bg-gray-200 rounded',
|
||||
onClick && !isLoading && !error ? 'cursor-pointer hover:opacity-80 transition-opacity' : '',
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={containerClasses}
|
||||
style={{ width, height }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-100 text-gray-500 text-sm p-2 text-center">
|
||||
<div>
|
||||
<svg className="w-8 h-8 mx-auto mb-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>Failed to load thumbnail</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail Image */}
|
||||
{thumbnailUrl && !isLoading && !error && (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setError('Failed to display thumbnail')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Play Overlay */}
|
||||
{onClick && !isLoading && !error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-black bg-opacity-30">
|
||||
<div className="bg-white bg-opacity-90 rounded-full p-3">
|
||||
<svg className="w-6 h-6 text-gray-800" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp Badge */}
|
||||
{timestamp > 0 && !isLoading && !error && (
|
||||
<div className="absolute bottom-2 right-2 bg-black bg-opacity-75 text-white text-xs px-2 py-1 rounded">
|
||||
{Math.floor(timestamp / 60)}:{(timestamp % 60).toString().padStart(2, '0')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Video Streaming Components - Index
|
||||
*
|
||||
* Centralized export for all video streaming components.
|
||||
* This makes it easy to import components from a single location.
|
||||
*/
|
||||
|
||||
export { VideoPlayer } from './VideoPlayer';
|
||||
export { VideoThumbnail } from './VideoThumbnail';
|
||||
export { VideoCard } from './VideoCard';
|
||||
export { VideoList } from './VideoList';
|
||||
export { VideoModal } from './VideoModal';
|
||||
export { Pagination, PageInfo } from './Pagination';
|
||||
export { ApiStatusIndicator } from './ApiStatusIndicator';
|
||||
export { VideoErrorBoundary, withVideoErrorBoundary } from './VideoErrorBoundary';
|
||||
export { PerformanceDashboard } from './PerformanceDashboard';
|
||||
export { VideoDebugger } from './VideoDebugger';
|
||||
|
||||
// Re-export component prop types for convenience
|
||||
export type {
|
||||
VideoPlayerProps,
|
||||
VideoThumbnailProps,
|
||||
VideoCardProps,
|
||||
VideoListProps,
|
||||
PaginationProps,
|
||||
} from '../types';
|
||||
Reference in New Issue
Block a user