feat(video-streaming): Implement video streaming feature with components, hooks, services, and utilities

- Added centralized exports for video streaming components and hooks.
- Implemented `useVideoInfo` hook for fetching and managing video metadata and streaming information.
- Developed `useVideoList` hook for managing video list state, fetching, filtering, and pagination.
- Created `useVideoPlayer` hook for managing video player state and controls.
- Established `videoApiService` for handling API interactions related to video streaming.
- Defined TypeScript types for video streaming feature, including video metadata, API responses, and component props.
- Added utility functions for video operations, formatting, and data processing.
- Created main entry point for the video streaming feature, exporting all public APIs.
This commit is contained in:
Alireza Vaezi
2025-08-04 15:02:48 -04:00
parent 97f22d239d
commit 551e5dc2e3
44 changed files with 3964 additions and 176 deletions

View File

@@ -6,6 +6,7 @@ import { UserManagement } from './UserManagement'
import { Experiments } from './Experiments'
import { DataEntry } from './DataEntry'
import { VisionSystem } from './VisionSystem'
import { VideoStreamingPage } from '../features/video-streaming'
import { userManagement, type User } from '../lib/supabase'
interface DashboardLayoutProps {
@@ -84,6 +85,8 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
return <DataEntry />
case 'vision-system':
return <VisionSystem />
case 'video-library':
return <VideoStreamingPage />
default:
return <DashboardHome user={user} />
}

View File

