- Added health check to the camera management API service in docker-compose.yml for better container reliability. - Updated installation scripts in Dockerfile to check for existing dependencies before installation, improving efficiency. - Enhanced error handling in the USDAVisionSystem class to allow partial operation if some components fail to start, preventing immediate shutdown. - Improved logging throughout the application, including more detailed error messages and critical error handling in the main loop. - Refactored WebSocketManager and CameraMonitor classes to use debug logging for connection events, reducing log noise.
188 lines
6.7 KiB
TypeScript
188 lines
6.7 KiB
TypeScript
import React, { useRef, useEffect, useState } from 'react'
|
|
import videojs from 'video.js'
|
|
import 'video.js/dist/video-js.css'
|
|
|
|
const BASE = (import.meta as any).env?.VITE_MEDIA_API_URL || (import.meta as any).env?.VITE_VISION_API_URL || '/api'
|
|
|
|
type Props = {
|
|
fileId: string | null
|
|
onClose: () => void
|
|
}
|
|
|
|
export const VideoModal: React.FC<Props> = ({ fileId, onClose }) => {
|
|
const videoRef = useRef<HTMLVideoElement>(null)
|
|
const playerRef = useRef<any>(null)
|
|
const [useTranscoded, setUseTranscoded] = useState(false)
|
|
|
|
// Try regular stream first (works for downloads), fallback to transcoded if needed
|
|
const regularSrc = fileId ? `${BASE}/videos/stream?file_id=${encodeURIComponent(fileId)}` : null
|
|
const transcodedSrc = fileId ? `${BASE}/videos/stream-transcoded?file_id=${encodeURIComponent(fileId)}` : null
|
|
const src = useTranscoded ? transcodedSrc : regularSrc
|
|
|
|
// Reset transcoded flag when fileId changes
|
|
useEffect(() => {
|
|
setUseTranscoded(false)
|
|
}, [fileId])
|
|
|
|
useEffect(() => {
|
|
if (!fileId || !src || !videoRef.current) return
|
|
|
|
// Dispose existing player if any
|
|
if (playerRef.current) {
|
|
playerRef.current.dispose()
|
|
playerRef.current = null
|
|
}
|
|
|
|
// Initialize Video.js player
|
|
const player = videojs(videoRef.current, {
|
|
controls: true,
|
|
autoplay: false, // Don't autoplay - let user control
|
|
preload: 'metadata', // Load metadata first, then data on play
|
|
fluid: false, // Disable fluid mode to respect container boundaries
|
|
responsive: false, // Disable responsive mode to prevent overflow
|
|
playbackRates: [0.5, 1, 1.25, 1.5, 2],
|
|
html5: {
|
|
vhs: {
|
|
overrideNative: true
|
|
},
|
|
nativeVideoTracks: false,
|
|
nativeAudioTracks: false,
|
|
nativeTextTracks: false
|
|
},
|
|
sources: [
|
|
{
|
|
src: src,
|
|
type: 'video/mp4'
|
|
}
|
|
]
|
|
})
|
|
|
|
playerRef.current = player
|
|
|
|
let errorHandled = false
|
|
const handleError = () => {
|
|
const error = player.error()
|
|
if (error && !errorHandled) {
|
|
errorHandled = true
|
|
console.error('Video.js error:', error)
|
|
console.error('Error code:', error.code)
|
|
console.error('Error message:', error.message)
|
|
|
|
// If regular stream fails and we haven't tried transcoded yet, switch to transcoded
|
|
if (!useTranscoded && transcodedSrc) {
|
|
console.log('Switching to transcoded endpoint...')
|
|
// Clear the player ref to prevent double disposal
|
|
playerRef.current = null
|
|
// Dispose player before switching
|
|
try {
|
|
player.dispose()
|
|
} catch (e) {
|
|
// Ignore disposal errors
|
|
}
|
|
// Use setTimeout to allow cleanup to complete before switching
|
|
setTimeout(() => {
|
|
setUseTranscoded(true)
|
|
}, 100)
|
|
} else {
|
|
// Show user-friendly error
|
|
console.error('Video playback failed. Please try downloading the video instead.')
|
|
}
|
|
}
|
|
}
|
|
|
|
player.on('error', handleError)
|
|
|
|
player.on('loadedmetadata', () => {
|
|
console.log('Video metadata loaded, duration:', player.duration())
|
|
})
|
|
|
|
player.on('canplay', () => {
|
|
console.log('Video can play')
|
|
})
|
|
|
|
// Cleanup
|
|
return () => {
|
|
if (playerRef.current) {
|
|
playerRef.current.dispose()
|
|
playerRef.current = null
|
|
}
|
|
}
|
|
}, [fileId, src, useTranscoded, transcodedSrc])
|
|
|
|
if (!fileId || !src) return null
|
|
|
|
return (
|
|
<>
|
|
<style>{`
|
|
.video-modal-container .video-js {
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
max-width: 100% !important;
|
|
max-height: 100% !important;
|
|
}
|
|
.video-modal-container .video-js .vjs-tech {
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
object-fit: contain;
|
|
}
|
|
.video-modal-container .video-js .vjs-control-bar {
|
|
position: absolute !important;
|
|
bottom: 0 !important;
|
|
left: 0 !important;
|
|
right: 0 !important;
|
|
width: 100% !important;
|
|
}
|
|
`}</style>
|
|
<div
|
|
className="fixed inset-0 z-[1000] bg-black/60 dark:bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="video-modal-container bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden transform transition-all relative flex flex-col"
|
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
|
>
|
|
{/* Close button - positioned absolutely in top right corner */}
|
|
<button
|
|
onClick={onClose}
|
|
className="absolute top-3 right-3 z-10 inline-flex items-center justify-center w-10 h-10 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 shadow-md hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-600 dark:hover:text-red-400 hover:border-red-300 dark:hover:border-red-700 transition-all duration-200"
|
|
aria-label="Close modal"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
|
|
<div className="p-4 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Video Player</h3>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Watch your recording</p>
|
|
</div>
|
|
<a
|
|
href={`${BASE}/videos/stream?file_id=${encodeURIComponent(fileId)}`}
|
|
download={fileId.split('/').pop() || 'video.mp4'}
|
|
className="px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
Download Video
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 min-h-0 bg-black p-4 flex items-center justify-center">
|
|
<div className="relative w-full max-w-full max-h-full" style={{ aspectRatio: '16/9', maxHeight: 'calc(90vh - 200px)' }}>
|
|
<video
|
|
ref={videoRef}
|
|
className="video-js vjs-default-skin w-full h-full"
|
|
playsInline
|
|
key={`${fileId}-${useTranscoded ? 'transcoded' : 'regular'}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default VideoModal
|