Refactor video streaming feature and update dependencies
- Replaced npm ci with npm install in docker-compose for better package management. - Introduced remote component loading for the VideoStreamingPage with error handling. - Updated the title in index.html to "Experiments Dashboard" for clarity. - Added new video remote service configuration in docker-compose for improved integration. - Removed deprecated files and components related to the video streaming feature to streamline the codebase. - Updated package.json and package-lock.json to include @originjs/vite-plugin-federation for module federation support.
This commit is contained in:
@@ -7,7 +7,10 @@ import { ExperimentManagement } from './ExperimentManagement'
|
||||
import { DataEntry } from './DataEntry'
|
||||
import { VisionSystem } from './VisionSystem'
|
||||
import { Scheduling } from './Scheduling'
|
||||
import { VideoStreamingPage } from '../features/video-streaming'
|
||||
import React, { Suspense } from 'react'
|
||||
import { loadRemoteComponent } from '../lib/loadRemote'
|
||||
import { ErrorBoundary } from './ErrorBoundary'
|
||||
import { isFeatureEnabled } from '../lib/featureFlags'
|
||||
import { UserProfile } from './UserProfile'
|
||||
import { userManagement, type User } from '../lib/supabase'
|
||||
|
||||
@@ -154,6 +157,13 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
|
||||
}
|
||||
}
|
||||
|
||||
const LocalVideoPlaceholder = () => (<div className="p-6">Video module not enabled.</div>)
|
||||
const RemoteVideoLibrary = loadRemoteComponent(
|
||||
isFeatureEnabled('enableVideoModule'),
|
||||
() => import('videoRemote/App'),
|
||||
LocalVideoPlaceholder as any
|
||||
) as unknown as React.ComponentType
|
||||
|
||||
const renderCurrentView = () => {
|
||||
if (!user) return null
|
||||
|
||||
@@ -194,7 +204,13 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
|
||||
case 'scheduling':
|
||||
return <Scheduling user={user} currentRoute={currentRoute} />
|
||||
case 'video-library':
|
||||
return <VideoStreamingPage />
|
||||
return (
|
||||
<ErrorBoundary fallback={<div className="p-6">Failed to load video module. Please try again.</div>}>
|
||||
<Suspense fallback={<div className="p-6">Loading video module...</div>}>
|
||||
<RemoteVideoLibrary />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
case 'profile':
|
||||
return <UserProfile user={user} />
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Component, ReactNode } from 'react'
|
||||
|
||||
type Props = { children: ReactNode, fallback?: ReactNode }
|
||||
type State = { hasError: boolean }
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false }
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
componentDidCatch() {}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback ?? <div className="p-6">Something went wrong loading this section.</div>
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* VideoStreamingPage Component
|
||||
*
|
||||
* Main page component for the video streaming feature.
|
||||
* Demonstrates how to compose the modular components together.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { VideoList, VideoModal, ApiStatusIndicator, VideoErrorBoundary, PerformanceDashboard } from './components';
|
||||
import { type VideoFile, type VideoListFilters, type VideoListSortOptions } from './types';
|
||||
|
||||
export const VideoStreamingPage: React.FC = () => {
|
||||
const [selectedVideo, setSelectedVideo] = useState<VideoFile | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<VideoListFilters>({});
|
||||
const [sortOptions, setSortOptions] = useState<VideoListSortOptions>({
|
||||
field: 'created_at',
|
||||
direction: 'desc',
|
||||
});
|
||||
|
||||
// Available cameras for filtering (this could come from an API)
|
||||
const availableCameras = ['camera1', 'camera2', 'camera3']; // This should be fetched from your camera API
|
||||
|
||||
const handleVideoSelect = (video: VideoFile) => {
|
||||
setSelectedVideo(video);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedVideo(null);
|
||||
};
|
||||
|
||||
const handleCameraFilterChange = (cameraName: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
cameraName: cameraName === 'all' ? undefined : cameraName,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSortChange = (field: VideoListSortOptions['field'], direction: VideoListSortOptions['direction']) => {
|
||||
setSortOptions({ field, direction });
|
||||
};
|
||||
|
||||
const handleDateRangeChange = (start: string, end: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
dateRange: start && end ? { start, end } : undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<VideoErrorBoundary>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Video Library</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Browse and view recorded videos from your camera system
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<ApiStatusIndicator showDetails={false} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Controls */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-theme-sm">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Camera Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Camera
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={filters.cameraName || 'all'}
|
||||
onChange={(e) => handleCameraFilterChange(e.target.value)}
|
||||
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
|
||||
>
|
||||
<option value="all">All Cameras</option>
|
||||
{availableCameras.map(camera => (
|
||||
<option key={camera} value={camera}>
|
||||
{camera}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort Options */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Sort by
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<select
|
||||
value={sortOptions.field}
|
||||
onChange={(e) => handleSortChange(e.target.value as VideoListSortOptions['field'], sortOptions.direction)}
|
||||
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
|
||||
>
|
||||
<option value="created_at">Date Created</option>
|
||||
<option value="file_size_bytes">File Size</option>
|
||||
<option value="camera_name">Camera Name</option>
|
||||
<option value="filename">Filename</option>
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSortChange(sortOptions.field, sortOptions.direction === 'asc' ? 'desc' : 'asc')}
|
||||
className="px-3 py-2.5 border border-gray-300 rounded-lg bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
|
||||
title={`Sort ${sortOptions.direction === 'asc' ? 'Descending' : 'Ascending'}`}
|
||||
>
|
||||
{sortOptions.direction === 'asc' ? (
|
||||
<svg className="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h9m5-4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Range Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateRange?.start || ''}
|
||||
onChange={(e) => handleDateRangeChange(e.target.value, filters.dateRange?.end || '')}
|
||||
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateRange?.end || ''}
|
||||
onChange={(e) => handleDateRangeChange(filters.dateRange?.start || '', e.target.value)}
|
||||
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{(filters.cameraName || filters.dateRange) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => setFilters({})}
|
||||
className="inline-flex items-center px-4 py-2.5 text-sm font-medium transition rounded-lg border bg-white text-gray-700 border-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Clear All Filters
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Video List */}
|
||||
<VideoList
|
||||
filters={filters}
|
||||
sortOptions={sortOptions}
|
||||
onVideoSelect={handleVideoSelect}
|
||||
limit={24}
|
||||
/>
|
||||
|
||||
{/* Video Modal */}
|
||||
<VideoModal
|
||||
video={selectedVideo}
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
|
||||
{/* Performance Dashboard (development only) */}
|
||||
<PerformanceDashboard />
|
||||
</div>
|
||||
</VideoErrorBoundary>
|
||||
);
|
||||
};
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* 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 { 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>
|
||||
);
|
||||
};
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* VideoCard Component
|
||||
*
|
||||
* A reusable card component for displaying video information with thumbnail, metadata, and actions.
|
||||
*/
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,196 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
@@ -1,234 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Video Streaming Hooks - Index
|
||||
*
|
||||
* Centralized export for all video streaming hooks.
|
||||
* This makes it easy to import hooks from a single location.
|
||||
*/
|
||||
|
||||
export { useVideoList, type UseVideoListReturn } from './useVideoList';
|
||||
export { useVideoPlayer, type UseVideoPlayerReturn, type VideoPlayerState } from './useVideoPlayer';
|
||||
export { useVideoInfo, type UseVideoInfoReturn } from './useVideoInfo';
|
||||
|
||||
// Re-export types that are commonly used with hooks
|
||||
export type {
|
||||
VideoListFilters,
|
||||
VideoListSortOptions,
|
||||
} from '../types';
|
||||
@@ -1,191 +0,0 @@
|
||||
/**
|
||||
* useVideoInfo Hook
|
||||
*
|
||||
* Custom React hook for fetching and managing video metadata and streaming information.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
import {
|
||||
type VideoInfoResponse,
|
||||
type VideoStreamingInfo,
|
||||
type VideoError,
|
||||
type LoadingState
|
||||
} from '../types';
|
||||
|
||||
export interface UseVideoInfoReturn {
|
||||
videoInfo: VideoInfoResponse | null;
|
||||
streamingInfo: VideoStreamingInfo | null;
|
||||
loading: LoadingState;
|
||||
error: VideoError | null;
|
||||
refetch: () => Promise<void>;
|
||||
clearCache: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
interface UseVideoInfoOptions {
|
||||
autoFetch?: boolean;
|
||||
cacheKey?: string;
|
||||
}
|
||||
|
||||
export function useVideoInfo(
|
||||
fileId: string | null,
|
||||
options: UseVideoInfoOptions = {}
|
||||
) {
|
||||
const { autoFetch = true, cacheKey = 'default' } = options;
|
||||
|
||||
// State
|
||||
const [videoInfo, setVideoInfo] = useState<VideoInfoResponse | null>(null);
|
||||
const [streamingInfo, setStreamingInfo] = useState<VideoStreamingInfo | null>(null);
|
||||
const [loading, setLoading] = useState<LoadingState>('idle');
|
||||
const [error, setError] = useState<VideoError | null>(null);
|
||||
|
||||
// Refs for cleanup and caching
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const cacheRef = useRef<Map<string, {
|
||||
videoInfo: VideoInfoResponse;
|
||||
streamingInfo: VideoStreamingInfo;
|
||||
timestamp: number;
|
||||
}>>(new Map());
|
||||
|
||||
const CACHE_DURATION = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
/**
|
||||
* Check if cached data is still valid
|
||||
*/
|
||||
const isCacheValid = useCallback((timestamp: number): boolean => {
|
||||
return Date.now() - timestamp < CACHE_DURATION;
|
||||
}, [CACHE_DURATION]);
|
||||
|
||||
/**
|
||||
* Fetch video information
|
||||
*/
|
||||
const fetchVideoInfo = useCallback(async (id: string): Promise<void> => {
|
||||
// Cancel any ongoing request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
try {
|
||||
setLoading('loading');
|
||||
setError(null);
|
||||
|
||||
// Check cache first
|
||||
const key = `${cacheKey}_${id}`;
|
||||
const cached = cacheRef.current.get(key);
|
||||
|
||||
if (cached && isCacheValid(cached.timestamp)) {
|
||||
setVideoInfo(cached.videoInfo);
|
||||
setStreamingInfo(cached.streamingInfo);
|
||||
setLoading('success');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch both video info and streaming info in parallel
|
||||
const [videoInfoResponse, streamingInfoResponse] = await Promise.all([
|
||||
videoApiService.getVideoInfo(id),
|
||||
videoApiService.getStreamingInfo(id)
|
||||
]);
|
||||
|
||||
// Check if request was aborted
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update cache
|
||||
cacheRef.current.set(key, {
|
||||
videoInfo: videoInfoResponse,
|
||||
streamingInfo: streamingInfoResponse,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Update state
|
||||
setVideoInfo(videoInfoResponse);
|
||||
setStreamingInfo(streamingInfoResponse);
|
||||
setLoading('success');
|
||||
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoError: VideoError = err instanceof Error
|
||||
? { code: 'FETCH_ERROR', message: err.message, details: err }
|
||||
: { code: 'UNKNOWN_ERROR', message: 'An unknown error occurred' };
|
||||
|
||||
setError(videoError);
|
||||
setLoading('error');
|
||||
} finally {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, [cacheKey, isCacheValid]);
|
||||
|
||||
/**
|
||||
* Refetch video information
|
||||
*/
|
||||
const refetch = useCallback(async (): Promise<void> => {
|
||||
if (!fileId) return;
|
||||
await fetchVideoInfo(fileId);
|
||||
}, [fileId, fetchVideoInfo]);
|
||||
|
||||
/**
|
||||
* Clear cache for current video
|
||||
*/
|
||||
const clearCache = useCallback((): void => {
|
||||
if (!fileId) return;
|
||||
const key = `${cacheKey}_${fileId}`;
|
||||
cacheRef.current.delete(key);
|
||||
}, [fileId, cacheKey]);
|
||||
|
||||
/**
|
||||
* Reset state
|
||||
*/
|
||||
const reset = useCallback((): void => {
|
||||
setVideoInfo(null);
|
||||
setStreamingInfo(null);
|
||||
setLoading('idle');
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Auto-fetch when fileId changes
|
||||
useEffect(() => {
|
||||
if (fileId && autoFetch) {
|
||||
fetchVideoInfo(fileId);
|
||||
} else if (!fileId) {
|
||||
reset();
|
||||
}
|
||||
|
||||
// Cleanup on unmount or fileId change
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [fileId, autoFetch, fetchVideoInfo, reset]);
|
||||
|
||||
// Cleanup cache periodically
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
for (const [key, value] of cacheRef.current.entries()) {
|
||||
if (!isCacheValid(value.timestamp)) {
|
||||
cacheRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
}, CACHE_DURATION);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isCacheValid, CACHE_DURATION]);
|
||||
|
||||
return {
|
||||
videoInfo,
|
||||
streamingInfo,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
clearCache,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
/**
|
||||
* useVideoList Hook
|
||||
*
|
||||
* Custom React hook for managing video list state, fetching, filtering, and pagination.
|
||||
* Provides a clean interface for components to interact with video data.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
import {
|
||||
type VideoFile,
|
||||
type VideoListParams,
|
||||
type VideoError,
|
||||
type LoadingState,
|
||||
type VideoListFilters,
|
||||
type VideoListSortOptions
|
||||
} from '../types';
|
||||
|
||||
export interface UseVideoListReturn {
|
||||
videos: VideoFile[];
|
||||
totalCount: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
loading: LoadingState;
|
||||
error: VideoError | null;
|
||||
refetch: () => Promise<void>;
|
||||
loadMore: () => Promise<void>;
|
||||
hasMore: boolean;
|
||||
goToPage: (page: number) => Promise<void>;
|
||||
nextPage: () => Promise<void>;
|
||||
previousPage: () => Promise<void>;
|
||||
updateFilters: (filters: VideoListFilters) => void;
|
||||
updateSort: (sortOptions: VideoListSortOptions) => void;
|
||||
clearCache: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
import { filterVideos, sortVideos } from '../utils/videoUtils';
|
||||
|
||||
interface UseVideoListOptions {
|
||||
initialParams?: VideoListParams;
|
||||
autoFetch?: boolean;
|
||||
cacheKey?: string;
|
||||
}
|
||||
|
||||
export function useVideoList(options: UseVideoListOptions = {}) {
|
||||
const {
|
||||
initialParams = {},
|
||||
autoFetch = true,
|
||||
cacheKey = 'default'
|
||||
} = options;
|
||||
|
||||
// State
|
||||
const [videos, setVideos] = useState<VideoFile[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [loading, setLoading] = useState<LoadingState>('idle');
|
||||
const [error, setError] = useState<VideoError | null>(null);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [currentParams, setCurrentParams] = useState<VideoListParams>(initialParams);
|
||||
|
||||
// Refs for cleanup and caching
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Fetch videos from API
|
||||
*/
|
||||
const fetchVideos = useCallback(async (
|
||||
params: VideoListParams = initialParams,
|
||||
append: boolean = false
|
||||
): Promise<void> => {
|
||||
// Cancel any ongoing request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
try {
|
||||
setLoading('loading');
|
||||
setError(null);
|
||||
|
||||
// Fetch from API
|
||||
const response = await videoApiService.getVideos(params);
|
||||
|
||||
// Check if request was aborted
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update state
|
||||
setVideos(append ? prev => [...prev, ...response.videos] : response.videos);
|
||||
setTotalCount(response.total_count);
|
||||
|
||||
// Update pagination state
|
||||
if (response.page && response.total_pages) {
|
||||
setCurrentPage(response.page);
|
||||
setTotalPages(response.total_pages);
|
||||
setHasMore(response.has_next || false);
|
||||
} else {
|
||||
// Fallback for offset-based pagination
|
||||
setHasMore(response.videos.length === (params.limit || 50));
|
||||
}
|
||||
|
||||
setLoading('success');
|
||||
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoError: VideoError = err instanceof Error
|
||||
? { code: 'FETCH_ERROR', message: err.message, details: err }
|
||||
: { code: 'UNKNOWN_ERROR', message: 'An unknown error occurred' };
|
||||
|
||||
setError(videoError);
|
||||
setLoading('error');
|
||||
} finally {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, [initialParams]);
|
||||
|
||||
/**
|
||||
* Refetch videos with current page
|
||||
*/
|
||||
const refetch = useCallback(async (): Promise<void> => {
|
||||
const currentParams = {
|
||||
...initialParams,
|
||||
page: currentPage,
|
||||
limit: initialParams.limit || 20,
|
||||
};
|
||||
await fetchVideos(currentParams, false);
|
||||
}, [fetchVideos, initialParams, currentPage]);
|
||||
|
||||
/**
|
||||
* Load more videos (pagination) - for backward compatibility
|
||||
*/
|
||||
const loadMore = useCallback(async (): Promise<void> => {
|
||||
if (!hasMore || loading === 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
const offset = videos.length;
|
||||
const params = { ...initialParams, offset };
|
||||
await fetchVideos(params, true);
|
||||
}, [hasMore, loading, videos.length, initialParams, fetchVideos]);
|
||||
|
||||
/**
|
||||
* Go to specific page
|
||||
*/
|
||||
const goToPage = useCallback(async (page: number): Promise<void> => {
|
||||
if (page < 1 || (totalPages > 0 && page > totalPages) || loading === 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = { ...currentParams, page, limit: currentParams.limit || 20 };
|
||||
setCurrentParams(params);
|
||||
await fetchVideos(params, false);
|
||||
}, [currentParams, totalPages, loading, fetchVideos]);
|
||||
|
||||
/**
|
||||
* Go to next page
|
||||
*/
|
||||
const nextPage = useCallback(async (): Promise<void> => {
|
||||
if (currentPage < totalPages) {
|
||||
await goToPage(currentPage + 1);
|
||||
}
|
||||
}, [currentPage, totalPages, goToPage]);
|
||||
|
||||
/**
|
||||
* Go to previous page
|
||||
*/
|
||||
const previousPage = useCallback(async (): Promise<void> => {
|
||||
if (currentPage > 1) {
|
||||
await goToPage(currentPage - 1);
|
||||
}
|
||||
}, [currentPage, goToPage]);
|
||||
|
||||
/**
|
||||
* Update filters and refetch
|
||||
*/
|
||||
const updateFilters = useCallback((filters: VideoListFilters): void => {
|
||||
const newParams: VideoListParams = {
|
||||
...initialParams,
|
||||
camera_name: filters.cameraName,
|
||||
start_date: filters.dateRange?.start,
|
||||
end_date: filters.dateRange?.end,
|
||||
page: 1, // Reset to first page when filters change
|
||||
limit: initialParams.limit || 20,
|
||||
};
|
||||
|
||||
setCurrentParams(newParams);
|
||||
fetchVideos(newParams, false);
|
||||
}, [initialParams, fetchVideos]);
|
||||
|
||||
/**
|
||||
* Update sort options and refetch
|
||||
*/
|
||||
const updateSort = useCallback((sortOptions: VideoListSortOptions): void => {
|
||||
// Since the API doesn't support sorting, we'll sort locally
|
||||
setVideos(prev => sortVideos(prev, sortOptions.field, sortOptions.direction));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear cache (placeholder for future caching implementation)
|
||||
*/
|
||||
const clearCache = useCallback((): void => {
|
||||
// TODO: Implement cache clearing when caching is added
|
||||
console.log('Cache cleared');
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Reset to initial state
|
||||
*/
|
||||
const reset = useCallback((): void => {
|
||||
setVideos([]);
|
||||
setTotalCount(0);
|
||||
setCurrentPage(1);
|
||||
setTotalPages(0);
|
||||
setLoading('idle');
|
||||
setError(null);
|
||||
setHasMore(true);
|
||||
}, []);
|
||||
|
||||
// Auto-fetch on mount only
|
||||
useEffect(() => {
|
||||
if (autoFetch) {
|
||||
fetchVideos(initialParams, false);
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []); // Empty dependency array - only run once on mount
|
||||
|
||||
return {
|
||||
videos,
|
||||
totalCount,
|
||||
currentPage,
|
||||
totalPages,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
loadMore,
|
||||
hasMore,
|
||||
// Pagination methods
|
||||
goToPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
// Additional utility methods
|
||||
updateFilters,
|
||||
updateSort,
|
||||
clearCache,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
/**
|
||||
* useVideoPlayer Hook
|
||||
*
|
||||
* Custom React hook for managing video player state and controls.
|
||||
* Provides a comprehensive interface for video playback functionality.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
// Video player state interface
|
||||
export interface VideoPlayerState {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
isFullscreen: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface UseVideoPlayerReturn {
|
||||
state: VideoPlayerState;
|
||||
actions: {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
togglePlay: () => void;
|
||||
seek: (time: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
toggleMute: () => void;
|
||||
toggleFullscreen: () => void;
|
||||
skip: (seconds: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
ref: React.RefObject<HTMLVideoElement>;
|
||||
}
|
||||
|
||||
interface UseVideoPlayerOptions {
|
||||
autoPlay?: boolean;
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
volume?: number;
|
||||
onPlay?: () => void;
|
||||
onPause?: () => void;
|
||||
onEnded?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
onTimeUpdate?: (currentTime: number) => void;
|
||||
onDurationChange?: (duration: number) => void;
|
||||
}
|
||||
|
||||
export function useVideoPlayer(options: UseVideoPlayerOptions = {}) {
|
||||
const {
|
||||
autoPlay = false,
|
||||
loop = false,
|
||||
muted = false,
|
||||
volume = 1,
|
||||
onPlay,
|
||||
onPause,
|
||||
onEnded,
|
||||
onError,
|
||||
onTimeUpdate,
|
||||
onDurationChange,
|
||||
} = options;
|
||||
|
||||
// Video element ref
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Player state
|
||||
const [state, setState] = useState<VideoPlayerState>({
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: volume,
|
||||
isMuted: muted,
|
||||
isFullscreen: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Update state helper
|
||||
*/
|
||||
const updateState = useCallback((updates: Partial<VideoPlayerState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Play video
|
||||
*/
|
||||
const play = useCallback(async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
try {
|
||||
updateState({ isLoading: true, error: null });
|
||||
await video.play();
|
||||
updateState({ isPlaying: true, isLoading: false });
|
||||
onPlay?.();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to play video';
|
||||
updateState({ isLoading: false, error: errorMessage });
|
||||
onError?.(errorMessage);
|
||||
}
|
||||
}, [updateState, onPlay, onError]);
|
||||
|
||||
/**
|
||||
* Pause video
|
||||
*/
|
||||
const pause = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.pause();
|
||||
updateState({ isPlaying: false });
|
||||
onPause?.();
|
||||
}, [updateState, onPause]);
|
||||
|
||||
/**
|
||||
* Toggle play/pause
|
||||
*/
|
||||
const togglePlay = useCallback(() => {
|
||||
if (state.isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
}, [state.isPlaying, play, pause]);
|
||||
|
||||
/**
|
||||
* Seek to specific time
|
||||
*/
|
||||
const seek = useCallback((time: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.currentTime = Math.max(0, Math.min(time, video.duration || 0));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Set volume (0-1)
|
||||
*/
|
||||
const setVolume = useCallback((newVolume: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const clampedVolume = Math.max(0, Math.min(1, newVolume));
|
||||
video.volume = clampedVolume;
|
||||
updateState({ volume: clampedVolume });
|
||||
}, [updateState]);
|
||||
|
||||
/**
|
||||
* Toggle mute
|
||||
*/
|
||||
const toggleMute = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.muted = !video.muted;
|
||||
updateState({ isMuted: video.muted });
|
||||
}, [updateState]);
|
||||
|
||||
/**
|
||||
* Enter/exit fullscreen
|
||||
*/
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
await video.requestFullscreen();
|
||||
updateState({ isFullscreen: true });
|
||||
} else {
|
||||
await document.exitFullscreen();
|
||||
updateState({ isFullscreen: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Fullscreen not supported or failed:', error);
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
/**
|
||||
* Skip forward/backward
|
||||
*/
|
||||
const skip = useCallback((seconds: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const newTime = video.currentTime + seconds;
|
||||
seek(newTime);
|
||||
}, [seek]);
|
||||
|
||||
/**
|
||||
* Set playback rate
|
||||
*/
|
||||
const setPlaybackRate = useCallback((rate: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.playbackRate = Math.max(0.25, Math.min(4, rate));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Reset video to beginning
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.currentTime = 0;
|
||||
pause();
|
||||
}, [pause]);
|
||||
|
||||
// Event handlers
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleLoadStart = () => {
|
||||
updateState({ isLoading: true, error: null });
|
||||
|
||||
// Set a timeout to detect if loading takes too long
|
||||
const loadTimeout = setTimeout(() => {
|
||||
if (video && video.readyState < 2) { // HAVE_CURRENT_DATA
|
||||
updateState({
|
||||
isLoading: false,
|
||||
error: 'Video loading timeout. The video may not be accessible or there may be a network issue.'
|
||||
});
|
||||
}
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
// Store timeout ID to clear it later
|
||||
(video as any)._loadTimeout = loadTimeout;
|
||||
};
|
||||
|
||||
const handleLoadedData = () => {
|
||||
updateState({ isLoading: false });
|
||||
|
||||
// Clear the loading timeout
|
||||
if ((video as any)._loadTimeout) {
|
||||
clearTimeout((video as any)._loadTimeout);
|
||||
(video as any)._loadTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
updateState({ currentTime: video.currentTime });
|
||||
onTimeUpdate?.(video.currentTime);
|
||||
};
|
||||
|
||||
const handleDurationChange = () => {
|
||||
updateState({ duration: video.duration });
|
||||
onDurationChange?.(video.duration);
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
updateState({ isPlaying: true });
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
updateState({ isPlaying: false });
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
updateState({ isPlaying: false });
|
||||
onEnded?.();
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
const errorMessage = video.error?.message || 'Video playback error';
|
||||
updateState({ isLoading: false, error: errorMessage, isPlaying: false });
|
||||
onError?.(errorMessage);
|
||||
|
||||
// Clear the loading timeout
|
||||
if ((video as any)._loadTimeout) {
|
||||
clearTimeout((video as any)._loadTimeout);
|
||||
(video as any)._loadTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = () => {
|
||||
updateState({
|
||||
volume: video.volume,
|
||||
isMuted: video.muted
|
||||
});
|
||||
};
|
||||
|
||||
const handleFullscreenChange = () => {
|
||||
updateState({ isFullscreen: !!document.fullscreenElement });
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
video.addEventListener('loadstart', handleLoadStart);
|
||||
video.addEventListener('loadeddata', handleLoadedData);
|
||||
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||
video.addEventListener('durationchange', handleDurationChange);
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener('ended', handleEnded);
|
||||
video.addEventListener('error', handleError);
|
||||
video.addEventListener('volumechange', handleVolumeChange);
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
|
||||
// Set initial properties
|
||||
video.autoplay = autoPlay;
|
||||
video.loop = loop;
|
||||
video.muted = muted;
|
||||
video.volume = volume;
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
video.removeEventListener('loadstart', handleLoadStart);
|
||||
video.removeEventListener('loadeddata', handleLoadedData);
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('durationchange', handleDurationChange);
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener('ended', handleEnded);
|
||||
video.removeEventListener('error', handleError);
|
||||
video.removeEventListener('volumechange', handleVolumeChange);
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
};
|
||||
}, [autoPlay, loop, muted, volume, updateState, onTimeUpdate, onDurationChange, onEnded, onError]);
|
||||
|
||||
return {
|
||||
state,
|
||||
actions: {
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
seek,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
toggleFullscreen,
|
||||
skip,
|
||||
setPlaybackRate,
|
||||
reset,
|
||||
},
|
||||
ref: videoRef,
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Video Streaming Feature - Main Export
|
||||
*
|
||||
* This is the main entry point for the video streaming feature.
|
||||
* It exports all the public APIs that other parts of the application can use.
|
||||
*/
|
||||
|
||||
// Components
|
||||
export * from './components';
|
||||
|
||||
// Hooks
|
||||
export * from './hooks';
|
||||
|
||||
// Services
|
||||
export { videoApiService, VideoApiService } from './services/videoApi';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// Utils
|
||||
export * from './utils/videoUtils';
|
||||
|
||||
// Main feature component
|
||||
export { VideoStreamingPage } from './VideoStreamingPage';
|
||||
@@ -1,282 +0,0 @@
|
||||
/**
|
||||
* Video Streaming API Service
|
||||
*
|
||||
* This service handles all API interactions for the video streaming feature.
|
||||
* It provides a clean interface for components to interact with the video API
|
||||
* without knowing the implementation details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type VideoListResponse,
|
||||
type VideoInfoResponse,
|
||||
type VideoStreamingInfo,
|
||||
type VideoListParams,
|
||||
type ThumbnailParams,
|
||||
} from '../types';
|
||||
import { performanceMonitor } from '../utils/performanceMonitor';
|
||||
|
||||
// Configuration - Prefer env var; default to relative "/api" so Vite proxy can route to the API container
|
||||
const API_BASE_URL = import.meta.env.VITE_VISION_API_URL || '/api';
|
||||
|
||||
/**
|
||||
* Custom error class for video API errors
|
||||
*/
|
||||
export class VideoApiError extends Error {
|
||||
public code: string;
|
||||
public details?: unknown;
|
||||
|
||||
constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
details?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'VideoApiError';
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to handle API responses
|
||||
*/
|
||||
async function handleApiResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new VideoApiError(
|
||||
`HTTP_${response.status}`,
|
||||
`API request failed: ${response.statusText}`,
|
||||
{ status: response.status, body: errorText }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
throw new VideoApiError(
|
||||
'INVALID_RESPONSE',
|
||||
'Expected JSON response from API'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build query string from parameters
|
||||
*/
|
||||
function buildQueryString(params: VideoListParams | ThumbnailParams): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
return searchParams.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Video API Service Class
|
||||
*/
|
||||
export class VideoApiService {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string = API_BASE_URL) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total count of videos with filters (without pagination)
|
||||
*/
|
||||
private totalCountCache = new Map<string, { count: number; timestamp: number }>();
|
||||
private readonly CACHE_DURATION = 30000; // 30 seconds cache
|
||||
|
||||
private async getTotalCount(params: Omit<VideoListParams, 'limit' | 'offset' | 'page'>): Promise<number> {
|
||||
// Create cache key from params
|
||||
const cacheKey = JSON.stringify(params);
|
||||
const cached = this.totalCountCache.get(cacheKey);
|
||||
|
||||
// Return cached result if still valid
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_DURATION) {
|
||||
return cached.count;
|
||||
}
|
||||
|
||||
const queryString = buildQueryString({ ...params, limit: 1000 }); // Use high limit to get accurate total
|
||||
const url = `${this.baseUrl}/videos/${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<VideoListResponse>(response);
|
||||
const count = result.videos.length; // Since backend returns wrong total_count, count the actual videos
|
||||
|
||||
// Cache the result
|
||||
this.totalCountCache.set(cacheKey, { count, timestamp: Date.now() });
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of videos with optional filtering
|
||||
*/
|
||||
async getVideos(params: VideoListParams = {}): Promise<VideoListResponse> {
|
||||
return performanceMonitor.trackOperation('get_videos', async () => {
|
||||
// Convert page-based params to offset-based for API compatibility
|
||||
const apiParams = { ...params };
|
||||
|
||||
// If page is provided, convert to offset
|
||||
if (params.page && params.limit) {
|
||||
apiParams.offset = (params.page - 1) * params.limit;
|
||||
delete apiParams.page; // Remove page param as API expects offset
|
||||
}
|
||||
|
||||
const queryString = buildQueryString(apiParams);
|
||||
const url = `${this.baseUrl}/videos/${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<VideoListResponse>(response);
|
||||
|
||||
// Add pagination metadata if page was requested
|
||||
if (params.page && params.limit) {
|
||||
// Get accurate total count by calling without pagination
|
||||
const totalCount = await this.getTotalCount({
|
||||
camera_name: params.camera_name,
|
||||
start_date: params.start_date,
|
||||
end_date: params.end_date,
|
||||
include_metadata: params.include_metadata,
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(totalCount / params.limit);
|
||||
return {
|
||||
...result,
|
||||
total_count: totalCount, // Use accurate total count
|
||||
page: params.page,
|
||||
total_pages: totalPages,
|
||||
has_next: params.page < totalPages,
|
||||
has_previous: params.page > 1,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific video
|
||||
*/
|
||||
async getVideoInfo(fileId: string): Promise<VideoInfoResponse> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/videos/${fileId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
return await handleApiResponse<VideoInfoResponse>(response);
|
||||
} catch (error) {
|
||||
if (error instanceof VideoApiError) {
|
||||
throw error;
|
||||
}
|
||||
throw new VideoApiError(
|
||||
'NETWORK_ERROR',
|
||||
`Failed to fetch video info for ${fileId}`,
|
||||
{ originalError: error, fileId }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streaming information for a video
|
||||
*/
|
||||
async getStreamingInfo(fileId: string): Promise<VideoStreamingInfo> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/videos/${fileId}/info`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
return await handleApiResponse<VideoStreamingInfo>(response);
|
||||
} catch (error) {
|
||||
if (error instanceof VideoApiError) {
|
||||
throw error;
|
||||
}
|
||||
throw new VideoApiError(
|
||||
'NETWORK_ERROR',
|
||||
`Failed to fetch streaming info for ${fileId}`,
|
||||
{ originalError: error, fileId }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the streaming URL for a video
|
||||
*/
|
||||
getStreamingUrl(fileId: string): string {
|
||||
return `${this.baseUrl}/videos/${fileId}/stream`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the thumbnail URL for a video
|
||||
*/
|
||||
getThumbnailUrl(fileId: string, params: ThumbnailParams = {}): string {
|
||||
const queryString = buildQueryString(params);
|
||||
return `${this.baseUrl}/videos/${fileId}/thumbnail${queryString ? `?${queryString}` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download thumbnail as blob
|
||||
*/
|
||||
async getThumbnailBlob(fileId: string, params: ThumbnailParams = {}): Promise<Blob> {
|
||||
return performanceMonitor.trackOperation('get_thumbnail', async () => {
|
||||
const url = this.getThumbnailUrl(fileId, params);
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new VideoApiError(
|
||||
`HTTP_${response.status}`,
|
||||
`Failed to fetch thumbnail: ${response.statusText}`,
|
||||
{ status: response.status, fileId }
|
||||
);
|
||||
}
|
||||
|
||||
return await response.blob();
|
||||
}, { fileId, params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the video API is available
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/videos/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export a default instance
|
||||
export const videoApiService = new VideoApiService();
|
||||
|
||||
// Export utility functions
|
||||
export { buildQueryString, handleApiResponse };
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* Video Streaming Feature Types
|
||||
*
|
||||
* This file contains all TypeScript type definitions for the video streaming feature.
|
||||
* Following the modular architecture pattern where types are centralized and reusable.
|
||||
* Updated to fix import issues.
|
||||
*/
|
||||
|
||||
// Base video information from the API
|
||||
export interface VideoFile {
|
||||
file_id: string;
|
||||
camera_name: string;
|
||||
filename: string;
|
||||
file_size_bytes: number;
|
||||
format: string;
|
||||
status: 'completed' | 'processing' | 'failed';
|
||||
created_at: string;
|
||||
is_streamable: boolean;
|
||||
needs_conversion: boolean;
|
||||
}
|
||||
|
||||
// Extended video information with metadata
|
||||
export interface VideoWithMetadata extends VideoFile {
|
||||
metadata?: {
|
||||
duration_seconds: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
codec: string;
|
||||
aspect_ratio: number;
|
||||
};
|
||||
}
|
||||
|
||||
// API response for video list
|
||||
export interface VideoListResponse {
|
||||
videos: VideoFile[];
|
||||
total_count: number;
|
||||
page?: number;
|
||||
total_pages?: number;
|
||||
has_next?: boolean;
|
||||
has_previous?: boolean;
|
||||
}
|
||||
|
||||
// API response for video info
|
||||
export interface VideoInfoResponse {
|
||||
file_id: string;
|
||||
metadata: {
|
||||
duration_seconds: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
codec: string;
|
||||
aspect_ratio: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Streaming technical information
|
||||
export interface VideoStreamingInfo {
|
||||
file_id: string;
|
||||
file_size_bytes: number;
|
||||
content_type: string;
|
||||
supports_range_requests: boolean;
|
||||
chunk_size_bytes: number;
|
||||
}
|
||||
|
||||
// Query parameters for video list API
|
||||
export interface VideoListParams {
|
||||
camera_name?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
limit?: number;
|
||||
include_metadata?: boolean;
|
||||
page?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// Thumbnail request parameters
|
||||
export interface ThumbnailParams {
|
||||
timestamp?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// Video player state is now defined in useVideoPlayer hook to avoid circular imports
|
||||
|
||||
// Video list filter and sort options
|
||||
export interface VideoListFilters {
|
||||
cameraName?: string;
|
||||
dateRange?: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
status?: VideoFile['status'];
|
||||
format?: string;
|
||||
}
|
||||
|
||||
export interface VideoListSortOptions {
|
||||
field: 'created_at' | 'file_size_bytes' | 'camera_name' | 'filename';
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// Component props interfaces
|
||||
export interface VideoPlayerProps {
|
||||
fileId: string;
|
||||
autoPlay?: boolean;
|
||||
controls?: boolean;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
className?: string;
|
||||
onPlay?: () => void;
|
||||
onPause?: () => void;
|
||||
onEnded?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export interface VideoCardProps {
|
||||
video: VideoFile;
|
||||
onClick?: (video: VideoFile) => void;
|
||||
showMetadata?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface VideoListProps {
|
||||
filters?: VideoListFilters;
|
||||
sortOptions?: VideoListSortOptions;
|
||||
limit?: number;
|
||||
onVideoSelect?: (video: VideoFile) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Pagination component props
|
||||
export interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
showFirstLast?: boolean;
|
||||
showPrevNext?: boolean;
|
||||
maxVisiblePages?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface VideoThumbnailProps {
|
||||
fileId: string;
|
||||
timestamp?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
// Error types
|
||||
export interface VideoError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
// Loading states
|
||||
export type LoadingState = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
// Hook return types are exported from their respective hook files
|
||||
// This avoids circular import issues
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Video Streaming Utils - Index
|
||||
*
|
||||
* Centralized export for all video streaming utilities.
|
||||
*/
|
||||
|
||||
export * from './videoUtils';
|
||||
export * from './thumbnailCache';
|
||||
export * from './performanceMonitor';
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* Performance Monitor for Video Streaming
|
||||
*
|
||||
* Tracks and reports performance metrics for video streaming operations.
|
||||
*/
|
||||
|
||||
interface PerformanceMetric {
|
||||
operation: string;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
duration?: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface PerformanceStats {
|
||||
totalOperations: number;
|
||||
successfulOperations: number;
|
||||
failedOperations: number;
|
||||
averageDuration: number;
|
||||
minDuration: number;
|
||||
maxDuration: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export class PerformanceMonitor {
|
||||
private metrics: PerformanceMetric[] = [];
|
||||
private maxMetrics: number;
|
||||
|
||||
constructor(maxMetrics: number = 1000) {
|
||||
this.maxMetrics = maxMetrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tracking an operation
|
||||
*/
|
||||
startOperation(operation: string, metadata?: Record<string, any>): string {
|
||||
const id = `${operation}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const metric: PerformanceMetric = {
|
||||
operation,
|
||||
startTime: performance.now(),
|
||||
success: false,
|
||||
metadata,
|
||||
};
|
||||
|
||||
this.metrics.push(metric);
|
||||
|
||||
// Keep only the most recent metrics
|
||||
if (this.metrics.length > this.maxMetrics) {
|
||||
this.metrics.shift();
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* End tracking an operation
|
||||
*/
|
||||
endOperation(operation: string, success: boolean, error?: string): void {
|
||||
const metric = this.metrics
|
||||
.slice()
|
||||
.reverse()
|
||||
.find(m => m.operation === operation && !m.endTime);
|
||||
|
||||
if (metric) {
|
||||
metric.endTime = performance.now();
|
||||
metric.duration = metric.endTime - metric.startTime;
|
||||
metric.success = success;
|
||||
metric.error = error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a complete operation
|
||||
*/
|
||||
async trackOperation<T>(
|
||||
operation: string,
|
||||
fn: () => Promise<T>,
|
||||
metadata?: Record<string, any>
|
||||
): Promise<T> {
|
||||
this.startOperation(operation, metadata);
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
this.endOperation(operation, true);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.endOperation(operation, false, error instanceof Error ? error.message : 'Unknown error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance statistics for a specific operation
|
||||
*/
|
||||
getStats(operation?: string): PerformanceStats {
|
||||
const filteredMetrics = operation
|
||||
? this.metrics.filter(m => m.operation === operation && m.duration !== undefined)
|
||||
: this.metrics.filter(m => m.duration !== undefined);
|
||||
|
||||
if (filteredMetrics.length === 0) {
|
||||
return {
|
||||
totalOperations: 0,
|
||||
successfulOperations: 0,
|
||||
failedOperations: 0,
|
||||
averageDuration: 0,
|
||||
minDuration: 0,
|
||||
maxDuration: 0,
|
||||
successRate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const durations = filteredMetrics.map(m => m.duration!);
|
||||
const successfulOps = filteredMetrics.filter(m => m.success).length;
|
||||
const failedOps = filteredMetrics.length - successfulOps;
|
||||
|
||||
return {
|
||||
totalOperations: filteredMetrics.length,
|
||||
successfulOperations: successfulOps,
|
||||
failedOperations: failedOps,
|
||||
averageDuration: durations.reduce((sum, d) => sum + d, 0) / durations.length,
|
||||
minDuration: Math.min(...durations),
|
||||
maxDuration: Math.max(...durations),
|
||||
successRate: successfulOps / filteredMetrics.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent metrics
|
||||
*/
|
||||
getRecentMetrics(count: number = 10): PerformanceMetric[] {
|
||||
return this.metrics
|
||||
.filter(m => m.duration !== undefined)
|
||||
.slice(-count)
|
||||
.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all metrics
|
||||
*/
|
||||
clear(): void {
|
||||
this.metrics = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Export metrics for analysis
|
||||
*/
|
||||
exportMetrics(): PerformanceMetric[] {
|
||||
return [...this.metrics];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a performance report
|
||||
*/
|
||||
getReport(): string {
|
||||
const operations = [...new Set(this.metrics.map(m => m.operation))];
|
||||
let report = 'Video Streaming Performance Report\n';
|
||||
report += '=====================================\n\n';
|
||||
|
||||
for (const operation of operations) {
|
||||
const stats = this.getStats(operation);
|
||||
report += `${operation}:\n`;
|
||||
report += ` Total Operations: ${stats.totalOperations}\n`;
|
||||
report += ` Success Rate: ${(stats.successRate * 100).toFixed(1)}%\n`;
|
||||
report += ` Average Duration: ${stats.averageDuration.toFixed(2)}ms\n`;
|
||||
report += ` Min Duration: ${stats.minDuration.toFixed(2)}ms\n`;
|
||||
report += ` Max Duration: ${stats.maxDuration.toFixed(2)}ms\n\n`;
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const performanceMonitor = new PerformanceMonitor();
|
||||
|
||||
// Helper functions for common operations
|
||||
export const trackVideoLoad = (fileId: string) =>
|
||||
performanceMonitor.startOperation('video_load', { fileId });
|
||||
|
||||
export const trackThumbnailLoad = (fileId: string, width: number, height: number) =>
|
||||
performanceMonitor.startOperation('thumbnail_load', { fileId, width, height });
|
||||
|
||||
export const trackApiCall = (endpoint: string) =>
|
||||
performanceMonitor.startOperation('api_call', { endpoint });
|
||||
|
||||
// Log performance stats periodically in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
setInterval(() => {
|
||||
const stats = performanceMonitor.getStats();
|
||||
if (stats.totalOperations > 0) {
|
||||
console.log('Video Streaming Performance:', stats);
|
||||
}
|
||||
}, 60000); // Every minute
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
/**
|
||||
* Thumbnail Cache Utility
|
||||
*
|
||||
* Provides efficient caching for video thumbnails to improve performance
|
||||
* and reduce API calls.
|
||||
*/
|
||||
|
||||
interface CacheEntry {
|
||||
blob: Blob;
|
||||
url: string;
|
||||
timestamp: number;
|
||||
accessCount: number;
|
||||
lastAccessed: number;
|
||||
}
|
||||
|
||||
interface ThumbnailCacheOptions {
|
||||
maxSize: number; // Maximum number of cached thumbnails
|
||||
maxAge: number; // Maximum age in milliseconds
|
||||
maxMemory: number; // Maximum memory usage in bytes
|
||||
}
|
||||
|
||||
export class ThumbnailCache {
|
||||
private cache = new Map<string, CacheEntry>();
|
||||
private options: ThumbnailCacheOptions;
|
||||
|
||||
constructor(options: Partial<ThumbnailCacheOptions> = {}) {
|
||||
this.options = {
|
||||
maxSize: options.maxSize || 100,
|
||||
maxAge: options.maxAge || 30 * 60 * 1000, // 30 minutes
|
||||
maxMemory: options.maxMemory || 50 * 1024 * 1024, // 50MB
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for a thumbnail
|
||||
*/
|
||||
private generateKey(fileId: string, timestamp: number, width: number, height: number): string {
|
||||
return `${fileId}_${timestamp}_${width}x${height}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail from cache
|
||||
*/
|
||||
get(fileId: string, timestamp: number, width: number, height: number): string | null {
|
||||
const key = this.generateKey(fileId, timestamp, width, height);
|
||||
const entry = this.cache.get(key);
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if entry is expired
|
||||
const now = Date.now();
|
||||
if (now - entry.timestamp > this.options.maxAge) {
|
||||
this.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update access statistics
|
||||
entry.accessCount++;
|
||||
entry.lastAccessed = now;
|
||||
|
||||
return entry.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store thumbnail in cache
|
||||
*/
|
||||
set(fileId: string, timestamp: number, width: number, height: number, blob: Blob): string {
|
||||
const key = this.generateKey(fileId, timestamp, width, height);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const now = Date.now();
|
||||
|
||||
// Clean up existing entry if it exists
|
||||
const existingEntry = this.cache.get(key);
|
||||
if (existingEntry) {
|
||||
URL.revokeObjectURL(existingEntry.url);
|
||||
}
|
||||
|
||||
// Create new entry
|
||||
const entry: CacheEntry = {
|
||||
blob,
|
||||
url,
|
||||
timestamp: now,
|
||||
accessCount: 1,
|
||||
lastAccessed: now,
|
||||
};
|
||||
|
||||
this.cache.set(key, entry);
|
||||
|
||||
// Cleanup if necessary
|
||||
this.cleanup();
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete specific entry from cache
|
||||
*/
|
||||
delete(key: string): boolean {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry) {
|
||||
URL.revokeObjectURL(entry.url);
|
||||
return this.cache.delete(key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached thumbnails
|
||||
*/
|
||||
clear(): void {
|
||||
for (const entry of this.cache.values()) {
|
||||
URL.revokeObjectURL(entry.url);
|
||||
}
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
getStats() {
|
||||
const entries = Array.from(this.cache.values());
|
||||
const totalSize = entries.reduce((sum, entry) => sum + entry.blob.size, 0);
|
||||
const totalAccess = entries.reduce((sum, entry) => sum + entry.accessCount, 0);
|
||||
|
||||
return {
|
||||
size: this.cache.size,
|
||||
totalMemory: totalSize,
|
||||
totalAccess,
|
||||
averageSize: entries.length > 0 ? totalSize / entries.length : 0,
|
||||
averageAccess: entries.length > 0 ? totalAccess / entries.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired and least used entries
|
||||
*/
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
const entries = Array.from(this.cache.entries());
|
||||
|
||||
// Remove expired entries
|
||||
for (const [key, entry] of entries) {
|
||||
if (now - entry.timestamp > this.options.maxAge) {
|
||||
this.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need to remove more entries
|
||||
if (this.cache.size <= this.options.maxSize) {
|
||||
const stats = this.getStats();
|
||||
if (stats.totalMemory <= this.options.maxMemory) {
|
||||
return; // No cleanup needed
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by access frequency and recency (LRU with access count)
|
||||
const sortedEntries = Array.from(this.cache.entries()).sort(([, a], [, b]) => {
|
||||
// Prioritize by access count, then by last accessed time
|
||||
const scoreA = a.accessCount * 1000 + (a.lastAccessed / 1000);
|
||||
const scoreB = b.accessCount * 1000 + (b.lastAccessed / 1000);
|
||||
return scoreA - scoreB; // Ascending order (least valuable first)
|
||||
});
|
||||
|
||||
// Remove least valuable entries until we're under limits
|
||||
while (
|
||||
(this.cache.size > this.options.maxSize ||
|
||||
this.getStats().totalMemory > this.options.maxMemory) &&
|
||||
sortedEntries.length > 0
|
||||
) {
|
||||
const [key] = sortedEntries.shift()!;
|
||||
this.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload thumbnails for a list of videos
|
||||
*/
|
||||
async preload(
|
||||
videos: Array<{ file_id: string }>,
|
||||
getThumbnailBlob: (fileId: string, params: any) => Promise<Blob>,
|
||||
options: { timestamp?: number; width?: number; height?: number } = {}
|
||||
): Promise<void> {
|
||||
const { timestamp = 1.0, width = 320, height = 240 } = options;
|
||||
|
||||
const promises = videos.slice(0, 10).map(async (video) => {
|
||||
const key = this.generateKey(video.file_id, timestamp, width, height);
|
||||
|
||||
// Skip if already cached
|
||||
if (this.cache.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await getThumbnailBlob(video.file_id, {
|
||||
timestamp,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
this.set(video.file_id, timestamp, width, height, blob);
|
||||
} catch (error) {
|
||||
// Silently fail for preloading
|
||||
console.warn(`Failed to preload thumbnail for ${video.file_id}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const thumbnailCache = new ThumbnailCache({
|
||||
maxSize: 100,
|
||||
maxAge: 30 * 60 * 1000, // 30 minutes
|
||||
maxMemory: 50 * 1024 * 1024, // 50MB
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
thumbnailCache.clear();
|
||||
});
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
/**
|
||||
* Video Streaming Utilities
|
||||
*
|
||||
* Pure utility functions for video operations, formatting, and data processing.
|
||||
* These functions have no side effects and can be easily tested.
|
||||
* Enhanced with MP4 format support and improved file handling.
|
||||
*/
|
||||
|
||||
import { type VideoFile, type VideoWithMetadata } from '../types';
|
||||
import {
|
||||
isVideoFile as isVideoFileUtil,
|
||||
getVideoMimeType as getVideoMimeTypeUtil,
|
||||
getVideoFormat,
|
||||
isWebCompatibleFormat,
|
||||
getFormatDisplayName as getFormatDisplayNameUtil
|
||||
} from '../../../utils/videoFileUtils';
|
||||
|
||||
/**
|
||||
* Format file size in bytes to human readable format
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in seconds to human readable format (HH:MM:SS or MM:SS)
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (isNaN(seconds) || seconds < 0) return '00:00';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date string to human readable format
|
||||
*/
|
||||
export function formatVideoDate(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relative time string (e.g., "2 hours ago")
|
||||
*/
|
||||
export function getRelativeTime(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMinutes < 1) return 'Just now';
|
||||
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||
|
||||
return formatVideoDate(dateString);
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a filename is a video file (supports MP4, AVI, and other formats)
|
||||
*/
|
||||
export function isVideoFile(filename: string): boolean {
|
||||
return isVideoFileUtil(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type for video file based on filename
|
||||
*/
|
||||
export function getVideoMimeType(filename: string): string {
|
||||
return getVideoMimeTypeUtil(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract camera name from filename if not provided
|
||||
*/
|
||||
export function extractCameraName(filename: string): string {
|
||||
// Try to extract camera name from filename pattern like "camera1_recording_20250804_143022.avi"
|
||||
const match = filename.match(/^([^_]+)_/);
|
||||
return match ? match[1] : 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video format display name
|
||||
*/
|
||||
export function getFormatDisplayName(format: string): string {
|
||||
return getFormatDisplayNameUtil(format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if video format is web-compatible
|
||||
*/
|
||||
export function isWebCompatible(format: string): boolean {
|
||||
return isWebCompatibleFormat(format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status badge color class
|
||||
*/
|
||||
export function getStatusBadgeClass(status: VideoFile['status']): string {
|
||||
const statusClasses = {
|
||||
'completed': 'bg-green-100 text-green-800',
|
||||
'processing': 'bg-yellow-100 text-yellow-800',
|
||||
'failed': 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
return statusClasses[status] || 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video resolution display string
|
||||
*/
|
||||
export function getResolutionString(width?: number, height?: number): string {
|
||||
if (!width || !height) return 'Unknown';
|
||||
|
||||
// Common resolution names
|
||||
const resolutions: Record<string, string> = {
|
||||
'1920x1080': '1080p',
|
||||
'1280x720': '720p',
|
||||
'854x480': '480p',
|
||||
'640x360': '360p',
|
||||
'426x240': '240p',
|
||||
};
|
||||
|
||||
const key = `${width}x${height}`;
|
||||
return resolutions[key] || `${width}×${height}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate aspect ratio string
|
||||
*/
|
||||
export function getAspectRatioString(aspectRatio: number): string {
|
||||
if (!aspectRatio || aspectRatio <= 0) return 'Unknown';
|
||||
|
||||
// Common aspect ratios
|
||||
const ratios: Array<[number, string]> = [
|
||||
[16/9, '16:9'],
|
||||
[4/3, '4:3'],
|
||||
[21/9, '21:9'],
|
||||
[1, '1:1'],
|
||||
];
|
||||
|
||||
// Find closest match (within 0.1 tolerance)
|
||||
for (const [ratio, display] of ratios) {
|
||||
if (Math.abs(aspectRatio - ratio) < 0.1) {
|
||||
return display;
|
||||
}
|
||||
}
|
||||
|
||||
// Return calculated ratio
|
||||
const gcd = (a: number, b: number): number => b === 0 ? a : gcd(b, a % b);
|
||||
const width = Math.round(aspectRatio * 100);
|
||||
const height = 100;
|
||||
const divisor = gcd(width, height);
|
||||
|
||||
return `${width / divisor}:${height / divisor}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort videos by different criteria
|
||||
*/
|
||||
export function sortVideos(
|
||||
videos: VideoFile[],
|
||||
field: 'created_at' | 'file_size_bytes' | 'camera_name' | 'filename',
|
||||
direction: 'asc' | 'desc' = 'desc'
|
||||
): VideoFile[] {
|
||||
return [...videos].sort((a, b) => {
|
||||
let aValue: any = a[field];
|
||||
let bValue: any = b[field];
|
||||
|
||||
// Handle date strings
|
||||
if (field === 'created_at') {
|
||||
aValue = new Date(aValue).getTime();
|
||||
bValue = new Date(bValue).getTime();
|
||||
}
|
||||
|
||||
// Handle string comparison
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
if (aValue < bValue) result = -1;
|
||||
else if (aValue > bValue) result = 1;
|
||||
|
||||
return direction === 'desc' ? -result : result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter videos by criteria
|
||||
*/
|
||||
export function filterVideos(
|
||||
videos: VideoFile[],
|
||||
filters: {
|
||||
cameraName?: string;
|
||||
status?: VideoFile['status'];
|
||||
format?: string;
|
||||
dateRange?: { start: string; end: string };
|
||||
}
|
||||
): VideoFile[] {
|
||||
return videos.filter(video => {
|
||||
// Filter by camera name
|
||||
if (filters.cameraName && video.camera_name !== filters.cameraName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (filters.status && video.status !== filters.status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by format
|
||||
if (filters.format && video.format !== filters.format) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
if (filters.dateRange) {
|
||||
const videoDate = new Date(video.created_at);
|
||||
const startDate = new Date(filters.dateRange.start);
|
||||
const endDate = new Date(filters.dateRange.end);
|
||||
|
||||
if (videoDate < startDate || videoDate > endDate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique key for video caching
|
||||
*/
|
||||
export function generateVideoKey(fileId: string, params?: Record<string, any>): string {
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
return fileId;
|
||||
}
|
||||
|
||||
const sortedParams = Object.keys(params)
|
||||
.sort()
|
||||
.map(key => `${key}=${params[key]}`)
|
||||
.join('&');
|
||||
|
||||
return `${fileId}?${sortedParams}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate video file ID format
|
||||
*/
|
||||
export function isValidFileId(fileId: string): boolean {
|
||||
// Basic validation - adjust based on your file ID format
|
||||
return typeof fileId === 'string' && fileId.length > 0 && !fileId.includes('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video thumbnail timestamp suggestions
|
||||
*/
|
||||
export function getThumbnailTimestamps(duration: number): number[] {
|
||||
if (duration <= 0) return [0];
|
||||
|
||||
// Generate timestamps at 10%, 25%, 50%, 75%, 90% of video duration
|
||||
return [
|
||||
Math.floor(duration * 0.1),
|
||||
Math.floor(duration * 0.25),
|
||||
Math.floor(duration * 0.5),
|
||||
Math.floor(duration * 0.75),
|
||||
Math.floor(duration * 0.9),
|
||||
].filter(t => t >= 0 && t < duration);
|
||||
}
|
||||
26
management-dashboard-web-app/src/lib/featureFlags.ts
Normal file
26
management-dashboard-web-app/src/lib/featureFlags.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type FeatureFlags = {
|
||||
enableShell: boolean
|
||||
enableVideoModule: boolean
|
||||
enableExperimentModule: boolean
|
||||
enableCameraModule: boolean
|
||||
}
|
||||
|
||||
const toBool = (v: unknown, fallback = false): boolean => {
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim().toLowerCase()
|
||||
return s === '1' || s === 'true' || s === 'yes' || s === 'on'
|
||||
}
|
||||
if (typeof v === 'boolean') return v
|
||||
return fallback
|
||||
}
|
||||
|
||||
export const featureFlags: FeatureFlags = {
|
||||
enableShell: toBool(import.meta.env.VITE_ENABLE_SHELL ?? false),
|
||||
enableVideoModule: toBool(import.meta.env.VITE_ENABLE_VIDEO_MODULE ?? false),
|
||||
enableExperimentModule: toBool(import.meta.env.VITE_ENABLE_EXPERIMENT_MODULE ?? false),
|
||||
enableCameraModule: toBool(import.meta.env.VITE_ENABLE_CAMERA_MODULE ?? false),
|
||||
}
|
||||
|
||||
export const isFeatureEnabled = (flag: keyof FeatureFlags): boolean => featureFlags[flag]
|
||||
|
||||
|
||||
14
management-dashboard-web-app/src/lib/loadRemote.tsx
Normal file
14
management-dashboard-web-app/src/lib/loadRemote.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
|
||||
export function loadRemoteComponent<T>(
|
||||
enabled: boolean,
|
||||
remoteImport: () => Promise<{ default: T }>,
|
||||
localComponent: T
|
||||
): T {
|
||||
if (enabled) {
|
||||
return React.lazy(remoteImport) as T
|
||||
}
|
||||
return localComponent
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// analytics module public API placeholder
|
||||
export {}
|
||||
|
||||
|
||||
4
management-dashboard-web-app/src/modules/camera/index.ts
Normal file
4
management-dashboard-web-app/src/modules/camera/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// camera module public API placeholder
|
||||
export {}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// experiments module public API placeholder
|
||||
export {}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// scheduling module public API placeholder
|
||||
export {}
|
||||
|
||||
|
||||
4
management-dashboard-web-app/src/modules/users/index.ts
Normal file
4
management-dashboard-web-app/src/modules/users/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// users module public API placeholder
|
||||
export {}
|
||||
|
||||
|
||||
4
management-dashboard-web-app/src/modules/video/index.ts
Normal file
4
management-dashboard-web-app/src/modules/video/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// video module public API placeholder
|
||||
export {}
|
||||
|
||||
|
||||
28
management-dashboard-web-app/src/shared/api/client.ts
Normal file
28
management-dashboard-web-app/src/shared/api/client.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
|
||||
|
||||
async function request<T>(path: string, method: HttpMethod = 'GET', body?: unknown): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(text || `Request failed: ${res.status}`)
|
||||
}
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
get: <T>(path: string) => request<T>(path, 'GET'),
|
||||
post: <T>(path: string, body?: unknown) => request<T>(path, 'POST', body),
|
||||
put: <T>(path: string, body?: unknown) => request<T>(path, 'PUT', body),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>(path, 'PATCH', body),
|
||||
delete: <T>(path: string) => request<T>(path, 'DELETE'),
|
||||
}
|
||||
|
||||
|
||||
4
management-dashboard-web-app/src/shared/api/index.ts
Normal file
4
management-dashboard-web-app/src/shared/api/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { apiClient } from './client'
|
||||
|
||||
|
||||
|
||||
30
management-dashboard-web-app/src/shared/api/video.ts
Normal file
30
management-dashboard-web-app/src/shared/api/video.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { apiClient } from './client'
|
||||
import type { VideoListParams, VideoListResponse } from '../types/video'
|
||||
|
||||
const BASE = (import.meta as any).env?.VITE_VISION_API_URL || '/api'
|
||||
|
||||
export const videoApi = {
|
||||
async list(params: VideoListParams = {}): Promise<VideoListResponse> {
|
||||
const search = new URLSearchParams()
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null) search.append(k, String(v))
|
||||
})
|
||||
const qs = search.toString()
|
||||
return apiClient.get(`${BASE}/videos/${qs ? `?${qs}` : ''}`)
|
||||
},
|
||||
streamingUrl(fileId: string): string {
|
||||
return `${BASE}/videos/${fileId}/stream`
|
||||
},
|
||||
thumbnailUrl(fileId: string, params: { timestamp?: number; width?: number; height?: number } = {}): string {
|
||||
const search = new URLSearchParams()
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null) search.append(k, String(v))
|
||||
})
|
||||
const qs = search.toString()
|
||||
return `${BASE}/videos/${fileId}/thumbnail${qs ? `?${qs}` : ''}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
4
management-dashboard-web-app/src/shared/auth/index.ts
Normal file
4
management-dashboard-web-app/src/shared/auth/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { supabase, userManagement, type User } from '../../lib/supabase'
|
||||
export { useAuth } from '../../hooks/useAuth'
|
||||
|
||||
|
||||
6
management-dashboard-web-app/src/shared/index.ts
Normal file
6
management-dashboard-web-app/src/shared/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './types'
|
||||
export * from './ui'
|
||||
export * from './api'
|
||||
export * from './auth'
|
||||
|
||||
|
||||
6
management-dashboard-web-app/src/shared/types/index.ts
Normal file
6
management-dashboard-web-app/src/shared/types/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type ApiResult<T> = { success: true; data: T } | { success: false; error: string }
|
||||
|
||||
export type UserRole = 'admin' | 'conductor' | 'user'
|
||||
|
||||
|
||||
|
||||
34
management-dashboard-web-app/src/shared/types/video.ts
Normal file
34
management-dashboard-web-app/src/shared/types/video.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface VideoFile {
|
||||
file_id: string
|
||||
camera_name: string
|
||||
filename: string
|
||||
file_size_bytes: number
|
||||
format: string
|
||||
status: 'completed' | 'processing' | 'failed'
|
||||
created_at: string
|
||||
is_streamable: boolean
|
||||
needs_conversion: boolean
|
||||
}
|
||||
|
||||
export interface VideoListResponse {
|
||||
videos: VideoFile[]
|
||||
total_count: number
|
||||
page?: number
|
||||
total_pages?: number
|
||||
has_next?: boolean
|
||||
has_previous?: boolean
|
||||
}
|
||||
|
||||
export interface VideoListParams {
|
||||
camera_name?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
limit?: number
|
||||
include_metadata?: boolean
|
||||
page?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
4
management-dashboard-web-app/src/shared/ui/index.ts
Normal file
4
management-dashboard-web-app/src/shared/ui/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { PrimaryButton } from './widgets/PrimaryButton'
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ButtonHTMLAttributes, PropsWithChildren } from 'react'
|
||||
|
||||
type PrimaryButtonProps = PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>> & {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function PrimaryButton({ children, loading = false, className = '', ...rest }: PrimaryButtonProps) {
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
className={`px-4 py-2 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 ${className}`}
|
||||
disabled={loading || rest.disabled}
|
||||
>
|
||||
{loading ? 'Please wait…' : children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* Video Streaming API Test
|
||||
*
|
||||
* This test script verifies the video streaming functionality
|
||||
* and API connectivity with the USDA Vision Camera System.
|
||||
*/
|
||||
|
||||
import { videoApiService } from '../features/video-streaming/services/videoApi';
|
||||
|
||||
export interface TestResult {
|
||||
test: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export class VideoStreamingTester {
|
||||
private results: TestResult[] = [];
|
||||
|
||||
async runAllTests(): Promise<TestResult[]> {
|
||||
this.results = [];
|
||||
|
||||
console.log('🧪 Starting Video Streaming API Tests');
|
||||
console.log('=====================================');
|
||||
|
||||
await this.testApiConnectivity();
|
||||
await this.testVideoList();
|
||||
await this.testVideoInfo();
|
||||
await this.testStreamingUrls();
|
||||
|
||||
console.log('\n📊 Test Results Summary:');
|
||||
console.log('========================');
|
||||
|
||||
const passed = this.results.filter(r => r.success).length;
|
||||
const total = this.results.length;
|
||||
|
||||
this.results.forEach(result => {
|
||||
const icon = result.success ? '✅' : '❌';
|
||||
console.log(`${icon} ${result.test}: ${result.message}`);
|
||||
});
|
||||
|
||||
console.log(`\n🎯 Tests Passed: ${passed}/${total}`);
|
||||
|
||||
return this.results;
|
||||
}
|
||||
|
||||
private async testApiConnectivity(): Promise<void> {
|
||||
try {
|
||||
console.log('\n🔗 Testing API Connectivity...');
|
||||
|
||||
const isHealthy = await videoApiService.healthCheck();
|
||||
|
||||
if (isHealthy) {
|
||||
this.addResult('API Connectivity', true, 'Successfully connected to video API');
|
||||
} else {
|
||||
this.addResult('API Connectivity', false, 'API is not responding');
|
||||
}
|
||||
} catch (error) {
|
||||
this.addResult('API Connectivity', false, `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async testVideoList(): Promise<void> {
|
||||
try {
|
||||
console.log('\n📋 Testing Video List...');
|
||||
|
||||
const response = await videoApiService.getVideos({
|
||||
limit: 5,
|
||||
include_metadata: true
|
||||
});
|
||||
|
||||
if (response && typeof response.total_count === 'number') {
|
||||
this.addResult('Video List', true, `Found ${response.total_count} videos, retrieved ${response.videos.length} items`, response);
|
||||
} else {
|
||||
this.addResult('Video List', false, 'Invalid response format');
|
||||
}
|
||||
} catch (error) {
|
||||
this.addResult('Video List', false, `Failed to fetch videos: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async testVideoInfo(): Promise<void> {
|
||||
try {
|
||||
console.log('\n📹 Testing Video Info...');
|
||||
|
||||
// First get a video list to test with
|
||||
const videoList = await videoApiService.getVideos({ limit: 1 });
|
||||
|
||||
if (videoList.videos.length === 0) {
|
||||
this.addResult('Video Info', false, 'No videos available to test with');
|
||||
return;
|
||||
}
|
||||
|
||||
const firstVideo = videoList.videos[0];
|
||||
const videoInfo = await videoApiService.getVideoInfo(firstVideo.file_id);
|
||||
|
||||
if (videoInfo && videoInfo.file_id === firstVideo.file_id) {
|
||||
this.addResult('Video Info', true, `Successfully retrieved info for ${firstVideo.file_id}`, videoInfo);
|
||||
} else {
|
||||
this.addResult('Video Info', false, 'Invalid video info response');
|
||||
}
|
||||
} catch (error) {
|
||||
this.addResult('Video Info', false, `Failed to fetch video info: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async testStreamingUrls(): Promise<void> {
|
||||
try {
|
||||
console.log('\n🎬 Testing Streaming URLs...');
|
||||
|
||||
// Get a video to test with
|
||||
const videoList = await videoApiService.getVideos({ limit: 1 });
|
||||
|
||||
if (videoList.videos.length === 0) {
|
||||
this.addResult('Streaming URLs', false, 'No videos available to test with');
|
||||
return;
|
||||
}
|
||||
|
||||
const firstVideo = videoList.videos[0];
|
||||
|
||||
// Test streaming URL generation
|
||||
const streamingUrl = videoApiService.getStreamingUrl(firstVideo.file_id);
|
||||
const thumbnailUrl = videoApiService.getThumbnailUrl(firstVideo.file_id, {
|
||||
timestamp: 1.0,
|
||||
width: 320,
|
||||
height: 240
|
||||
});
|
||||
|
||||
if (streamingUrl && thumbnailUrl) {
|
||||
this.addResult('Streaming URLs', true, `Generated URLs for ${firstVideo.file_id}`, {
|
||||
streamingUrl,
|
||||
thumbnailUrl
|
||||
});
|
||||
} else {
|
||||
this.addResult('Streaming URLs', false, 'Failed to generate URLs');
|
||||
}
|
||||
} catch (error) {
|
||||
this.addResult('Streaming URLs', false, `Failed to test URLs: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private addResult(test: string, success: boolean, message: string, data?: any): void {
|
||||
this.results.push({ test, success, message, data });
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in browser console
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).VideoStreamingTester = VideoStreamingTester;
|
||||
(window as any).runVideoStreamingTests = async () => {
|
||||
const tester = new VideoStreamingTester();
|
||||
return await tester.runAllTests();
|
||||
};
|
||||
}
|
||||
|
||||
export default VideoStreamingTester;
|
||||
@@ -1,51 +0,0 @@
|
||||
// Simple test file to verify vision API client functionality
|
||||
// This is not a formal test suite, just a manual verification script
|
||||
|
||||
import { visionApi, formatBytes, formatDuration, formatUptime } from '../lib/visionApi'
|
||||
|
||||
// Test utility functions
|
||||
console.log('Testing utility functions:')
|
||||
console.log('formatBytes(1024):', formatBytes(1024)) // Should be "1 KB"
|
||||
console.log('formatBytes(1048576):', formatBytes(1048576)) // Should be "1 MB"
|
||||
console.log('formatDuration(65):', formatDuration(65)) // Should be "1m 5s"
|
||||
console.log('formatUptime(3661):', formatUptime(3661)) // Should be "1h 1m"
|
||||
|
||||
// Test API endpoints (these will fail if vision system is not running)
|
||||
export async function testVisionApi() {
|
||||
try {
|
||||
console.log('Testing vision API endpoints...')
|
||||
|
||||
// Test health endpoint
|
||||
const health = await visionApi.getHealth()
|
||||
console.log('Health check:', health)
|
||||
|
||||
// Test system status
|
||||
const status = await visionApi.getSystemStatus()
|
||||
console.log('System status:', status)
|
||||
|
||||
// Test cameras
|
||||
const cameras = await visionApi.getCameras()
|
||||
console.log('Cameras:', cameras)
|
||||
|
||||
// Test machines
|
||||
const machines = await visionApi.getMachines()
|
||||
console.log('Machines:', machines)
|
||||
|
||||
// Test storage stats
|
||||
const storage = await visionApi.getStorageStats()
|
||||
console.log('Storage stats:', storage)
|
||||
|
||||
// Test recordings
|
||||
const recordings = await visionApi.getRecordings()
|
||||
console.log('Recordings:', recordings)
|
||||
|
||||
console.log('All API tests passed!')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('API test failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Uncomment the line below to run the test when this file is imported
|
||||
// testVisionApi()
|
||||
Reference in New Issue
Block a user