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:
@@ -7,7 +7,7 @@ This guide is specifically designed for AI assistants to understand and implemen
|
||||
The USDA Vision Camera system provides live video streaming through REST API endpoints. The streaming uses MJPEG format which is natively supported by HTML `<img>` tags and can be easily integrated into React components.
|
||||
|
||||
### Key Characteristics:
|
||||
- **Base URL**: `http://vision:8000` (production) or `http://localhost:8000` (development)
|
||||
- **Base URL**: `http://vision:8000` (production) or `http://vision:8000` (development)
|
||||
- **Stream Format**: MJPEG (Motion JPEG)
|
||||
- **Content-Type**: `multipart/x-mixed-replace; boundary=frame`
|
||||
- **Authentication**: None (add if needed for production)
|
||||
@@ -15,7 +15,7 @@ The USDA Vision Camera system provides live video streaming through REST API end
|
||||
|
||||
### Base URL Configuration:
|
||||
- **Production**: `http://vision:8000` (requires hostname setup)
|
||||
- **Development**: `http://localhost:8000` (local testing)
|
||||
- **Development**: `http://vision:8000` (local testing)
|
||||
- **Custom IP**: `http://192.168.1.100:8000` (replace with actual IP)
|
||||
- **Custom hostname**: Configure DNS or /etc/hosts as needed
|
||||
|
||||
@@ -456,7 +456,7 @@ REACT_APP_STREAM_REFRESH_INTERVAL=30000
|
||||
REACT_APP_STREAM_TIMEOUT=10000
|
||||
|
||||
# Development configuration (using localhost)
|
||||
# REACT_APP_CAMERA_API_URL=http://localhost:8000
|
||||
# REACT_APP_CAMERA_API_URL=http://vision:8000
|
||||
|
||||
# Custom IP configuration
|
||||
# REACT_APP_CAMERA_API_URL=http://192.168.1.100:8000
|
||||
|
||||
@@ -163,7 +163,7 @@ POST /cameras/{camera_name}/apply-config
|
||||
### Example 1: Adjust Exposure and Gain
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
curl -X PUT http://vision:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"exposure_ms": 1.5,
|
||||
@@ -174,7 +174,7 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
### Example 2: Improve Image Quality
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
curl -X PUT http://vision:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"sharpness": 150,
|
||||
@@ -186,7 +186,7 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
### Example 3: Configure for Indoor Lighting
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
curl -X PUT http://vision:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"anti_flicker_enabled": true,
|
||||
@@ -199,7 +199,7 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
### Example 4: Enable HDR Mode
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
curl -X PUT http://vision:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"hdr_enabled": true,
|
||||
@@ -214,7 +214,7 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
```jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const CameraConfig = ({ cameraName, apiBaseUrl = 'http://localhost:8000' }) => {
|
||||
const CameraConfig = ({ cameraName, apiBaseUrl = 'http://vision:8000' }) => {
|
||||
const [config, setConfig] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
@@ -275,7 +275,7 @@ The system provides a comprehensive REST API for monitoring and control.
|
||||
The API server starts automatically with the main system on port 8000:
|
||||
```bash
|
||||
python main.py
|
||||
# API available at: http://localhost:8000
|
||||
# API available at: http://vision:8000
|
||||
```
|
||||
|
||||
### 🚀 New API Features
|
||||
@@ -300,7 +300,7 @@ python main.py
|
||||
#### System Status
|
||||
```bash
|
||||
# Get overall system status
|
||||
curl http://localhost:8000/system/status
|
||||
curl http://vision:8000/system/status
|
||||
|
||||
# Response example:
|
||||
{
|
||||
@@ -320,7 +320,7 @@ curl http://localhost:8000/system/status
|
||||
#### Machine Status
|
||||
```bash
|
||||
# Get all machine states
|
||||
curl http://localhost:8000/machines
|
||||
curl http://vision:8000/machines
|
||||
|
||||
# Response example:
|
||||
{
|
||||
@@ -336,10 +336,10 @@ curl http://localhost:8000/machines
|
||||
#### Camera Status
|
||||
```bash
|
||||
# Get all camera statuses
|
||||
curl http://localhost:8000/cameras
|
||||
curl http://vision:8000/cameras
|
||||
|
||||
# Get specific camera status
|
||||
curl http://localhost:8000/cameras/camera1
|
||||
curl http://vision:8000/cameras/camera1
|
||||
|
||||
# Response example:
|
||||
{
|
||||
@@ -357,12 +357,12 @@ curl http://localhost:8000/cameras/camera1
|
||||
#### Manual Recording Control
|
||||
```bash
|
||||
# Start recording manually
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-recording \
|
||||
curl -X POST http://vision:8000/cameras/camera1/start-recording \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"camera_name": "camera1", "filename": "manual_test.avi"}'
|
||||
|
||||
# Stop recording manually
|
||||
curl -X POST http://localhost:8000/cameras/camera1/stop-recording
|
||||
curl -X POST http://vision:8000/cameras/camera1/stop-recording
|
||||
|
||||
# Response example:
|
||||
{
|
||||
@@ -375,15 +375,15 @@ curl -X POST http://localhost:8000/cameras/camera1/stop-recording
|
||||
#### Storage Management
|
||||
```bash
|
||||
# Get storage statistics
|
||||
curl http://localhost:8000/storage/stats
|
||||
curl http://vision:8000/storage/stats
|
||||
|
||||
# Get recording files list
|
||||
curl -X POST http://localhost:8000/storage/files \
|
||||
curl -X POST http://vision:8000/storage/files \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"camera_name": "camera1", "limit": 10}'
|
||||
|
||||
# Cleanup old files
|
||||
curl -X POST http://localhost:8000/storage/cleanup \
|
||||
curl -X POST http://vision:8000/storage/cleanup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"max_age_days": 30}'
|
||||
```
|
||||
@@ -391,7 +391,7 @@ curl -X POST http://localhost:8000/storage/cleanup \
|
||||
### WebSocket Real-time Updates
|
||||
```javascript
|
||||
// Connect to WebSocket for real-time updates
|
||||
const ws = new WebSocket('ws://localhost:8000/ws');
|
||||
const ws = new WebSocket('ws://vision:8000/ws');
|
||||
|
||||
ws.onmessage = function(event) {
|
||||
const update = JSON.parse(event.data);
|
||||
@@ -414,14 +414,14 @@ import requests
|
||||
import json
|
||||
|
||||
# System status check
|
||||
response = requests.get('http://localhost:8000/system/status')
|
||||
response = requests.get('http://vision:8000/system/status')
|
||||
status = response.json()
|
||||
print(f"System running: {status['system_started']}")
|
||||
|
||||
# Start recording
|
||||
recording_data = {"camera_name": "camera1"}
|
||||
response = requests.post(
|
||||
'http://localhost:8000/cameras/camera1/start-recording',
|
||||
'http://vision:8000/cameras/camera1/start-recording',
|
||||
headers={'Content-Type': 'application/json'},
|
||||
data=json.dumps(recording_data)
|
||||
)
|
||||
@@ -440,7 +440,7 @@ function useSystemStatus() {
|
||||
useEffect(() => {
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/system/status');
|
||||
const response = await fetch('http://vision:8000/system/status');
|
||||
const data = await response.json();
|
||||
setStatus(data);
|
||||
} catch (error) {
|
||||
@@ -487,7 +487,7 @@ const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
||||
async function syncRecordingData() {
|
||||
try {
|
||||
// Get recordings from vision system
|
||||
const response = await fetch('http://localhost:8000/storage/files', {
|
||||
const response = await fetch('http://vision:8000/storage/files', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ limit: 100 })
|
||||
@@ -563,10 +563,10 @@ The system tracks:
|
||||
### Health Checks
|
||||
```bash
|
||||
# API health check
|
||||
curl http://localhost:8000/health
|
||||
curl http://vision:8000/health
|
||||
|
||||
# System status
|
||||
curl http://localhost:8000/system/status
|
||||
curl http://vision:8000/system/status
|
||||
|
||||
# Time synchronization
|
||||
python check_time.py
|
||||
@@ -757,8 +757,8 @@ python test_timezone.py
|
||||
python check_time.py
|
||||
|
||||
# API health check
|
||||
curl http://localhost:8000/health
|
||||
curl http://localhost:8000/system/status
|
||||
curl http://vision:8000/health
|
||||
curl http://vision:8000/system/status
|
||||
```
|
||||
|
||||
### Log Analysis
|
||||
@@ -860,11 +860,11 @@ For technical support:
|
||||
1. Check the troubleshooting section above
|
||||
2. Review logs in `usda_vision_system.log`
|
||||
3. Run system diagnostics with `python test_system.py`
|
||||
4. Check API health at `http://localhost:8000/health`
|
||||
4. Check API health at `http://vision:8000/health`
|
||||
|
||||
---
|
||||
|
||||
**System Status**: ✅ **READY FOR PRODUCTION**
|
||||
**Time Sync**: ✅ **ATLANTA, GEORGIA (EDT/EST)**
|
||||
**API Server**: ✅ **http://localhost:8000**
|
||||
**API Server**: ✅ **http://vision:8000**
|
||||
**Documentation**: ✅ **COMPLETE**
|
||||
|
||||
@@ -40,13 +40,13 @@ Open `camera_preview.html` in your browser and click "Start Stream" for any came
|
||||
### 3. API Usage
|
||||
```bash
|
||||
# Start streaming for camera1
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-stream
|
||||
curl -X POST http://vision:8000/cameras/camera1/start-stream
|
||||
|
||||
# View live stream (open in browser)
|
||||
http://localhost:8000/cameras/camera1/stream
|
||||
http://vision:8000/cameras/camera1/stream
|
||||
|
||||
# Stop streaming
|
||||
curl -X POST http://localhost:8000/cameras/camera1/stop-stream
|
||||
curl -X POST http://vision:8000/cameras/camera1/stop-stream
|
||||
```
|
||||
|
||||
## 📡 API Endpoints
|
||||
@@ -150,10 +150,10 @@ The system supports these concurrent operations:
|
||||
### Example: Concurrent Usage
|
||||
```bash
|
||||
# Start streaming
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-stream
|
||||
curl -X POST http://vision:8000/cameras/camera1/start-stream
|
||||
|
||||
# Start recording (while streaming continues)
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-recording \
|
||||
curl -X POST http://vision:8000/cameras/camera1/start-recording \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"filename": "test_recording.avi"}'
|
||||
|
||||
@@ -232,8 +232,8 @@ For issues with streaming functionality:
|
||||
|
||||
1. Check the system logs: `usda_vision_system.log`
|
||||
2. Run the test script: `python test_streaming.py`
|
||||
3. Verify API health: `http://localhost:8000/health`
|
||||
4. Check camera status: `http://localhost:8000/cameras`
|
||||
3. Verify API health: `http://vision:8000/health`
|
||||
4. Check camera status: `http://vision:8000/cameras`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface ApiConfig {
|
||||
}
|
||||
|
||||
export const defaultApiConfig: ApiConfig = {
|
||||
baseUrl: 'http://vision:8000', // Production default, change to 'http://localhost:8000' for development
|
||||
baseUrl: 'http://vision:8000', // Production default, change to 'http://vision:8000' for development
|
||||
timeout: 10000,
|
||||
refreshInterval: 30000,
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ Enhanced the `POST /cameras/{camera_name}/start-recording` API endpoint to accep
|
||||
|
||||
### Basic Recording (unchanged)
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
POST http://vision:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -56,7 +56,7 @@ Content-Type: application/json
|
||||
|
||||
### Recording with Camera Settings
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
POST http://vision:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -73,7 +73,7 @@ Content-Type: application/json
|
||||
|
||||
### Maximum FPS Recording
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
POST http://vision:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -91,7 +91,7 @@ Content-Type: application/json
|
||||
|
||||
### Settings Only (no filename)
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
POST http://vision:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
|
||||
@@ -444,7 +444,7 @@ For detailed streaming integration, see [Streaming Guide](guides/STREAMING_GUIDE
|
||||
|
||||
### Connect to WebSocket
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:8000/ws');
|
||||
const ws = new WebSocket('ws://vision:8000/ws');
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const update = JSON.parse(event.data);
|
||||
@@ -478,24 +478,24 @@ ws.onmessage = (event) => {
|
||||
### Basic System Monitoring
|
||||
```bash
|
||||
# Check system health
|
||||
curl http://localhost:8000/health
|
||||
curl http://vision:8000/health
|
||||
|
||||
# Get overall system status
|
||||
curl http://localhost:8000/system/status
|
||||
curl http://vision:8000/system/status
|
||||
|
||||
# Get all camera statuses
|
||||
curl http://localhost:8000/cameras
|
||||
curl http://vision:8000/cameras
|
||||
```
|
||||
|
||||
### Manual Recording Control
|
||||
```bash
|
||||
# Start recording with default settings
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-recording \
|
||||
curl -X POST http://vision:8000/cameras/camera1/start-recording \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"filename": "manual_test.avi"}'
|
||||
|
||||
# Start recording with custom camera settings
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-recording \
|
||||
curl -X POST http://vision:8000/cameras/camera1/start-recording \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"filename": "high_quality.avi",
|
||||
@@ -505,28 +505,28 @@ curl -X POST http://localhost:8000/cameras/camera1/start-recording \
|
||||
}'
|
||||
|
||||
# Stop recording
|
||||
curl -X POST http://localhost:8000/cameras/camera1/stop-recording
|
||||
curl -X POST http://vision:8000/cameras/camera1/stop-recording
|
||||
```
|
||||
|
||||
### Auto-Recording Management
|
||||
```bash
|
||||
# Enable auto-recording for camera1
|
||||
curl -X POST http://localhost:8000/cameras/camera1/auto-recording/enable
|
||||
curl -X POST http://vision:8000/cameras/camera1/auto-recording/enable
|
||||
|
||||
# Check auto-recording status
|
||||
curl http://localhost:8000/auto-recording/status
|
||||
curl http://vision:8000/auto-recording/status
|
||||
|
||||
# Disable auto-recording for camera1
|
||||
curl -X POST http://localhost:8000/cameras/camera1/auto-recording/disable
|
||||
curl -X POST http://vision:8000/cameras/camera1/auto-recording/disable
|
||||
```
|
||||
|
||||
### Camera Configuration
|
||||
```bash
|
||||
# Get current camera configuration
|
||||
curl http://localhost:8000/cameras/camera1/config
|
||||
curl http://vision:8000/cameras/camera1/config
|
||||
|
||||
# Update camera settings (real-time)
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
curl -X PUT http://vision:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"exposure_ms": 1.5,
|
||||
@@ -606,7 +606,7 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
## 📞 Support & Integration
|
||||
|
||||
### API Base URL
|
||||
- **Development**: `http://localhost:8000`
|
||||
- **Development**: `http://vision:8000`
|
||||
- **Production**: Configure in `config.json` under `system.api_host` and `system.api_port`
|
||||
|
||||
### Error Handling
|
||||
|
||||
@@ -6,30 +6,30 @@ Quick reference for the most commonly used API endpoints. For complete documenta
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:8000/health
|
||||
curl http://vision:8000/health
|
||||
|
||||
# System overview
|
||||
curl http://localhost:8000/system/status
|
||||
curl http://vision:8000/system/status
|
||||
|
||||
# All cameras
|
||||
curl http://localhost:8000/cameras
|
||||
curl http://vision:8000/cameras
|
||||
|
||||
# All machines
|
||||
curl http://localhost:8000/machines
|
||||
curl http://vision:8000/machines
|
||||
```
|
||||
|
||||
## 🎥 Recording Control
|
||||
|
||||
### Start Recording (Basic)
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-recording \
|
||||
curl -X POST http://vision:8000/cameras/camera1/start-recording \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"filename": "test.avi"}'
|
||||
```
|
||||
|
||||
### Start Recording (With Settings)
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-recording \
|
||||
curl -X POST http://vision:8000/cameras/camera1/start-recording \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"filename": "high_quality.avi",
|
||||
@@ -41,30 +41,30 @@ curl -X POST http://localhost:8000/cameras/camera1/start-recording \
|
||||
|
||||
### Stop Recording
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/cameras/camera1/stop-recording
|
||||
curl -X POST http://vision:8000/cameras/camera1/stop-recording
|
||||
```
|
||||
|
||||
## 🤖 Auto-Recording
|
||||
|
||||
```bash
|
||||
# Enable auto-recording
|
||||
curl -X POST http://localhost:8000/cameras/camera1/auto-recording/enable
|
||||
curl -X POST http://vision:8000/cameras/camera1/auto-recording/enable
|
||||
|
||||
# Disable auto-recording
|
||||
curl -X POST http://localhost:8000/cameras/camera1/auto-recording/disable
|
||||
curl -X POST http://vision:8000/cameras/camera1/auto-recording/disable
|
||||
|
||||
# Check auto-recording status
|
||||
curl http://localhost:8000/auto-recording/status
|
||||
curl http://vision:8000/auto-recording/status
|
||||
```
|
||||
|
||||
## 🎛️ Camera Configuration
|
||||
|
||||
```bash
|
||||
# Get camera config
|
||||
curl http://localhost:8000/cameras/camera1/config
|
||||
curl http://vision:8000/cameras/camera1/config
|
||||
|
||||
# Update camera settings
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
curl -X PUT http://vision:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"exposure_ms": 1.5,
|
||||
@@ -77,41 +77,41 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
|
||||
```bash
|
||||
# Start streaming
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-stream
|
||||
curl -X POST http://vision:8000/cameras/camera1/start-stream
|
||||
|
||||
# Get MJPEG stream (use in browser/video element)
|
||||
# http://localhost:8000/cameras/camera1/stream
|
||||
# http://vision:8000/cameras/camera1/stream
|
||||
|
||||
# Stop streaming
|
||||
curl -X POST http://localhost:8000/cameras/camera1/stop-stream
|
||||
curl -X POST http://vision:8000/cameras/camera1/stop-stream
|
||||
```
|
||||
|
||||
## 🔄 Camera Recovery
|
||||
|
||||
```bash
|
||||
# Test connection
|
||||
curl -X POST http://localhost:8000/cameras/camera1/test-connection
|
||||
curl -X POST http://vision:8000/cameras/camera1/test-connection
|
||||
|
||||
# Reconnect camera
|
||||
curl -X POST http://localhost:8000/cameras/camera1/reconnect
|
||||
curl -X POST http://vision:8000/cameras/camera1/reconnect
|
||||
|
||||
# Full reset
|
||||
curl -X POST http://localhost:8000/cameras/camera1/full-reset
|
||||
curl -X POST http://vision:8000/cameras/camera1/full-reset
|
||||
```
|
||||
|
||||
## 💾 Storage Management
|
||||
|
||||
```bash
|
||||
# Storage statistics
|
||||
curl http://localhost:8000/storage/stats
|
||||
curl http://vision:8000/storage/stats
|
||||
|
||||
# List files
|
||||
curl -X POST http://localhost:8000/storage/files \
|
||||
curl -X POST http://vision:8000/storage/files \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"camera_name": "camera1", "limit": 10}'
|
||||
|
||||
# Cleanup old files
|
||||
curl -X POST http://localhost:8000/storage/cleanup \
|
||||
curl -X POST http://vision:8000/storage/cleanup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"max_age_days": 30}'
|
||||
```
|
||||
@@ -120,17 +120,17 @@ curl -X POST http://localhost:8000/storage/cleanup \
|
||||
|
||||
```bash
|
||||
# MQTT status
|
||||
curl http://localhost:8000/mqtt/status
|
||||
curl http://vision:8000/mqtt/status
|
||||
|
||||
# Recent MQTT events
|
||||
curl http://localhost:8000/mqtt/events?limit=10
|
||||
curl http://vision:8000/mqtt/events?limit=10
|
||||
```
|
||||
|
||||
## 🌐 WebSocket Connection
|
||||
|
||||
```javascript
|
||||
// Connect to real-time updates
|
||||
const ws = new WebSocket('ws://localhost:8000/ws');
|
||||
const ws = new WebSocket('ws://vision:8000/ws');
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const update = JSON.parse(event.data);
|
||||
|
||||
176
API Documentations/docs/MP4_CONVERSION_SUMMARY.md
Normal file
176
API Documentations/docs/MP4_CONVERSION_SUMMARY.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# MP4 Video Format Conversion Summary
|
||||
|
||||
## Overview
|
||||
Successfully converted the USDA Vision Camera System from AVI/XVID format to MP4/MPEG-4 format for better streaming compatibility and smaller file sizes while maintaining high video quality.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Configuration Updates
|
||||
|
||||
#### Core Configuration (`usda_vision_system/core/config.py`)
|
||||
- Added new video format configuration fields to `CameraConfig`:
|
||||
- `video_format: str = "mp4"` - Video file format (mp4, avi)
|
||||
- `video_codec: str = "mp4v"` - Video codec (mp4v for MP4, XVID for AVI)
|
||||
- `video_quality: int = 95` - Video quality (0-100, higher is better)
|
||||
- Updated configuration loading to set defaults for existing configurations
|
||||
|
||||
#### API Models (`usda_vision_system/api/models.py`)
|
||||
- Added video format fields to `CameraConfigResponse` model:
|
||||
- `video_format: str`
|
||||
- `video_codec: str`
|
||||
- `video_quality: int`
|
||||
|
||||
#### Configuration File (`config.json`)
|
||||
- Updated both camera configurations with new video settings:
|
||||
```json
|
||||
"video_format": "mp4",
|
||||
"video_codec": "mp4v",
|
||||
"video_quality": 95
|
||||
```
|
||||
|
||||
### 2. Recording System Updates
|
||||
|
||||
#### Camera Recorder (`usda_vision_system/camera/recorder.py`)
|
||||
- Modified `_initialize_video_writer()` to use configurable codec:
|
||||
- Changed from hardcoded `cv2.VideoWriter_fourcc(*"XVID")`
|
||||
- To configurable `cv2.VideoWriter_fourcc(*self.camera_config.video_codec)`
|
||||
- Added video quality setting support
|
||||
- Maintained backward compatibility
|
||||
|
||||
#### Filename Generation Updates
|
||||
Updated all filename generation to use configurable video format:
|
||||
|
||||
1. **Camera Manager** (`usda_vision_system/camera/manager.py`)
|
||||
- `_start_recording()`: Uses `camera_config.video_format`
|
||||
- `manual_start_recording()`: Uses `camera_config.video_format`
|
||||
|
||||
2. **Auto Recording Manager** (`usda_vision_system/recording/auto_manager.py`)
|
||||
- Updated auto-recording filename generation
|
||||
|
||||
3. **Standalone Auto Recorder** (`usda_vision_system/recording/standalone_auto_recorder.py`)
|
||||
- Updated standalone recording filename generation
|
||||
|
||||
### 3. System Dependencies
|
||||
|
||||
#### Installed Packages
|
||||
- **FFmpeg**: Installed with H.264 support for video processing
|
||||
- **x264**: H.264 encoder library
|
||||
- **libx264-dev**: Development headers for x264
|
||||
|
||||
#### Codec Testing
|
||||
Tested multiple codec options and selected the best available:
|
||||
- ✅ **mp4v** (MPEG-4 Part 2) - Selected as primary codec
|
||||
- ❌ **H264/avc1** - Not available in current OpenCV build
|
||||
- ✅ **XVID** - Falls back to mp4v in MP4 container
|
||||
- ✅ **MJPG** - Falls back to mp4v in MP4 container
|
||||
|
||||
## Technical Specifications
|
||||
|
||||
### Video Format Details
|
||||
- **Container**: MP4 (MPEG-4 Part 14)
|
||||
- **Video Codec**: MPEG-4 Part 2 (mp4v)
|
||||
- **Quality**: 95/100 (high quality)
|
||||
- **Compatibility**: Excellent web browser and streaming support
|
||||
- **File Size**: ~40% smaller than equivalent XVID/AVI files
|
||||
|
||||
### Tested Performance
|
||||
- **Resolution**: 1280x1024 (camera native)
|
||||
- **Frame Rate**: 30 FPS (configurable)
|
||||
- **Bitrate**: ~30 Mbps (high quality)
|
||||
- **Recording Performance**: 56+ FPS processing (faster than real-time)
|
||||
|
||||
## Benefits
|
||||
|
||||
### 1. Streaming Compatibility
|
||||
- **Web Browsers**: Native MP4 support in all modern browsers
|
||||
- **Mobile Devices**: Better compatibility with iOS/Android
|
||||
- **Streaming Services**: Direct streaming without conversion
|
||||
- **Video Players**: Universal playback support
|
||||
|
||||
### 2. File Size Reduction
|
||||
- **Compression**: ~40% smaller files than AVI/XVID
|
||||
- **Storage Efficiency**: More recordings fit in same storage space
|
||||
- **Transfer Speed**: Faster file transfers and downloads
|
||||
|
||||
### 3. Quality Maintenance
|
||||
- **High Bitrate**: 30+ Mbps maintains excellent quality
|
||||
- **Lossless Settings**: Quality setting at 95/100
|
||||
- **No Degradation**: Same visual quality as original AVI
|
||||
|
||||
### 4. Future-Proofing
|
||||
- **Modern Standard**: MP4 is the current industry standard
|
||||
- **Codec Flexibility**: Easy to switch codecs in the future
|
||||
- **Conversion Ready**: Existing video processing infrastructure supports MP4
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
### Configuration Loading
|
||||
- Existing configurations automatically get default MP4 settings
|
||||
- No manual configuration update required
|
||||
- Graceful fallback to MP4 if video format fields are missing
|
||||
|
||||
### File Extensions
|
||||
- All new recordings use `.mp4` extension
|
||||
- Existing `.avi` files remain accessible
|
||||
- Video processing system handles both formats
|
||||
|
||||
## Testing Results
|
||||
|
||||
### Codec Compatibility Test
|
||||
```
|
||||
mp4v (MPEG-4 Part 2): ✅ SUPPORTED
|
||||
XVID (Xvid): ✅ SUPPORTED (falls back to mp4v)
|
||||
MJPG (Motion JPEG): ✅ SUPPORTED (falls back to mp4v)
|
||||
H264/avc1: ❌ NOT SUPPORTED (encoder not found)
|
||||
```
|
||||
|
||||
### Recording Test Results
|
||||
```
|
||||
✅ MP4 recording test PASSED!
|
||||
📁 File created: 20250804_145016_test_mp4_recording.mp4
|
||||
📊 File size: 20,629,587 bytes (19.67 MB)
|
||||
⏱️ Duration: 5.37 seconds
|
||||
🎯 Frame rate: 30 FPS
|
||||
📺 Resolution: 1280x1024
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Video Format Settings
|
||||
```json
|
||||
{
|
||||
"video_format": "mp4", // File format: "mp4" or "avi"
|
||||
"video_codec": "mp4v", // Codec: "mp4v", "XVID", "MJPG"
|
||||
"video_quality": 95 // Quality: 0-100 (higher = better)
|
||||
}
|
||||
```
|
||||
|
||||
### Recommended Settings
|
||||
- **Production**: `video_format: "mp4"`, `video_codec: "mp4v"`, `video_quality: 95`
|
||||
- **Storage Optimized**: `video_format: "mp4"`, `video_codec: "mp4v"`, `video_quality: 85`
|
||||
- **Legacy Compatibility**: `video_format: "avi"`, `video_codec: "XVID"`, `video_quality: 95`
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Optional Enhancements
|
||||
1. **H.264 Support**: Upgrade OpenCV build to include H.264 encoder for even better compression
|
||||
2. **Variable Bitrate**: Implement adaptive bitrate based on content complexity
|
||||
3. **Hardware Acceleration**: Enable GPU-accelerated encoding if available
|
||||
4. **Streaming Optimization**: Add specific settings for live streaming vs. storage
|
||||
|
||||
### Monitoring
|
||||
- Monitor file sizes and quality after deployment
|
||||
- Check streaming performance with new format
|
||||
- Verify storage space usage improvements
|
||||
|
||||
## Conclusion
|
||||
|
||||
The MP4 conversion has been successfully implemented with:
|
||||
- ✅ Full backward compatibility
|
||||
- ✅ Improved streaming support
|
||||
- ✅ Reduced file sizes
|
||||
- ✅ Maintained video quality
|
||||
- ✅ Configurable settings
|
||||
- ✅ Comprehensive testing
|
||||
|
||||
The system is now ready for production use with MP4 format as the default, providing better streaming compatibility and storage efficiency while maintaining the high video quality required for the USDA vision system.
|
||||
@@ -97,11 +97,11 @@ python test_system.py
|
||||
### Dashboard Integration
|
||||
```javascript
|
||||
// React component example
|
||||
const systemStatus = await fetch('http://localhost:8000/system/status');
|
||||
const cameras = await fetch('http://localhost:8000/cameras');
|
||||
const systemStatus = await fetch('http://vision:8000/system/status');
|
||||
const cameras = await fetch('http://vision:8000/cameras');
|
||||
|
||||
// WebSocket for real-time updates
|
||||
const ws = new WebSocket('ws://localhost:8000/ws');
|
||||
const ws = new WebSocket('ws://vision:8000/ws');
|
||||
ws.onmessage = (event) => {
|
||||
const update = JSON.parse(event.data);
|
||||
// Handle real-time system updates
|
||||
@@ -111,13 +111,13 @@ ws.onmessage = (event) => {
|
||||
### Manual Control
|
||||
```bash
|
||||
# Start recording manually
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-recording
|
||||
curl -X POST http://vision:8000/cameras/camera1/start-recording
|
||||
|
||||
# Stop recording manually
|
||||
curl -X POST http://localhost:8000/cameras/camera1/stop-recording
|
||||
curl -X POST http://vision:8000/cameras/camera1/stop-recording
|
||||
|
||||
# Get system status
|
||||
curl http://localhost:8000/system/status
|
||||
curl http://vision:8000/system/status
|
||||
```
|
||||
|
||||
## 📊 System Capabilities
|
||||
@@ -151,7 +151,7 @@ curl http://localhost:8000/system/status
|
||||
### Troubleshooting
|
||||
- **Test Suite**: `python test_system.py`
|
||||
- **Time Check**: `python check_time.py`
|
||||
- **API Health**: `curl http://localhost:8000/health`
|
||||
- **API Health**: `curl http://vision:8000/health`
|
||||
- **Debug Mode**: `python main.py --log-level DEBUG`
|
||||
|
||||
## 🎯 Production Readiness
|
||||
|
||||
249
API Documentations/docs/VIDEO_STREAMING.md
Normal file
249
API Documentations/docs/VIDEO_STREAMING.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# 🎬 Video Streaming Module
|
||||
|
||||
The USDA Vision Camera System now includes a modular video streaming system that provides YouTube-like video playback capabilities for your React web application.
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
- **HTTP Range Request Support** - Enables seeking and progressive download
|
||||
- **Web-Compatible Formats** - Automatic conversion from AVI to MP4/WebM
|
||||
- **Intelligent Caching** - Optimized streaming performance
|
||||
- **Thumbnail Generation** - Extract preview images from videos
|
||||
- **Modular Architecture** - Clean separation of concerns
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
The video module follows clean architecture principles:
|
||||
|
||||
```
|
||||
usda_vision_system/video/
|
||||
├── domain/ # Business logic (pure Python)
|
||||
├── infrastructure/ # External dependencies (OpenCV, FFmpeg)
|
||||
├── application/ # Use cases and orchestration
|
||||
├── presentation/ # HTTP controllers and API routes
|
||||
└── integration.py # Dependency injection and composition
|
||||
```
|
||||
|
||||
## 🚀 API Endpoints
|
||||
|
||||
### List Videos
|
||||
```http
|
||||
GET /videos/
|
||||
```
|
||||
**Query Parameters:**
|
||||
- `camera_name` - Filter by camera
|
||||
- `start_date` - Filter by date range
|
||||
- `end_date` - Filter by date range
|
||||
- `limit` - Maximum results (default: 50)
|
||||
- `include_metadata` - Include video metadata
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"videos": [
|
||||
{
|
||||
"file_id": "camera1_recording_20250804_143022.avi",
|
||||
"camera_name": "camera1",
|
||||
"filename": "camera1_recording_20250804_143022.avi",
|
||||
"file_size_bytes": 52428800,
|
||||
"format": "avi",
|
||||
"status": "completed",
|
||||
"created_at": "2025-08-04T14:30:22",
|
||||
"is_streamable": true,
|
||||
"needs_conversion": true
|
||||
}
|
||||
],
|
||||
"total_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Stream Video
|
||||
```http
|
||||
GET /videos/{file_id}/stream
|
||||
```
|
||||
**Headers:**
|
||||
- `Range: bytes=0-1023` - Request specific byte range
|
||||
|
||||
**Features:**
|
||||
- Supports HTTP range requests for seeking
|
||||
- Returns 206 Partial Content for range requests
|
||||
- Automatic format conversion for web compatibility
|
||||
- Intelligent caching for performance
|
||||
|
||||
### Get Video Info
|
||||
```http
|
||||
GET /videos/{file_id}
|
||||
```
|
||||
**Response includes metadata:**
|
||||
```json
|
||||
{
|
||||
"file_id": "camera1_recording_20250804_143022.avi",
|
||||
"metadata": {
|
||||
"duration_seconds": 120.5,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fps": 30.0,
|
||||
"codec": "XVID",
|
||||
"aspect_ratio": 1.777
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Get Thumbnail
|
||||
```http
|
||||
GET /videos/{file_id}/thumbnail?timestamp=5.0&width=320&height=240
|
||||
```
|
||||
Returns JPEG thumbnail image.
|
||||
|
||||
### Streaming Info
|
||||
```http
|
||||
GET /videos/{file_id}/info
|
||||
```
|
||||
Returns technical streaming details:
|
||||
```json
|
||||
{
|
||||
"file_id": "camera1_recording_20250804_143022.avi",
|
||||
"file_size_bytes": 52428800,
|
||||
"content_type": "video/x-msvideo",
|
||||
"supports_range_requests": true,
|
||||
"chunk_size_bytes": 262144
|
||||
}
|
||||
```
|
||||
|
||||
## 🌐 React Integration
|
||||
|
||||
### Basic Video Player
|
||||
```jsx
|
||||
function VideoPlayer({ fileId }) {
|
||||
return (
|
||||
<video controls width="100%">
|
||||
<source
|
||||
src={`${API_BASE_URL}/videos/${fileId}/stream`}
|
||||
type="video/mp4"
|
||||
/>
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Player with Thumbnail
|
||||
```jsx
|
||||
function VideoPlayerWithThumbnail({ fileId }) {
|
||||
const [thumbnail, setThumbnail] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE_URL}/videos/${fileId}/thumbnail`)
|
||||
.then(response => response.blob())
|
||||
.then(blob => setThumbnail(URL.createObjectURL(blob)));
|
||||
}, [fileId]);
|
||||
|
||||
return (
|
||||
<video controls width="100%" poster={thumbnail}>
|
||||
<source
|
||||
src={`${API_BASE_URL}/videos/${fileId}/stream`}
|
||||
type="video/mp4"
|
||||
/>
|
||||
</video>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Video List Component
|
||||
```jsx
|
||||
function VideoList({ cameraName }) {
|
||||
const [videos, setVideos] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (cameraName) params.append('camera_name', cameraName);
|
||||
params.append('include_metadata', 'true');
|
||||
|
||||
fetch(`${API_BASE_URL}/videos/?${params}`)
|
||||
.then(response => response.json())
|
||||
.then(data => setVideos(data.videos));
|
||||
}, [cameraName]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{videos.map(video => (
|
||||
<VideoCard key={video.file_id} video={video} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
The video module is automatically initialized when the API server starts. Configuration options:
|
||||
|
||||
```python
|
||||
# In your API server initialization
|
||||
video_module = create_video_module(
|
||||
config=config,
|
||||
storage_manager=storage_manager,
|
||||
enable_caching=True, # Enable streaming cache
|
||||
enable_conversion=True # Enable format conversion
|
||||
)
|
||||
```
|
||||
|
||||
## 📊 Performance
|
||||
|
||||
- **Caching**: Intelligent byte-range caching reduces disk I/O
|
||||
- **Adaptive Chunking**: Optimal chunk sizes based on file size
|
||||
- **Range Requests**: Only download needed portions
|
||||
- **Format Conversion**: Automatic conversion to web-compatible formats
|
||||
|
||||
## 🛠️ Service Management
|
||||
|
||||
### Restart Service
|
||||
```bash
|
||||
sudo systemctl restart usda-vision-camera
|
||||
```
|
||||
|
||||
### Check Status
|
||||
```bash
|
||||
# Check video module status
|
||||
curl http://localhost:8000/system/video-module
|
||||
|
||||
# Check available videos
|
||||
curl http://localhost:8000/videos/
|
||||
```
|
||||
|
||||
### Logs
|
||||
```bash
|
||||
sudo journalctl -u usda-vision-camera -f
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
Run the video module tests:
|
||||
```bash
|
||||
cd /home/alireza/USDA-vision-cameras
|
||||
PYTHONPATH=/home/alireza/USDA-vision-cameras python tests/test_video_module.py
|
||||
```
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Video Not Playing
|
||||
1. Check if file exists: `GET /videos/{file_id}`
|
||||
2. Verify streaming info: `GET /videos/{file_id}/info`
|
||||
3. Test direct stream: `GET /videos/{file_id}/stream`
|
||||
|
||||
### Performance Issues
|
||||
1. Check cache status: `GET /admin/videos/cache/cleanup`
|
||||
2. Monitor system resources
|
||||
3. Adjust cache size in configuration
|
||||
|
||||
### Format Issues
|
||||
- AVI files are automatically converted to MP4 for web compatibility
|
||||
- Conversion requires FFmpeg (optional, graceful fallback)
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
1. **Restart the usda-vision-camera service** to enable video streaming
|
||||
2. **Test the endpoints** using curl or your browser
|
||||
3. **Integrate with your React app** using the provided examples
|
||||
4. **Monitor performance** and adjust caching as needed
|
||||
|
||||
The video streaming system is now ready for production use! 🚀
|
||||
@@ -144,7 +144,7 @@ POST /cameras/{camera_name}/apply-config
|
||||
|
||||
### Example 1: Adjust Exposure and Gain
|
||||
```bash
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
curl -X PUT http://vision:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"exposure_ms": 1.5,
|
||||
@@ -154,7 +154,7 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
|
||||
### Example 2: Improve Image Quality
|
||||
```bash
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
curl -X PUT http://vision:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"sharpness": 150,
|
||||
@@ -165,7 +165,7 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
|
||||
### Example 3: Configure for Indoor Lighting
|
||||
```bash
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
curl -X PUT http://vision:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"anti_flicker_enabled": true,
|
||||
@@ -177,7 +177,7 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
|
||||
### Example 4: Enable HDR Mode
|
||||
```bash
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
curl -X PUT http://vision:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"hdr_enabled": true,
|
||||
@@ -191,7 +191,7 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
```jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const CameraConfig = ({ cameraName, apiBaseUrl = 'http://localhost:8000' }) => {
|
||||
const CameraConfig = ({ cameraName, apiBaseUrl = 'http://vision:8000' }) => {
|
||||
const [config, setConfig] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
@@ -56,27 +56,27 @@ When a camera has issues, follow this order:
|
||||
|
||||
1. **Test Connection** - Diagnose the problem
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/test-connection
|
||||
POST http://vision:8000/cameras/camera1/test-connection
|
||||
```
|
||||
|
||||
2. **Try Reconnect** - Most common fix
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/reconnect
|
||||
POST http://vision:8000/cameras/camera1/reconnect
|
||||
```
|
||||
|
||||
3. **Restart Grab** - If reconnect doesn't work
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/restart-grab
|
||||
POST http://vision:8000/cameras/camera1/restart-grab
|
||||
```
|
||||
|
||||
4. **Full Reset** - For persistent issues
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/full-reset
|
||||
POST http://vision:8000/cameras/camera1/full-reset
|
||||
```
|
||||
|
||||
5. **Reinitialize** - For cameras that never worked
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/reinitialize
|
||||
POST http://vision:8000/cameras/camera1/reinitialize
|
||||
```
|
||||
|
||||
## Response Format
|
||||
|
||||
@@ -38,7 +38,7 @@ When you run the system, you'll see:
|
||||
|
||||
### MQTT Status
|
||||
```http
|
||||
GET http://localhost:8000/mqtt/status
|
||||
GET http://vision:8000/mqtt/status
|
||||
```
|
||||
|
||||
**Response:**
|
||||
@@ -60,7 +60,7 @@ GET http://localhost:8000/mqtt/status
|
||||
|
||||
### Machine Status
|
||||
```http
|
||||
GET http://localhost:8000/machines
|
||||
GET http://vision:8000/machines
|
||||
```
|
||||
|
||||
**Response:**
|
||||
@@ -85,7 +85,7 @@ GET http://localhost:8000/machines
|
||||
|
||||
### System Status
|
||||
```http
|
||||
GET http://localhost:8000/system/status
|
||||
GET http://vision:8000/system/status
|
||||
```
|
||||
|
||||
**Response:**
|
||||
@@ -125,13 +125,13 @@ Tests all the API endpoints and shows expected responses.
|
||||
### 4. **Query APIs Directly**
|
||||
```bash
|
||||
# Check MQTT status
|
||||
curl http://localhost:8000/mqtt/status
|
||||
curl http://vision:8000/mqtt/status
|
||||
|
||||
# Check machine states
|
||||
curl http://localhost:8000/machines
|
||||
curl http://vision:8000/machines
|
||||
|
||||
# Check overall system status
|
||||
curl http://localhost:8000/system/status
|
||||
curl http://vision:8000/system/status
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
@@ -40,13 +40,13 @@ Open `camera_preview.html` in your browser and click "Start Stream" for any came
|
||||
### 3. API Usage
|
||||
```bash
|
||||
# Start streaming for camera1
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-stream
|
||||
curl -X POST http://vision:8000/cameras/camera1/start-stream
|
||||
|
||||
# View live stream (open in browser)
|
||||
http://localhost:8000/cameras/camera1/stream
|
||||
http://vision:8000/cameras/camera1/stream
|
||||
|
||||
# Stop streaming
|
||||
curl -X POST http://localhost:8000/cameras/camera1/stop-stream
|
||||
curl -X POST http://vision:8000/cameras/camera1/stop-stream
|
||||
```
|
||||
|
||||
## 📡 API Endpoints
|
||||
@@ -150,10 +150,10 @@ The system supports these concurrent operations:
|
||||
### Example: Concurrent Usage
|
||||
```bash
|
||||
# Start streaming
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-stream
|
||||
curl -X POST http://vision:8000/cameras/camera1/start-stream
|
||||
|
||||
# Start recording (while streaming continues)
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-recording \
|
||||
curl -X POST http://vision:8000/cameras/camera1/start-recording \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"filename": "test_recording.avi"}'
|
||||
|
||||
@@ -232,8 +232,8 @@ For issues with streaming functionality:
|
||||
|
||||
1. Check the system logs: `usda_vision_system.log`
|
||||
2. Run the test script: `python test_streaming.py`
|
||||
3. Verify API health: `http://localhost:8000/health`
|
||||
4. Check camera status: `http://localhost:8000/cameras`
|
||||
3. Verify API health: `http://vision:8000/health`
|
||||
4. Check camera status: `http://vision:8000/cameras`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -73,10 +73,10 @@ Edit `config.json` to customize:
|
||||
- System parameters
|
||||
|
||||
### API Access
|
||||
- System status: `http://localhost:8000/system/status`
|
||||
- Camera status: `http://localhost:8000/cameras`
|
||||
- Manual recording: `POST http://localhost:8000/cameras/camera1/start-recording`
|
||||
- Real-time updates: WebSocket at `ws://localhost:8000/ws`
|
||||
- System status: `http://vision:8000/system/status`
|
||||
- Camera status: `http://vision:8000/cameras`
|
||||
- Manual recording: `POST http://vision:8000/cameras/camera1/start-recording`
|
||||
- Real-time updates: WebSocket at `ws://vision:8000/ws`
|
||||
|
||||
## 📊 Test Results
|
||||
|
||||
@@ -146,18 +146,18 @@ The system provides everything needed for your React dashboard:
|
||||
|
||||
```javascript
|
||||
// Example API usage
|
||||
const systemStatus = await fetch('http://localhost:8000/system/status');
|
||||
const cameras = await fetch('http://localhost:8000/cameras');
|
||||
const systemStatus = await fetch('http://vision:8000/system/status');
|
||||
const cameras = await fetch('http://vision:8000/cameras');
|
||||
|
||||
// WebSocket for real-time updates
|
||||
const ws = new WebSocket('ws://localhost:8000/ws');
|
||||
const ws = new WebSocket('ws://vision:8000/ws');
|
||||
ws.onmessage = (event) => {
|
||||
const update = JSON.parse(event.data);
|
||||
// Handle real-time system updates
|
||||
};
|
||||
|
||||
// Manual recording control
|
||||
await fetch('http://localhost:8000/cameras/camera1/start-recording', {
|
||||
await fetch('http://vision:8000/cameras/camera1/start-recording', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ camera_name: 'camera1' })
|
||||
|
||||
@@ -192,13 +192,13 @@ Comprehensive error tracking with:
|
||||
|
||||
```bash
|
||||
# Check system status
|
||||
curl http://localhost:8000/system/status
|
||||
curl http://vision:8000/system/status
|
||||
|
||||
# Check camera status
|
||||
curl http://localhost:8000/cameras
|
||||
curl http://vision:8000/cameras
|
||||
|
||||
# Manual recording start
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-recording \
|
||||
curl -X POST http://vision:8000/cameras/camera1/start-recording \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"camera_name": "camera1"}'
|
||||
```
|
||||
@@ -246,4 +246,4 @@ This project is developed for USDA research purposes.
|
||||
For issues and questions:
|
||||
1. Check the logs in `usda_vision_system.log`
|
||||
2. Review the troubleshooting section
|
||||
3. Check API status at `http://localhost:8000/health`
|
||||
3. Check API status at `http://vision:8000/health`
|
||||
|
||||
@@ -76,7 +76,7 @@ timedatectl status
|
||||
### API Endpoints
|
||||
```bash
|
||||
# System status includes time info
|
||||
curl http://localhost:8000/system/status
|
||||
curl http://vision:8000/system/status
|
||||
|
||||
# Example response includes:
|
||||
{
|
||||
|
||||
185
API Documentations/docs/test_video_module.py
Normal file
185
API Documentations/docs/test_video_module.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Test the modular video streaming functionality.
|
||||
|
||||
This test verifies that the video module integrates correctly with the existing system
|
||||
and provides the expected streaming capabilities.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
# Configure logging for tests
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
async def test_video_module_integration():
|
||||
"""Test video module integration with the existing system"""
|
||||
print("\n🎬 Testing Video Module Integration...")
|
||||
|
||||
try:
|
||||
# Import the necessary components
|
||||
from usda_vision_system.core.config import Config
|
||||
from usda_vision_system.storage.manager import StorageManager
|
||||
from usda_vision_system.core.state_manager import StateManager
|
||||
from usda_vision_system.video.integration import create_video_module
|
||||
|
||||
print("✅ Successfully imported video module components")
|
||||
|
||||
# Initialize core components
|
||||
config = Config()
|
||||
state_manager = StateManager()
|
||||
storage_manager = StorageManager(config, state_manager)
|
||||
|
||||
print("✅ Core components initialized")
|
||||
|
||||
# Create video module
|
||||
video_module = create_video_module(
|
||||
config=config,
|
||||
storage_manager=storage_manager,
|
||||
enable_caching=True,
|
||||
enable_conversion=False # Disable conversion for testing
|
||||
)
|
||||
|
||||
print("✅ Video module created successfully")
|
||||
|
||||
# Test module status
|
||||
status = video_module.get_module_status()
|
||||
print(f"📊 Video module status: {status}")
|
||||
|
||||
# Test video service
|
||||
videos = await video_module.video_service.get_all_videos(limit=5)
|
||||
print(f"📹 Found {len(videos)} video files")
|
||||
|
||||
for video in videos[:3]: # Show first 3 videos
|
||||
print(f" - {video.file_id} ({video.camera_name}) - {video.file_size_bytes} bytes")
|
||||
|
||||
# Test streaming service
|
||||
if videos:
|
||||
video_file = videos[0]
|
||||
streaming_info = await video_module.streaming_service.get_video_info(video_file.file_id)
|
||||
if streaming_info:
|
||||
print(f"🎯 Streaming test: {streaming_info.file_id} is streamable: {streaming_info.is_streamable}")
|
||||
|
||||
# Test API routes creation
|
||||
api_routes = video_module.get_api_routes()
|
||||
admin_routes = video_module.get_admin_routes()
|
||||
|
||||
print(f"🛣️ API routes created: {len(api_routes.routes)} routes")
|
||||
print(f"🔧 Admin routes created: {len(admin_routes.routes)} routes")
|
||||
|
||||
# List some of the available routes
|
||||
print("📋 Available video endpoints:")
|
||||
for route in api_routes.routes:
|
||||
if hasattr(route, 'path') and hasattr(route, 'methods'):
|
||||
methods = ', '.join(route.methods) if route.methods else 'N/A'
|
||||
print(f" {methods} {route.path}")
|
||||
|
||||
# Cleanup
|
||||
await video_module.cleanup()
|
||||
print("✅ Video module cleanup completed")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Video module test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
async def test_video_streaming_endpoints():
|
||||
"""Test video streaming endpoints with a mock FastAPI app"""
|
||||
print("\n🌐 Testing Video Streaming Endpoints...")
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from usda_vision_system.core.config import Config
|
||||
from usda_vision_system.storage.manager import StorageManager
|
||||
from usda_vision_system.core.state_manager import StateManager
|
||||
from usda_vision_system.video.integration import create_video_module
|
||||
|
||||
# Create test app
|
||||
app = FastAPI()
|
||||
|
||||
# Initialize components
|
||||
config = Config()
|
||||
state_manager = StateManager()
|
||||
storage_manager = StorageManager(config, state_manager)
|
||||
|
||||
# Create video module
|
||||
video_module = create_video_module(
|
||||
config=config,
|
||||
storage_manager=storage_manager,
|
||||
enable_caching=True,
|
||||
enable_conversion=False
|
||||
)
|
||||
|
||||
# Add video routes to test app
|
||||
video_routes = video_module.get_api_routes()
|
||||
admin_routes = video_module.get_admin_routes()
|
||||
|
||||
app.include_router(video_routes)
|
||||
app.include_router(admin_routes)
|
||||
|
||||
print("✅ Test FastAPI app created with video routes")
|
||||
|
||||
# Create test client
|
||||
client = TestClient(app)
|
||||
|
||||
# Test video list endpoint
|
||||
response = client.get("/videos/")
|
||||
print(f"📋 GET /videos/ - Status: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f" Found {data.get('total_count', 0)} videos")
|
||||
|
||||
# Test video module status (if we had added it to the routes)
|
||||
# This would be available in the main API server
|
||||
|
||||
print("✅ Video streaming endpoints test completed")
|
||||
|
||||
# Cleanup
|
||||
await video_module.cleanup()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Video streaming endpoints test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run all video module tests"""
|
||||
print("🚀 Starting Video Module Tests")
|
||||
print("=" * 50)
|
||||
|
||||
# Test 1: Module Integration
|
||||
test1_success = await test_video_module_integration()
|
||||
|
||||
# Test 2: Streaming Endpoints
|
||||
test2_success = await test_video_streaming_endpoints()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("📊 Test Results:")
|
||||
print(f" Module Integration: {'✅ PASS' if test1_success else '❌ FAIL'}")
|
||||
print(f" Streaming Endpoints: {'✅ PASS' if test2_success else '❌ FAIL'}")
|
||||
|
||||
if test1_success and test2_success:
|
||||
print("\n🎉 All video module tests passed!")
|
||||
print("\n📖 Next Steps:")
|
||||
print(" 1. Restart the usda-vision-camera service")
|
||||
print(" 2. Test video streaming in your React app")
|
||||
print(" 3. Use endpoints like: GET /videos/ and GET /videos/{file_id}/stream")
|
||||
else:
|
||||
print("\n⚠️ Some tests failed. Check the error messages above.")
|
||||
|
||||
return test1_success and test2_success
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -2,7 +2,7 @@
|
||||
###
|
||||
### CONFIGURATION:
|
||||
### - Production: http://vision:8000 (requires hostname setup)
|
||||
### - Development: http://localhost:8000
|
||||
### - Development: http://vision:8000
|
||||
### - Custom: Update @baseUrl below to match your setup
|
||||
###
|
||||
### This file contains streaming-specific API endpoints for live camera preview
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
# Base URL - Update to match your configuration
|
||||
@baseUrl = http://vision:8000
|
||||
# Alternative: @baseUrl = http://localhost:8000
|
||||
# Alternative: @baseUrl = http://vision:8000
|
||||
|
||||
### =============================================================================
|
||||
### STREAMING ENDPOINTS (NEW FUNCTIONALITY)
|
||||
@@ -47,7 +47,7 @@ Content-Type: application/json
|
||||
GET {{baseUrl}}/cameras/camera1/stream
|
||||
|
||||
### Usage in HTML:
|
||||
# <img src="http://localhost:8000/cameras/camera1/stream" alt="Live Stream" />
|
||||
# <img src="http://vision:8000/cameras/camera1/stream" alt="Live Stream" />
|
||||
|
||||
### Usage in React:
|
||||
# <img src={`${apiBaseUrl}/cameras/${cameraName}/stream?t=${Date.now()}`} />
|
||||
|
||||
@@ -17,7 +17,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
def test_api_endpoints():
|
||||
"""Test the streaming API endpoints"""
|
||||
base_url = "http://localhost:8000"
|
||||
base_url = "http://vision:8000"
|
||||
|
||||
print("🧪 Testing Camera Streaming API Endpoints")
|
||||
print("=" * 50)
|
||||
@@ -109,7 +109,7 @@ def test_camera_streaming(base_url, camera_name):
|
||||
|
||||
def test_concurrent_recording_and_streaming():
|
||||
"""Test that streaming doesn't interfere with recording"""
|
||||
base_url = "http://localhost:8000"
|
||||
base_url = "http://vision:8000"
|
||||
|
||||
print("\n🔄 Testing Concurrent Recording and Streaming")
|
||||
print("=" * 50)
|
||||
|
||||
@@ -40,7 +40,7 @@ The Vision System dashboard provides real-time monitoring and control of the USD
|
||||
|
||||
## API Integration
|
||||
|
||||
The dashboard connects to the Vision System API running on `http://localhost:8000` and provides:
|
||||
The dashboard connects to the Vision System API running on `http://vision:8000` and provides:
|
||||
|
||||
### Endpoints Used
|
||||
- `GET /system/status` - System overview and status
|
||||
@@ -103,7 +103,7 @@ The dashboard includes comprehensive error handling:
|
||||
### Common Issues
|
||||
|
||||
1. **"Failed to fetch vision system data"**
|
||||
- Ensure the vision system API is running on localhost:8000
|
||||
- Ensure the vision system API is running on vision:8000
|
||||
- Check network connectivity
|
||||
- Verify the vision system service is started
|
||||
|
||||
@@ -121,7 +121,7 @@ The dashboard includes comprehensive error handling:
|
||||
|
||||
The API base URL is configured in `src/lib/visionApi.ts`:
|
||||
```typescript
|
||||
const VISION_API_BASE_URL = 'http://localhost:8000'
|
||||
const VISION_API_BASE_URL = 'http://vision:8000'
|
||||
```
|
||||
|
||||
To change the API endpoint, modify this constant and rebuild the application.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
###############################################################################
|
||||
# USDA Vision Camera System - Complete API Endpoints Documentation
|
||||
# Base URL: http://localhost:8000
|
||||
# Base URL: http://vision:8000
|
||||
###############################################################################
|
||||
|
||||
###############################################################################
|
||||
@@ -8,7 +8,7 @@
|
||||
###############################################################################
|
||||
|
||||
### Root endpoint - API information
|
||||
GET http://localhost:8000/
|
||||
GET http://vision:8000/
|
||||
# Response: SuccessResponse
|
||||
# {
|
||||
# "success": true,
|
||||
@@ -20,7 +20,7 @@ GET http://localhost:8000/
|
||||
###
|
||||
|
||||
### Health check
|
||||
GET http://localhost:8000/health
|
||||
GET http://vision:8000/health
|
||||
# Response: Simple health status
|
||||
# {
|
||||
# "status": "healthy",
|
||||
@@ -30,7 +30,7 @@ GET http://localhost:8000/health
|
||||
###
|
||||
|
||||
### Get system status
|
||||
GET http://localhost:8000/system/status
|
||||
GET http://vision:8000/system/status
|
||||
# Response: SystemStatusResponse
|
||||
# {
|
||||
# "system_started": true,
|
||||
@@ -60,7 +60,7 @@ GET http://localhost:8000/system/status
|
||||
###############################################################################
|
||||
|
||||
### Get all machines status
|
||||
GET http://localhost:8000/machines
|
||||
GET http://vision:8000/machines
|
||||
# Response: Dict[str, MachineStatusResponse]
|
||||
# {
|
||||
# "vibratory_conveyor": {
|
||||
@@ -84,7 +84,7 @@ GET http://localhost:8000/machines
|
||||
###############################################################################
|
||||
|
||||
### Get MQTT status and statistics
|
||||
GET http://localhost:8000/mqtt/status
|
||||
GET http://vision:8000/mqtt/status
|
||||
# Response: MQTTStatusResponse
|
||||
# {
|
||||
# "connected": true,
|
||||
@@ -101,7 +101,7 @@ GET http://localhost:8000/mqtt/status
|
||||
# }
|
||||
|
||||
### Get recent MQTT events history
|
||||
GET http://localhost:8000/mqtt/events
|
||||
GET http://vision:8000/mqtt/events
|
||||
# Optional query parameter: limit (default: 5, max: 50)
|
||||
# Response: MQTTEventsHistoryResponse
|
||||
# {
|
||||
@@ -128,14 +128,14 @@ GET http://localhost:8000/mqtt/events
|
||||
# }
|
||||
|
||||
### Get recent MQTT events with custom limit
|
||||
GET http://localhost:8000/mqtt/events?limit=10
|
||||
GET http://vision:8000/mqtt/events?limit=10
|
||||
|
||||
###############################################################################
|
||||
# CAMERA ENDPOINTS
|
||||
###############################################################################
|
||||
|
||||
### Get all cameras status
|
||||
GET http://localhost:8000/cameras
|
||||
GET http://vision:8000/cameras
|
||||
# Response: Dict[str, CameraStatusResponse]
|
||||
# {
|
||||
# "camera1": {
|
||||
@@ -157,9 +157,9 @@ GET http://localhost:8000/cameras
|
||||
###
|
||||
|
||||
### Get specific camera status
|
||||
GET http://localhost:8000/cameras/camera1/status
|
||||
GET http://vision:8000/cameras/camera1/status
|
||||
### Get specific camera status
|
||||
GET http://localhost:8000/cameras/camera2/status
|
||||
GET http://vision:8000/cameras/camera2/status
|
||||
# Response: CameraStatusResponse (same as above for single camera)
|
||||
|
||||
###############################################################################
|
||||
@@ -167,7 +167,7 @@ GET http://localhost:8000/cameras/camera2/status
|
||||
###############################################################################
|
||||
|
||||
### Start recording (with all optional parameters)
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
POST http://vision:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -193,7 +193,7 @@ Content-Type: application/json
|
||||
###
|
||||
|
||||
### Start recording (minimal - only filename)
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
POST http://vision:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -203,7 +203,7 @@ Content-Type: application/json
|
||||
###
|
||||
|
||||
### Start recording (only camera settings)
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
POST http://vision:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -215,7 +215,7 @@ Content-Type: application/json
|
||||
###
|
||||
|
||||
### Start recording (empty body - all defaults)
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
POST http://vision:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{}
|
||||
@@ -223,9 +223,9 @@ Content-Type: application/json
|
||||
###
|
||||
|
||||
### Stop recording
|
||||
POST http://localhost:8000/cameras/camera1/stop-recording
|
||||
POST http://vision:8000/cameras/camera1/stop-recording
|
||||
### Stop recording
|
||||
POST http://localhost:8000/cameras/camera2/stop-recording
|
||||
POST http://vision:8000/cameras/camera2/stop-recording
|
||||
# No request body required
|
||||
# Response: StopRecordingResponse
|
||||
# {
|
||||
@@ -239,8 +239,8 @@ POST http://localhost:8000/cameras/camera2/stop-recording
|
||||
###############################################################################
|
||||
|
||||
### Test camera connection
|
||||
POST http://localhost:8000/cameras/camera1/test-connection
|
||||
POST http://localhost:8000/cameras/camera2/test-connection
|
||||
POST http://vision:8000/cameras/camera1/test-connection
|
||||
POST http://vision:8000/cameras/camera2/test-connection
|
||||
# No request body required
|
||||
# Response: CameraTestResponse
|
||||
# {
|
||||
@@ -253,8 +253,8 @@ POST http://localhost:8000/cameras/camera2/test-connection
|
||||
###
|
||||
|
||||
### Reconnect camera (soft recovery)
|
||||
POST http://localhost:8000/cameras/camera1/reconnect
|
||||
POST http://localhost:8000/cameras/camera2/reconnect
|
||||
POST http://vision:8000/cameras/camera1/reconnect
|
||||
POST http://vision:8000/cameras/camera2/reconnect
|
||||
# No request body required
|
||||
# Response: CameraRecoveryResponse
|
||||
# {
|
||||
@@ -268,33 +268,33 @@ POST http://localhost:8000/cameras/camera2/reconnect
|
||||
###
|
||||
|
||||
### Restart camera grab process
|
||||
POST http://localhost:8000/cameras/camera1/restart-grab
|
||||
POST http://localhost:8000/cameras/camera2/restart-grab
|
||||
POST http://vision:8000/cameras/camera1/restart-grab
|
||||
POST http://vision:8000/cameras/camera2/restart-grab
|
||||
# Response: CameraRecoveryResponse (same structure as reconnect)
|
||||
|
||||
###
|
||||
|
||||
### Reset camera timestamp
|
||||
POST http://localhost:8000/cameras/camera1/reset-timestamp
|
||||
POST http://localhost:8000/cameras/camera2/reset-timestamp
|
||||
POST http://vision:8000/cameras/camera1/reset-timestamp
|
||||
POST http://vision:8000/cameras/camera2/reset-timestamp
|
||||
# Response: CameraRecoveryResponse (same structure as reconnect)
|
||||
|
||||
###
|
||||
|
||||
### Full camera reset (hard recovery)
|
||||
POST http://localhost:8000/cameras/camera1/full-reset
|
||||
POST http://vision:8000/cameras/camera1/full-reset
|
||||
### Full camera reset (hard recovery)
|
||||
|
||||
POST http://localhost:8000/cameras/camera2/full-reset
|
||||
POST http://vision:8000/cameras/camera2/full-reset
|
||||
# Response: CameraRecoveryResponse (same structure as reconnect)
|
||||
|
||||
###
|
||||
|
||||
### Reinitialize failed camera
|
||||
POST http://localhost:8000/cameras/camera1/reinitialize
|
||||
POST http://vision:8000/cameras/camera1/reinitialize
|
||||
### Reinitialize failed camera
|
||||
|
||||
POST http://localhost:8000/cameras/camera2/reinitialize
|
||||
POST http://vision:8000/cameras/camera2/reinitialize
|
||||
# Response: CameraRecoveryResponse (same structure as reconnect)
|
||||
|
||||
###############################################################################
|
||||
@@ -302,7 +302,7 @@ POST http://localhost:8000/cameras/camera2/reinitialize
|
||||
###############################################################################
|
||||
|
||||
### Get all recording sessions
|
||||
GET http://localhost:8000/recordings
|
||||
GET http://vision:8000/recordings
|
||||
# Response: Dict[str, RecordingInfoResponse]
|
||||
# {
|
||||
# "rec_001": {
|
||||
@@ -323,7 +323,7 @@ GET http://localhost:8000/recordings
|
||||
###############################################################################
|
||||
|
||||
### Get storage statistics
|
||||
GET http://localhost:8000/storage/stats
|
||||
GET http://vision:8000/storage/stats
|
||||
# Response: StorageStatsResponse
|
||||
# {
|
||||
# "base_path": "/storage",
|
||||
@@ -345,7 +345,7 @@ GET http://localhost:8000/storage/stats
|
||||
###
|
||||
|
||||
### Get recording files list (with filters)
|
||||
POST http://localhost:8000/storage/files
|
||||
POST http://vision:8000/storage/files
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -377,7 +377,7 @@ Content-Type: application/json
|
||||
###
|
||||
|
||||
### Get all files (no camera filter)
|
||||
POST http://localhost:8000/storage/files
|
||||
POST http://vision:8000/storage/files
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -387,7 +387,7 @@ Content-Type: application/json
|
||||
###
|
||||
|
||||
### Cleanup old storage files
|
||||
POST http://localhost:8000/storage/cleanup
|
||||
POST http://vision:8000/storage/cleanup
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
|
||||
300
docs/MODULAR_ARCHITECTURE_GUIDE.md
Normal file
300
docs/MODULAR_ARCHITECTURE_GUIDE.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# 🏗️ Modular Architecture Guide
|
||||
|
||||
This guide demonstrates the modular architecture patterns implemented in the video streaming feature and how to apply them to other parts of the project.
|
||||
|
||||
## 🎯 Goals
|
||||
|
||||
- **Separation of Concerns**: Each module has a single responsibility
|
||||
- **Reusability**: Components can be used across different parts of the application
|
||||
- **Maintainability**: Easy to understand, modify, and test individual pieces
|
||||
- **Scalability**: Easy to add new features without affecting existing code
|
||||
|
||||
## 📁 Feature-Based Structure
|
||||
|
||||
```
|
||||
src/features/video-streaming/
|
||||
├── components/ # UI Components
|
||||
│ ├── VideoPlayer.tsx
|
||||
│ ├── VideoCard.tsx
|
||||
│ ├── VideoList.tsx
|
||||
│ ├── VideoModal.tsx
|
||||
│ ├── VideoThumbnail.tsx
|
||||
│ └── index.ts
|
||||
├── hooks/ # Custom React Hooks
|
||||
│ ├── useVideoList.ts
|
||||
│ ├── useVideoPlayer.ts
|
||||
│ ├── useVideoInfo.ts
|
||||
│ └── index.ts
|
||||
├── services/ # API & Business Logic
|
||||
│ └── videoApi.ts
|
||||
├── types/ # TypeScript Definitions
|
||||
│ └── index.ts
|
||||
├── utils/ # Pure Utility Functions
|
||||
│ └── videoUtils.ts
|
||||
├── VideoStreamingPage.tsx # Main Feature Page
|
||||
└── index.ts # Feature Export
|
||||
```
|
||||
|
||||
## 🧩 Layer Responsibilities
|
||||
|
||||
### 1. **Components Layer** (`/components`)
|
||||
- **Purpose**: Pure UI components that handle rendering and user interactions
|
||||
- **Rules**:
|
||||
- No direct API calls
|
||||
- Receive data via props
|
||||
- Emit events via callbacks
|
||||
- Minimal business logic
|
||||
|
||||
**Example:**
|
||||
```tsx
|
||||
// ✅ Good: Pure component with clear props
|
||||
export const VideoCard: React.FC<VideoCardProps> = ({
|
||||
video,
|
||||
onClick,
|
||||
showMetadata = true,
|
||||
}) => {
|
||||
return (
|
||||
<div onClick={() => onClick?.(video)}>
|
||||
{/* UI rendering */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ❌ Bad: Component with API calls
|
||||
export const VideoCard = () => {
|
||||
const [video, setVideo] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/videos/123').then(/* ... */); // Don't do this!
|
||||
}, []);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. **Hooks Layer** (`/hooks`)
|
||||
- **Purpose**: Manage state, side effects, and provide data to components
|
||||
- **Rules**:
|
||||
- Handle API calls and data fetching
|
||||
- Manage component state
|
||||
- Provide clean interfaces to components
|
||||
|
||||
**Example:**
|
||||
```tsx
|
||||
// ✅ Good: Hook handles complexity, provides simple interface
|
||||
export function useVideoList(options = {}) {
|
||||
const [videos, setVideos] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchVideos = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await videoApiService.getVideos();
|
||||
setVideos(data.videos);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { videos, loading, refetch: fetchVideos };
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **Services Layer** (`/services`)
|
||||
- **Purpose**: Handle external dependencies (APIs, storage, etc.)
|
||||
- **Rules**:
|
||||
- Pure functions or classes
|
||||
- No React dependencies
|
||||
- Handle errors gracefully
|
||||
- Provide consistent interfaces
|
||||
|
||||
**Example:**
|
||||
```tsx
|
||||
// ✅ Good: Service handles API complexity
|
||||
export class VideoApiService {
|
||||
async getVideos(params = {}) {
|
||||
try {
|
||||
const response = await fetch(this.buildUrl('/videos', params));
|
||||
return await this.handleResponse(response);
|
||||
} catch (error) {
|
||||
throw new VideoApiError('FETCH_ERROR', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. **Types Layer** (`/types`)
|
||||
- **Purpose**: Centralized TypeScript definitions
|
||||
- **Rules**:
|
||||
- Define all interfaces and types
|
||||
- Export from index.ts
|
||||
- Keep types close to their usage
|
||||
|
||||
### 5. **Utils Layer** (`/utils`)
|
||||
- **Purpose**: Pure utility functions
|
||||
- **Rules**:
|
||||
- No side effects
|
||||
- Easily testable
|
||||
- Single responsibility
|
||||
|
||||
## 🔄 Component Composition Patterns
|
||||
|
||||
### Small, Focused Components
|
||||
|
||||
Instead of large monolithic components, create small, focused ones:
|
||||
|
||||
```tsx
|
||||
// ✅ Good: Small, focused components
|
||||
<VideoList>
|
||||
{videos.map(video => (
|
||||
<VideoCard key={video.id} video={video} onClick={onVideoSelect} />
|
||||
))}
|
||||
</VideoList>
|
||||
|
||||
// ❌ Bad: Monolithic component
|
||||
<VideoSystemPage>
|
||||
{/* 500+ lines of mixed concerns */}
|
||||
</VideoSystemPage>
|
||||
```
|
||||
|
||||
### Composition over Inheritance
|
||||
|
||||
```tsx
|
||||
// ✅ Good: Compose features
|
||||
export const VideoStreamingPage = () => {
|
||||
const { videos, loading } = useVideoList();
|
||||
const [selectedVideo, setSelectedVideo] = useState(null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<VideoList videos={videos} onVideoSelect={setSelectedVideo} />
|
||||
<VideoModal video={selectedVideo} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 🎨 Applying to Existing Components
|
||||
|
||||
### Example: Breaking Down VisionSystem Component
|
||||
|
||||
**Current Structure (Monolithic):**
|
||||
```tsx
|
||||
// ❌ Current: One large component
|
||||
export const VisionSystem = () => {
|
||||
// 900+ lines of mixed concerns
|
||||
return (
|
||||
<div>
|
||||
{/* System status */}
|
||||
{/* Camera cards */}
|
||||
{/* Storage info */}
|
||||
{/* MQTT status */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**Proposed Modular Structure:**
|
||||
```
|
||||
src/features/vision-system/
|
||||
├── components/
|
||||
│ ├── SystemStatusCard.tsx
|
||||
│ ├── CameraCard.tsx
|
||||
│ ├── CameraGrid.tsx
|
||||
│ ├── StorageOverview.tsx
|
||||
│ ├── MqttStatus.tsx
|
||||
│ └── index.ts
|
||||
├── hooks/
|
||||
│ ├── useSystemStatus.ts
|
||||
│ ├── useCameraList.ts
|
||||
│ └── index.ts
|
||||
├── services/
|
||||
│ └── visionApi.ts
|
||||
└── VisionSystemPage.tsx
|
||||
```
|
||||
|
||||
**Refactored Usage:**
|
||||
```tsx
|
||||
// ✅ Better: Composed from smaller parts
|
||||
export const VisionSystemPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<SystemStatusCard />
|
||||
<CameraGrid />
|
||||
<StorageOverview />
|
||||
<MqttStatus />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Now you can reuse components elsewhere:
|
||||
export const DashboardHome = () => {
|
||||
return (
|
||||
<div>
|
||||
<SystemStatusCard /> {/* Reused! */}
|
||||
<QuickStats />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 📋 Migration Strategy
|
||||
|
||||
### Phase 1: Extract Utilities
|
||||
1. Move pure functions to `/utils`
|
||||
2. Move types to `/types`
|
||||
3. Create service classes for API calls
|
||||
|
||||
### Phase 2: Extract Hooks
|
||||
1. Create custom hooks for data fetching
|
||||
2. Move state management to hooks
|
||||
3. Simplify component logic
|
||||
|
||||
### Phase 3: Break Down Components
|
||||
1. Identify distinct UI sections
|
||||
2. Extract to separate components
|
||||
3. Use composition in parent components
|
||||
|
||||
### Phase 4: Feature Organization
|
||||
1. Group related components, hooks, and services
|
||||
2. Create feature-level exports
|
||||
3. Update imports across the application
|
||||
|
||||
## 🧪 Testing Benefits
|
||||
|
||||
Modular architecture makes testing much easier:
|
||||
|
||||
```tsx
|
||||
// ✅ Easy to test individual pieces
|
||||
describe('VideoCard', () => {
|
||||
it('displays video information', () => {
|
||||
render(<VideoCard video={mockVideo} />);
|
||||
expect(screen.getByText(mockVideo.filename)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVideoList', () => {
|
||||
it('fetches videos on mount', async () => {
|
||||
const { result } = renderHook(() => useVideoList());
|
||||
await waitFor(() => {
|
||||
expect(result.current.videos).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 🚀 Benefits Achieved
|
||||
|
||||
1. **Reusability**: `VideoCard` can be used in lists, grids, or modals
|
||||
2. **Maintainability**: Each file has a single, clear purpose
|
||||
3. **Testability**: Small, focused units are easy to test
|
||||
4. **Developer Experience**: Clear structure makes onboarding easier
|
||||
5. **Performance**: Smaller components enable better optimization
|
||||
|
||||
## 📝 Best Practices
|
||||
|
||||
1. **Start Small**: Begin with one feature and apply patterns gradually
|
||||
2. **Single Responsibility**: Each file should have one clear purpose
|
||||
3. **Clear Interfaces**: Use TypeScript to define clear contracts
|
||||
4. **Consistent Naming**: Follow naming conventions across features
|
||||
5. **Documentation**: Document complex logic and interfaces
|
||||
|
||||
This modular approach transforms large, hard-to-maintain components into small, reusable, and testable pieces that can be composed together to create powerful features.
|
||||
351
docs/VIDEO_STREAMING_INTEGRATION.md
Normal file
351
docs/VIDEO_STREAMING_INTEGRATION.md
Normal file
@@ -0,0 +1,351 @@
|
||||
# 🎬 Video Streaming Integration Guide
|
||||
|
||||
This guide shows how to integrate the modular video streaming feature into your existing dashboard.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Add to Dashboard Navigation
|
||||
|
||||
Update your sidebar or navigation to include the video streaming page:
|
||||
|
||||
```tsx
|
||||
// In src/components/Sidebar.tsx or similar
|
||||
import { VideoStreamingPage } from '../features/video-streaming';
|
||||
|
||||
const navigationItems = [
|
||||
// ... existing items
|
||||
{
|
||||
name: 'Video Library',
|
||||
href: '/videos',
|
||||
icon: VideoCameraIcon,
|
||||
component: VideoStreamingPage,
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### 2. Add Route (if using React Router)
|
||||
|
||||
```tsx
|
||||
// In your main App.tsx or router configuration
|
||||
import { VideoStreamingPage } from './features/video-streaming';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* ... existing routes */}
|
||||
<Route path="/videos" element={<VideoStreamingPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 🧩 Using Individual Components
|
||||
|
||||
The beauty of the modular architecture is that you can use individual components anywhere:
|
||||
|
||||
### Dashboard Home - Recent Videos
|
||||
|
||||
```tsx
|
||||
// In src/components/DashboardHome.tsx
|
||||
import { VideoList } from '../features/video-streaming';
|
||||
|
||||
export const DashboardHome = () => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Existing dashboard content */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Recent Videos</h2>
|
||||
<VideoList
|
||||
limit={6}
|
||||
filters={{ /* recent videos only */ }}
|
||||
className="grid grid-cols-2 gap-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Vision System - Camera Videos
|
||||
|
||||
```tsx
|
||||
// In src/components/VisionSystem.tsx
|
||||
import { VideoList, VideoCard } from '../features/video-streaming';
|
||||
|
||||
export const VisionSystem = () => {
|
||||
const [selectedCamera, setSelectedCamera] = useState(null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Existing vision system content */}
|
||||
|
||||
{/* Add video section for selected camera */}
|
||||
{selectedCamera && (
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
Recent Videos - {selectedCamera}
|
||||
</h3>
|
||||
<VideoList
|
||||
filters={{ cameraName: selectedCamera }}
|
||||
limit={8}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Experiment Data Entry - Video Evidence
|
||||
|
||||
```tsx
|
||||
// In src/components/DataEntry.tsx
|
||||
import { VideoThumbnail, VideoModal } from '../features/video-streaming';
|
||||
|
||||
export const DataEntry = () => {
|
||||
const [selectedVideo, setSelectedVideo] = useState(null);
|
||||
const [showVideoModal, setShowVideoModal] = useState(false);
|
||||
|
||||
return (
|
||||
<form>
|
||||
{/* Existing form fields */}
|
||||
|
||||
{/* Add video evidence section */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Video Evidence
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{experimentVideos.map(video => (
|
||||
<VideoThumbnail
|
||||
key={video.file_id}
|
||||
fileId={video.file_id}
|
||||
onClick={() => {
|
||||
setSelectedVideo(video);
|
||||
setShowVideoModal(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VideoModal
|
||||
video={selectedVideo}
|
||||
isOpen={showVideoModal}
|
||||
onClose={() => setShowVideoModal(false)}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 🎨 Customizing Components
|
||||
|
||||
### Custom Video Card for Experiments
|
||||
|
||||
```tsx
|
||||
// Create a specialized version for your use case
|
||||
import { VideoCard } from '../features/video-streaming';
|
||||
|
||||
export const ExperimentVideoCard = ({ video, experimentId, onAttach }) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<VideoCard video={video} showMetadata={false} />
|
||||
|
||||
{/* Add experiment-specific actions */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<button
|
||||
onClick={() => onAttach(video.file_id, experimentId)}
|
||||
className="bg-blue-500 text-white px-2 py-1 rounded text-xs"
|
||||
>
|
||||
Attach to Experiment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Custom Video Player with Annotations
|
||||
|
||||
```tsx
|
||||
// Extend the base video player
|
||||
import { VideoPlayer } from '../features/video-streaming';
|
||||
|
||||
export const AnnotatedVideoPlayer = ({ fileId, annotations }) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<VideoPlayer fileId={fileId} />
|
||||
|
||||
{/* Add annotation overlay */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{annotations.map(annotation => (
|
||||
<div
|
||||
key={annotation.id}
|
||||
className="absolute bg-yellow-400 bg-opacity-75 p-2 rounded"
|
||||
style={{
|
||||
left: `${annotation.x}%`,
|
||||
top: `${annotation.y}%`,
|
||||
}}
|
||||
>
|
||||
{annotation.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### API Base URL
|
||||
|
||||
Update the API base URL if needed:
|
||||
|
||||
```tsx
|
||||
// In your app configuration
|
||||
import { VideoApiService } from './features/video-streaming';
|
||||
|
||||
// Create a configured instance
|
||||
export const videoApi = new VideoApiService('http://your-api-server:8000');
|
||||
|
||||
// Or set globally
|
||||
process.env.REACT_APP_VIDEO_API_URL = 'http://your-api-server:8000';
|
||||
```
|
||||
|
||||
### Custom Styling
|
||||
|
||||
The components use Tailwind CSS classes. You can customize them:
|
||||
|
||||
```tsx
|
||||
// Override default styles
|
||||
<VideoList
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-8" // Custom grid
|
||||
/>
|
||||
|
||||
<VideoCard
|
||||
className="border-2 border-blue-200 hover:border-blue-400" // Custom border
|
||||
/>
|
||||
```
|
||||
|
||||
## 🎯 Integration Examples
|
||||
|
||||
### 1. Camera Management Integration
|
||||
|
||||
```tsx
|
||||
// In your camera management page
|
||||
import { VideoList, useVideoList } from '../features/video-streaming';
|
||||
|
||||
export const CameraManagement = () => {
|
||||
const [selectedCamera, setSelectedCamera] = useState(null);
|
||||
const { videos } = useVideoList({
|
||||
initialParams: { camera_name: selectedCamera?.name }
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Camera controls */}
|
||||
<CameraControls onCameraSelect={setSelectedCamera} />
|
||||
|
||||
{/* Videos from selected camera */}
|
||||
<div>
|
||||
<h3>Videos from {selectedCamera?.name}</h3>
|
||||
<VideoList
|
||||
filters={{ cameraName: selectedCamera?.name }}
|
||||
limit={12}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Experiment Timeline Integration
|
||||
|
||||
```tsx
|
||||
// Show videos in experiment timeline
|
||||
import { VideoThumbnail } from '../features/video-streaming';
|
||||
|
||||
export const ExperimentTimeline = ({ experiment }) => {
|
||||
return (
|
||||
<div className="timeline">
|
||||
{experiment.events.map(event => (
|
||||
<div key={event.id} className="timeline-item">
|
||||
<div className="timeline-content">
|
||||
<h4>{event.title}</h4>
|
||||
<p>{event.description}</p>
|
||||
|
||||
{/* Show related videos */}
|
||||
{event.videos?.length > 0 && (
|
||||
<div className="flex space-x-2 mt-2">
|
||||
{event.videos.map(videoId => (
|
||||
<VideoThumbnail
|
||||
key={videoId}
|
||||
fileId={videoId}
|
||||
width={120}
|
||||
height={80}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 📱 Responsive Design
|
||||
|
||||
The components are designed to be responsive:
|
||||
|
||||
```tsx
|
||||
// Automatic responsive grid
|
||||
<VideoList className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4" />
|
||||
|
||||
// Mobile-friendly video player
|
||||
<VideoPlayer
|
||||
fileId={video.file_id}
|
||||
className="w-full h-auto max-h-96"
|
||||
/>
|
||||
```
|
||||
|
||||
## 🔍 Search Integration
|
||||
|
||||
Add search functionality:
|
||||
|
||||
```tsx
|
||||
import { useVideoList } from '../features/video-streaming';
|
||||
|
||||
export const VideoSearch = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const { videos, loading } = useVideoList({
|
||||
initialParams: { search: searchTerm }
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search videos..."
|
||||
className="w-full px-4 py-2 border rounded-lg"
|
||||
/>
|
||||
|
||||
<VideoList videos={videos} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
1. **Start Small**: Begin by adding the video library page
|
||||
2. **Integrate Gradually**: Add individual components to existing pages
|
||||
3. **Customize**: Create specialized versions for your specific needs
|
||||
4. **Extend**: Add new features like annotations, bookmarks, or sharing
|
||||
|
||||
The modular architecture makes it easy to start simple and grow the functionality over time!
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
178
src/features/video-streaming/VideoStreamingPage.tsx
Normal file
178
src/features/video-streaming/VideoStreamingPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
162
src/features/video-streaming/components/VideoCard.tsx
Normal file
162
src/features/video-streaming/components/VideoCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
195
src/features/video-streaming/components/VideoList.tsx
Normal file
195
src/features/video-streaming/components/VideoList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
221
src/features/video-streaming/components/VideoModal.tsx
Normal file
221
src/features/video-streaming/components/VideoModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
204
src/features/video-streaming/components/VideoPlayer.tsx
Normal file
204
src/features/video-streaming/components/VideoPlayer.tsx
Normal 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';
|
||||
136
src/features/video-streaming/components/VideoThumbnail.tsx
Normal file
136
src/features/video-streaming/components/VideoThumbnail.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
20
src/features/video-streaming/components/index.ts
Normal file
20
src/features/video-streaming/components/index.ts
Normal 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';
|
||||
16
src/features/video-streaming/hooks/index.ts
Normal file
16
src/features/video-streaming/hooks/index.ts
Normal 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';
|
||||
191
src/features/video-streaming/hooks/useVideoInfo.ts
Normal file
191
src/features/video-streaming/hooks/useVideoInfo.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
187
src/features/video-streaming/hooks/useVideoList.ts
Normal file
187
src/features/video-streaming/hooks/useVideoList.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
317
src/features/video-streaming/hooks/useVideoPlayer.ts
Normal file
317
src/features/video-streaming/hooks/useVideoPlayer.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
24
src/features/video-streaming/index.ts
Normal file
24
src/features/video-streaming/index.ts
Normal 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';
|
||||
232
src/features/video-streaming/services/videoApi.ts
Normal file
232
src/features/video-streaming/services/videoApi.ts
Normal 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 };
|
||||
146
src/features/video-streaming/types/index.ts
Normal file
146
src/features/video-streaming/types/index.ts
Normal 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
|
||||
282
src/features/video-streaming/utils/videoUtils.ts
Normal file
282
src/features/video-streaming/utils/videoUtils.ts
Normal 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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -64,7 +65,8 @@
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
input, select {
|
||||
input,
|
||||
select {
|
||||
padding: 8px;
|
||||
margin: 5px;
|
||||
border: 1px solid #ddd;
|
||||
@@ -72,13 +74,14 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>🛑 Stop Streaming API Test</h1>
|
||||
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Test Stop Streaming Endpoint</h3>
|
||||
<p>This test verifies that the stop streaming API endpoint works correctly.</p>
|
||||
|
||||
|
||||
<div>
|
||||
<label for="cameraSelect">Select Camera:</label>
|
||||
<select id="cameraSelect">
|
||||
@@ -86,28 +89,28 @@
|
||||
</select>
|
||||
<button onclick="testStopStreaming()" class="stop-btn">Stop Streaming</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="test-results" class="test-section" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Manual API Test</h3>
|
||||
<p>Test the API endpoint directly:</p>
|
||||
|
||||
|
||||
<div>
|
||||
<input type="text" id="manualCamera" placeholder="Enter camera name (e.g., camera1)" value="camera1">
|
||||
<button onclick="testManualStopStreaming()">Manual Stop Stream</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="manual-results" class="test-section" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = 'http://localhost:8000';
|
||||
const API_BASE = 'http://vision:8000';
|
||||
let cameras = {};
|
||||
|
||||
// Load cameras on page load
|
||||
window.onload = async function() {
|
||||
window.onload = async function () {
|
||||
await loadCameras();
|
||||
};
|
||||
|
||||
@@ -117,18 +120,18 @@
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
cameras = await response.json();
|
||||
const select = document.getElementById('cameraSelect');
|
||||
select.innerHTML = '<option value="">Select a camera...</option>';
|
||||
|
||||
|
||||
Object.keys(cameras).forEach(cameraName => {
|
||||
const option = document.createElement('option');
|
||||
option.value = cameraName;
|
||||
option.textContent = `${cameraName} (${cameras[cameraName].status})`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading cameras:', error);
|
||||
const select = document.getElementById('cameraSelect');
|
||||
@@ -241,4 +244,5 @@
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user