@@ -48,6 +48,15 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
),
requiredRoles: ['admin', 'conductor']
},
{
id: 'video-library',
name: 'Video Library',
icon: (
<svg className="w-5 h-5" 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>
),
},
{
id: 'analytics',
name: 'Analytics',

View File

@@ -0,0 +1,178 @@
/**
* 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 } 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 (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="py-6">
<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>
</div>
{/* Filters and Controls */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="bg-white rounded-lg shadow p-6 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Camera Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Filter by Camera
</label>
<select
value={filters.cameraName || 'all'}
onChange={(e) => handleCameraFilterChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">All Cameras</option>
{availableCameras.map(camera => (
<option key={camera} value={camera}>
{camera}
</option>
))}
</select>
</div>
{/* Sort Options */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sort by
</label>
<div className="flex space-x-2">
<select
value={sortOptions.field}
onChange={(e) => handleSortChange(e.target.value as VideoListSortOptions['field'], sortOptions.direction)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<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>
<button
onClick={() => handleSortChange(sortOptions.field, sortOptions.direction === 'asc' ? 'desc' : 'asc')}
className="px-3 py-2 border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
title={`Sort ${sortOptions.direction === 'asc' ? 'Descending' : 'Ascending'}`}
>
{sortOptions.direction === 'asc' ? (
<svg className="w-5 h-5" 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-5 h-5" 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">
Date Range
</label>
<div className="flex space-x-2">
<input
type="date"
value={filters.dateRange?.start || ''}
onChange={(e) => handleDateRangeChange(e.target.value, filters.dateRange?.end || '')}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<input
type="date"
value={filters.dateRange?.end || ''}
onChange={(e) => handleDateRangeChange(filters.dateRange?.start || '', e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
{/* Clear Filters */}
{(filters.cameraName || filters.dateRange) && (
<div className="mt-4 pt-4 border-t">
<button
onClick={() => setFilters({})}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
Clear Filters
</button>
</div>
)}
</div>
{/* Video List */}
<VideoList
filters={filters}
sortOptions={sortOptions}
onVideoSelect={handleVideoSelect}
limit={24}
/>
</div>
{/* Video Modal */}
<VideoModal
video={selectedVideo}
isOpen={isModalOpen}
onClose={handleModalClose}
/>
</div>
);
};

View File

@@ -0,0 +1,162 @@
/**
* VideoCard Component
*
* A reusable card component for displaying video information with thumbnail, metadata, and actions.
*/
import React from 'react';
import { type VideoCardProps } from '../types';
import { VideoThumbnail } from './VideoThumbnail';
import {
formatFileSize,
formatVideoDate,
getRelativeTime,
getFormatDisplayName,
getStatusBadgeClass,
getResolutionString,
} from '../utils/videoUtils';
export const VideoCard: React.FC<VideoCardProps> = ({
video,
onClick,
showMetadata = true,
className = '',
}) => {
const handleClick = () => {
if (onClick) {
onClick(video);
}
};
const handleThumbnailClick = () => {
handleClick();
};
const cardClasses = [
'bg-white rounded-lg shadow-md overflow-hidden transition-shadow hover:shadow-lg',
onClick ? 'cursor-pointer' : '',
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>
)}
{/* 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">
<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">
<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 border border-transparent text-xs font-medium rounded text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<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>
);
};

View File

@@ -0,0 +1,195 @@
/**
* 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';
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,
loading,
error,
refetch,
loadMore,
hasMore,
updateFilters,
updateSort,
} = useVideoList({
initialParams: {
camera_name: localFilters.cameraName,
start_date: localFilters.dateRange?.start,
end_date: localFilters.dateRange?.end,
limit,
include_metadata: true,
},
autoFetch: true,
});
// Update filters when props change (but don't auto-fetch)
useEffect(() => {
if (filters) {
setLocalFilters(filters);
}
}, [filters]);
// Update sort when props change
useEffect(() => {
if (sortOptions) {
setLocalSort(sortOptions);
updateSort(sortOptions);
}
}, [sortOptions, updateSort]);
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}>
{/* Results Summary */}
<div className="flex items-center justify-between mb-6">
<div className="text-sm text-gray-600">
Showing {videos.length} of {totalCount} videos
</div>
<button
onClick={refetch}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<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>
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>
{/* Load More Button */}
{hasMore && (
<div className="flex justify-center mt-8">
<button
onClick={handleLoadMore}
disabled={loading === 'loading'}
className="inline-flex items-center px-6 py-3 border border-transparent text-base 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 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading === 'loading' ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Loading...
</>
) : (
<>
<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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Load More Videos
</>
)}
</button>
</div>
)}
{/* Loading Indicator for Additional Videos */}
{loading === 'loading' && videos.length > 0 && (
<div className="flex justify-center mt-4">
<div className="text-sm text-gray-600 flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500 mr-2"></div>
Loading more videos...
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,221 @@
/**
* 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 { useVideoInfo } from '../hooks/useVideoInfo';
import {
formatFileSize,
formatVideoDate,
getFormatDisplayName,
getStatusBadgeClass,
getResolutionString,
formatDuration,
} 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-50 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">
<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 bg-gray-100 text-gray-800">
{getFormatDisplayName(video.format)}
</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>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,204 @@
/**
* 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 } from 'react';
import { useVideoPlayer } from '../hooks/useVideoPlayer';
import { videoApiService } from '../services/videoApi';
import { type VideoPlayerProps } from '../types';
import { formatDuration } 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 { state, actions, ref } = useVideoPlayer({
autoPlay,
onPlay,
onPause,
onEnded,
onError,
});
// Combine refs
React.useImperativeHandle(forwardedRef, () => ref.current!, [ref]);
const streamingUrl = videoApiService.getStreamingUrl(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} // Use native controls if custom controls are disabled
style={{ width, height }}
>
<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-lg">Loading...</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';

View File

@@ -0,0 +1,136 @@
/**
* 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 { 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);
const blob = await videoApiService.getThumbnailBlob(fileId, {
timestamp,
width,
height,
});
if (isMounted) {
const url = URL.createObjectURL(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;
if (thumbnailUrl) {
URL.revokeObjectURL(thumbnailUrl);
}
};
}, [fileId, timestamp, width, height]);
// Cleanup URL on unmount
useEffect(() => {
return () => {
if (thumbnailUrl) {
URL.revokeObjectURL(thumbnailUrl);
}
};
}, [thumbnailUrl]);
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>
);
};

View File

@@ -0,0 +1,20 @@
/**
* 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';
// Re-export component prop types for convenience
export type {
VideoPlayerProps,
VideoThumbnailProps,
VideoCardProps,
VideoListProps,
} from '../types';

View File

@@ -0,0 +1,16 @@
/**
* 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';

View File

@@ -0,0 +1,191 @@
/**
* 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,
};
}

View File

@@ -0,0 +1,187 @@
/**
* 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;
loading: LoadingState;
error: VideoError | null;
refetch: () => Promise<void>;
loadMore: () => Promise<void>;
hasMore: boolean;
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 [loading, setLoading] = useState<LoadingState>('idle');
const [error, setError] = useState<VideoError | null>(null);
const [hasMore, setHasMore] = useState(true);
// Refs for cleanup and caching
const abortControllerRef = useRef<AbortController | 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);
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 initial parameters
*/
const refetch = useCallback(async (): Promise<void> => {
await fetchVideos(initialParams, false);
}, [fetchVideos, initialParams]);
/**
* Load more videos (pagination)
*/
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]);
/**
* 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,
};
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));
}, []);
/**
* Reset to initial state
*/
const reset = useCallback((): void => {
setVideos([]);
setTotalCount(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,
loading,
error,
refetch,
loadMore,
hasMore,
// Additional utility methods
updateFilters,
updateSort,
reset,
};
}

View File

@@ -0,0 +1,317 @@
/**
* 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 });
};
const handleLoadedData = () => {
updateState({ isLoading: false });
};
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);
};
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,
};
}

View File

@@ -0,0 +1,24 @@
/**
* 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';

View File

@@ -0,0 +1,232 @@
/**
* 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';
// Configuration
const API_BASE_URL = 'http://vision:8000'; // Based on the test script
/**
* 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 list of videos with optional filtering
*/
async getVideos(params: VideoListParams = {}): Promise<VideoListResponse> {
try {
const queryString = buildQueryString(params);
const url = `${this.baseUrl}/videos/${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
});
return await handleApiResponse<VideoListResponse>(response);
} catch (error) {
if (error instanceof VideoApiError) {
throw error;
}
throw new VideoApiError(
'NETWORK_ERROR',
'Failed to fetch videos',
{ originalError: error }
);
}
}
/**
* 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> {
try {
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();
} catch (error) {
if (error instanceof VideoApiError) {
throw error;
}
throw new VideoApiError(
'NETWORK_ERROR',
`Failed to fetch thumbnail for ${fileId}`,
{ originalError: error, fileId }
);
}
}
/**
* 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 };

View File

@@ -0,0 +1,146 @@
/**
* 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;
}
// 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;
}
// 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;
}
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

View File

@@ -0,0 +1,282 @@
/**
* Video Streaming Utilities
*
* Pure utility functions for video operations, formatting, and data processing.
* These functions have no side effects and can be easily tested.
*/
import { type VideoFile, type VideoWithMetadata } from '../types';
/**
* 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;
}
}
/**
* 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 {
const formatMap: Record<string, string> = {
'avi': 'AVI',
'mp4': 'MP4',
'webm': 'WebM',
'mov': 'MOV',
'mkv': 'MKV',
};
return formatMap[format.toLowerCase()] || format.toUpperCase();
}
/**
* Check if video format is web-compatible
*/
export function isWebCompatible(format: string): boolean {
const webFormats = ['mp4', 'webm', 'ogg'];
return webFormats.includes(format.toLowerCase());
}
/**
* 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);
}

View File

@@ -1,6 +1,6 @@
// Vision System API Client
// Base URL for the vision system API
const VISION_API_BASE_URL = 'http://localhost:8000'
const VISION_API_BASE_URL = 'http://vision:8000'
// Types based on the API documentation
export interface SystemStatus {