mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-24 15:38:10 +02:00
feat: refactor camera to react-webcam, add test route, and fix docker volume for ffmpeg
This commit is contained in:
1 parent
986f9741c1
commit
c9b7493c9c
6 files changed
+142
-205
No files matched your search
@@ -7,6 +7,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
|||||||
import { createResponsiveRoute } from "@/components/responsive-route";
|
import { createResponsiveRoute } from "@/components/responsive-route";
|
||||||
import NotFound from "@/pages/not-found";
|
import NotFound from "@/pages/not-found";
|
||||||
import Login from "@/pages/login";
|
import Login from "@/pages/login";
|
||||||
|
import TestCamera from "@/pages/test-camera";
|
||||||
|
|
||||||
|
|
||||||
// Create responsive routes with lazy loading for optimal performance
|
// Create responsive routes with lazy loading for optimal performance
|
||||||
@@ -135,6 +136,7 @@ function Router() {
|
|||||||
<Route path="/sql">{() => <ProtectedRoute component={SqlAdmin} adminOnly />}</Route>
|
<Route path="/sql">{() => <ProtectedRoute component={SqlAdmin} adminOnly />}</Route>
|
||||||
<Route path="/users">{() => <ProtectedRoute component={UsersAdmin} adminOnly />}</Route>
|
<Route path="/users">{() => <ProtectedRoute component={UsersAdmin} adminOnly />}</Route>
|
||||||
<Route path="/analytics">{() => <ProtectedRoute component={Analytics} />}</Route>
|
<Route path="/analytics">{() => <ProtectedRoute component={Analytics} />}</Route>
|
||||||
|
<Route path="/test-camera" component={TestCamera} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
|
||||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||||
import { Camera, StopCircle, RefreshCw, CheckCircle, X, RotateCcw, Settings2, AlertCircle, Bug } from 'lucide-react';
|
import Webcam from 'react-webcam';
|
||||||
|
import { Camera, StopCircle, RefreshCw, CheckCircle, X, RotateCcw, Settings2, AlertCircle, Video } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
@@ -17,163 +18,72 @@ interface QualityConfig {
|
|||||||
label: string;
|
label: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
bitrate: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const QUALITY_CONFIGS: Record<VideoQuality, QualityConfig> = {
|
const QUALITY_CONFIGS: Record<VideoQuality, QualityConfig> = {
|
||||||
"4k": { label: "4K Ultra HD", width: 3840, height: 2160, bitrate: 25000000 },
|
"4k": { label: "4K Ultra HD", width: 3840, height: 2160 },
|
||||||
"1080p": { label: "Full HD", width: 1920, height: 1080, bitrate: 8000000 },
|
"1080p": { label: "Full HD", width: 1920, height: 1080 },
|
||||||
"720p": { label: "HD 720p", width: 1280, height: 720, bitrate: 5000000 },
|
"720p": { label: "HD 720p", width: 1280, height: 720 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) {
|
export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const webcamRef = useRef<Webcam>(null);
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
const chunksRef = useRef<Blob[]>([]);
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
|
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
const [timer, setTimer] = useState(0);
|
const [timer, setTimer] = useState(0);
|
||||||
|
|
||||||
// Camera Device State
|
// Devices & Settings
|
||||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('');
|
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('');
|
||||||
const [permissionError, setPermissionError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [resolutionInfo, setResolutionInfo] = useState<string>('');
|
|
||||||
const [quality, setQuality] = useState<VideoQuality>('1080p');
|
const [quality, setQuality] = useState<VideoQuality>('1080p');
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [showDebug, setShowDebug] = useState(false);
|
const [cameraError, setCameraError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Initial Device Fetch
|
// Fetch devices
|
||||||
useEffect(() => {
|
const handleDevices = useCallback((mediaDevices: MediaDeviceInfo[]) => {
|
||||||
const getDevices = async () => {
|
const videoDevices = mediaDevices.filter(({ kind }) => kind === "videoinput");
|
||||||
try {
|
setDevices(videoDevices);
|
||||||
// Request permission first to get device labels
|
if (videoDevices.length > 0 && !selectedDeviceId) {
|
||||||
await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
setSelectedDeviceId(videoDevices[0].deviceId);
|
||||||
|
|
||||||
const allDevices = await navigator.mediaDevices.enumerateDevices();
|
|
||||||
const videoDevices = allDevices.filter(device => device.kind === 'videoinput');
|
|
||||||
setDevices(videoDevices);
|
|
||||||
console.log("Devices found:", videoDevices);
|
|
||||||
|
|
||||||
if (videoDevices.length > 0 && !selectedDeviceId) {
|
|
||||||
setSelectedDeviceId(videoDevices[0].deviceId);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error enumerating devices:", err);
|
|
||||||
setPermissionError("Accès caméra refusé. Vérifiez les permissions du navigateur.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
getDevices();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Camera Start Logic
|
|
||||||
const startCamera = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setPermissionError(null);
|
|
||||||
|
|
||||||
if (stream) {
|
|
||||||
stream.getTracks().forEach(track => track.stop());
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = QUALITY_CONFIGS[quality];
|
|
||||||
console.log(`📸 Starting camera: ${config.label}, deviceId: ${selectedDeviceId}`);
|
|
||||||
|
|
||||||
// Constraints
|
|
||||||
const constraints: MediaStreamConstraints = {
|
|
||||||
audio: {
|
|
||||||
echoCancellation: true,
|
|
||||||
noiseSuppression: true,
|
|
||||||
},
|
|
||||||
video: selectedDeviceId ? {
|
|
||||||
deviceId: { exact: selectedDeviceId },
|
|
||||||
width: { ideal: config.width },
|
|
||||||
height: { ideal: config.height },
|
|
||||||
} : {
|
|
||||||
facingMode: 'user',
|
|
||||||
width: { ideal: config.width },
|
|
||||||
height: { ideal: config.height },
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let newStream: MediaStream;
|
|
||||||
|
|
||||||
try {
|
|
||||||
newStream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('⚠️ Standard constraints failed:', err);
|
|
||||||
setPermissionError(`Erreur: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
// Fallback
|
|
||||||
newStream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: true,
|
|
||||||
video: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setStream(newStream);
|
|
||||||
|
|
||||||
if (videoRef.current) {
|
|
||||||
videoRef.current.srcObject = newStream;
|
|
||||||
videoRef.current.volume = 0;
|
|
||||||
await videoRef.current.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolution check
|
|
||||||
const videoTrack = newStream.getVideoTracks()[0];
|
|
||||||
if (videoTrack) {
|
|
||||||
const settings = videoTrack.getSettings();
|
|
||||||
setResolutionInfo(`${settings.width}x${settings.height}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error accessing camera:', error);
|
|
||||||
const msg = error instanceof Error ? error.message : "Erreur inconnue";
|
|
||||||
setPermissionError(msg);
|
|
||||||
toast({
|
|
||||||
title: "Erreur caméra",
|
|
||||||
description: msg,
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [selectedDeviceId, quality, toast]);
|
}, [selectedDeviceId]);
|
||||||
|
|
||||||
// Restart on quality/device change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedDeviceId || devices.length === 0) {
|
navigator.mediaDevices.enumerateDevices().then(handleDevices).catch(err => {
|
||||||
// Attempt start even if no devices found (might work with default)
|
console.error("Error enumerating devices", err);
|
||||||
startCamera();
|
setCameraError("Erreur liste périphériques: " + err.message);
|
||||||
}
|
});
|
||||||
return () => {
|
}, [handleDevices]);
|
||||||
if (stream) {
|
|
||||||
stream.getTracks().forEach(track => track.stop());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [selectedDeviceId, quality]);
|
|
||||||
|
|
||||||
// Timer logic
|
// Timer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let interval: NodeJS.Timeout;
|
let interval: NodeJS.Timeout;
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
interval = setInterval(() => {
|
interval = setInterval(() => setTimer(t => t + 1), 1000);
|
||||||
setTimer(prev => prev + 1);
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
} else {
|
||||||
setTimer(0);
|
setTimer(0);
|
||||||
}
|
}
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [isRecording]);
|
}, [isRecording]);
|
||||||
|
|
||||||
// Recording Functions
|
const formatTime = (seconds: number) => {
|
||||||
const startRecording = () => {
|
const mins = Math.floor(seconds / 60);
|
||||||
if (!stream) return;
|
const secs = seconds % 60;
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recording Logic
|
||||||
|
const startRecording = useCallback(() => {
|
||||||
|
if (!webcamRef.current || !webcamRef.current.stream) return;
|
||||||
|
|
||||||
chunksRef.current = [];
|
chunksRef.current = [];
|
||||||
|
const stream = webcamRef.current.stream;
|
||||||
|
|
||||||
// Codec selection
|
|
||||||
const mimeTypes = ['video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm'];
|
const mimeTypes = ['video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm'];
|
||||||
const mimeType = mimeTypes.find(type => MediaRecorder.isTypeSupported(type)) || '';
|
const mimeType = mimeTypes.find(type => MediaRecorder.isTypeSupported(type)) || '';
|
||||||
|
|
||||||
@@ -191,24 +101,23 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) {
|
|||||||
const blob = new Blob(chunksRef.current, { type: mimeType || 'video/webm' });
|
const blob = new Blob(chunksRef.current, { type: mimeType || 'video/webm' });
|
||||||
setRecordedBlob(blob);
|
setRecordedBlob(blob);
|
||||||
setPreviewUrl(URL.createObjectURL(blob));
|
setPreviewUrl(URL.createObjectURL(blob));
|
||||||
stream.getTracks().forEach(track => track.stop());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaRecorder.start(1000);
|
mediaRecorder.start();
|
||||||
mediaRecorderRef.current = mediaRecorder;
|
mediaRecorderRef.current = mediaRecorder;
|
||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error('Recording failed:', e);
|
console.error("Recording error", e);
|
||||||
toast({ title: "Erreur enregistrement", description: "Format non supporté.", variant: "destructive" });
|
toast({ title: "Erreur", description: "Impossible de lancer l'enregistrement: " + e.message, variant: "destructive" });
|
||||||
}
|
}
|
||||||
};
|
}, [webcamRef, toast]);
|
||||||
|
|
||||||
const stopRecording = () => {
|
const stopRecording = useCallback(() => {
|
||||||
if (mediaRecorderRef.current && isRecording) {
|
if (mediaRecorderRef.current && isRecording) {
|
||||||
mediaRecorderRef.current.stop();
|
mediaRecorderRef.current.stop();
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
}
|
}
|
||||||
};
|
}, [isRecording]);
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
if (recordedBlob) {
|
if (recordedBlob) {
|
||||||
@@ -216,19 +125,19 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
const handleUserMediaError = useCallback((error: string | DOMException) => {
|
||||||
const mins = Math.floor(seconds / 60);
|
console.error("Webcam Error:", error);
|
||||||
const secs = seconds % 60;
|
const msg = typeof error === 'string' ? error : error.message;
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
setCameraError(msg);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// Render Review Mode
|
// Review Mode
|
||||||
if (recordedBlob && previewUrl) {
|
if (recordedBlob && previewUrl) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black flex flex-col items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 bg-black flex flex-col items-center justify-center p-4">
|
||||||
<video src={previewUrl} controls className="w-full max-h-[80vh] rounded-lg mb-8" autoPlay playsInline />
|
<video src={previewUrl} controls className="w-full max-h-[80vh] rounded-lg mb-8" autoPlay playsInline />
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Button variant="secondary" size="lg" onClick={() => { setRecordedBlob(null); startCamera(); }} className="rounded-full px-8 py-6 text-lg">
|
<Button variant="secondary" size="lg" onClick={() => { setRecordedBlob(null); setPreviewUrl(null); }} className="rounded-full px-8 py-6 text-lg">
|
||||||
<RotateCcw className="mr-2 h-5 w-5" /> Refaire
|
<RotateCcw className="mr-2 h-5 w-5" /> Refaire
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="lg" onClick={handleConfirm} className="rounded-full px-8 py-6 text-lg bg-green-600 hover:bg-green-700">
|
<Button size="lg" onClick={handleConfirm} className="rounded-full px-8 py-6 text-lg bg-green-600 hover:bg-green-700">
|
||||||
@@ -239,82 +148,52 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render Capture Mode
|
const config = QUALITY_CONFIGS[quality];
|
||||||
|
const videoConstraints = {
|
||||||
|
width: { ideal: config.width },
|
||||||
|
height: { ideal: config.height },
|
||||||
|
deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined,
|
||||||
|
facingMode: selectedDeviceId ? undefined : "user"
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black flex flex-col">
|
<div className="fixed inset-0 z-50 bg-black flex flex-col">
|
||||||
{/* Header / Top Bar */}
|
{/* Header */}
|
||||||
<div className="absolute top-0 left-0 right-0 p-4 z-10 flex justify-between items-center bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
|
<div className="absolute top-0 left-0 right-0 p-4 z-10 flex justify-between items-center bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
|
||||||
<div className="pointer-events-auto">
|
<div className="pointer-events-auto">
|
||||||
<Button variant="ghost" size="icon" onClick={onCancel} className="text-white">
|
<Button variant="ghost" size="icon" onClick={onCancel} className="text-white bg-black/20 hover:bg-black/40">
|
||||||
<X className="h-6 w-6" />
|
<X className="h-6 w-6" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pointer-events-auto">
|
<div className="pointer-events-auto flex gap-2">
|
||||||
{/* Debug Toggle */}
|
|
||||||
<button onClick={() => setShowDebug(!showDebug)} className="bg-red-900/50 p-2 rounded text-xs text-white">
|
|
||||||
<Bug className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isRecording ? (
|
{isRecording ? (
|
||||||
<div className="bg-red-600 px-3 py-1 rounded-full text-white font-mono text-sm flex items-center gap-2">
|
<div className="bg-red-600 px-3 py-1 rounded-full text-white font-mono text-sm flex items-center gap-2 animate-pulse">
|
||||||
<div className="w-2 h-2 bg-white rounded-full animate-pulse" />
|
<div className="w-2 h-2 bg-white rounded-full" />
|
||||||
{formatTime(timer)}
|
{formatTime(timer)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<Button variant="outline" size="sm" onClick={() => setShowSettings(!showSettings)} className="bg-black/30 border-white/20 text-white backdrop-blur">
|
||||||
onClick={() => setShowSettings(!showSettings)}
|
<Settings2 className="mr-2 h-4 w-4" />
|
||||||
className="bg-black/50 backdrop-blur-md px-3 py-2 rounded-full text-white font-medium text-sm flex items-center gap-2 border border-white/30 hover:bg-black/70 transition-colors"
|
<span className="hidden sm:inline">Réglages</span>
|
||||||
>
|
</Button>
|
||||||
<Settings2 className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Paramètres</span>
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10"></div>
|
<div className="w-10"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* DEBUG OVERLAY */}
|
{/* Error Overlay */}
|
||||||
{showDebug && (
|
{cameraError && (
|
||||||
<div className="absolute top-16 left-4 z-40 bg-black/80 text-green-400 p-4 rounded text-xs font-mono whitespace-pre-wrap max-w-sm pointer-events-auto overflow-auto max-h-[50vh]">
|
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center bg-black/90 p-6 text-center">
|
||||||
<h3 className="font-bold border-b border-green-500 mb-2">Diagnostic Caméra</h3>
|
|
||||||
<p>Secure Context: {window.isSecureContext ? "YES (HTTPS/Local)" : "NO (HTTP) - CAMERA BLOCKED"}</p>
|
|
||||||
<p>Err Permission: {permissionError || "Aucune"}</p>
|
|
||||||
<p>Resolution: {resolutionInfo}</p>
|
|
||||||
<p>Selected Device: {selectedDeviceId || "Auto"}</p>
|
|
||||||
<p>Stream Active: {stream ? "Oui" : "Non"}</p>
|
|
||||||
<hr className="border-green-800 my-2" />
|
|
||||||
<strong>Devices ({devices.length}):</strong>
|
|
||||||
{devices.map((d, i) => (
|
|
||||||
<div key={i} className="mb-1">
|
|
||||||
[{d.kind}] {d.label || "Sans étiquette"} ({d.deviceId.slice(0, 8)}...)
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Critical Error Display */}
|
|
||||||
{!window.isSecureContext && (
|
|
||||||
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center bg-black/90 p-6 text-center text-white">
|
|
||||||
<AlertCircle className="w-16 h-16 text-red-500 mb-4" />
|
<AlertCircle className="w-16 h-16 text-red-500 mb-4" />
|
||||||
<h2 className="text-xl font-bold text-red-400">Connexion non sécurisée (HTTP)</h2>
|
<h3 className="text-xl font-bold text-red-500 mb-2">Erreur Caméra</h3>
|
||||||
<p className="mt-2 text-gray-300">Les navigateurs bloquent l'accès caméra sur les sites non sécurisés (sauf localhost).</p>
|
<p className="text-white mb-6">{cameraError}</p>
|
||||||
<p className="mt-2 text-sm text-gray-400">Veuillez passer en HTTPS ou utiliser localhost.</p>
|
<p className="text-sm text-gray-400">Vérifiez vos permissions et que vous êtes bien en HTTPS (ou localhost).</p>
|
||||||
|
<Button onClick={() => window.location.reload()} variant="outline" className="mt-4 text-black">Recharger</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Regular Error Display */}
|
{/* Settings Overlay */}
|
||||||
{permissionError && window.isSecureContext && (
|
|
||||||
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center bg-black/90 p-6 text-center text-white">
|
|
||||||
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
|
|
||||||
<h3 className="text-xl font-bold">Erreur : {permissionError}</h3>
|
|
||||||
<Button onClick={startCamera} variant="outline" className="mt-4 text-black">
|
|
||||||
Réessayer
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Settings Modal */}
|
|
||||||
{showSettings && !isRecording && (
|
{showSettings && !isRecording && (
|
||||||
<div className="absolute inset-0 z-20 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
|
<div className="absolute inset-0 z-20 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
|
||||||
<div className="bg-[#1c1c1e] text-white p-6 rounded-2xl w-full max-w-sm border border-white/10 shadow-2xl">
|
<div className="bg-[#1c1c1e] text-white p-6 rounded-2xl w-full max-w-sm border border-white/10 shadow-2xl">
|
||||||
@@ -326,10 +205,10 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-white/70">Source Vidéo</label>
|
<label className="text-sm font-medium text-white/70">Caméra</label>
|
||||||
<Select value={selectedDeviceId} onValueChange={setSelectedDeviceId}>
|
<Select value={selectedDeviceId} onValueChange={setSelectedDeviceId}>
|
||||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-white mt-1">
|
<SelectTrigger className="w-full bg-white/5 border-white/10 text-white mt-1">
|
||||||
<SelectValue placeholder="Choisir caméra" />
|
<SelectValue placeholder="Choisir..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#2c2c2e] border-white/10 text-white">
|
<SelectContent className="bg-[#2c2c2e] border-white/10 text-white">
|
||||||
{devices.map((device) => (
|
{devices.map((device) => (
|
||||||
@@ -347,7 +226,7 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) {
|
|||||||
<button
|
<button
|
||||||
key={q}
|
key={q}
|
||||||
onClick={() => setQuality(q)}
|
onClick={() => setQuality(q)}
|
||||||
className={`px-2 py-2 rounded text-sm border ${quality === q ? 'bg-white text-black' : 'bg-transparent text-white border-white/20'}`}
|
className={`px-2 py-2 rounded text-sm border transition-colors ${quality === q ? 'bg-white text-black' : 'bg-transparent text-white border-white/20 hover:bg-white/10'}`}
|
||||||
>
|
>
|
||||||
{q.toUpperCase()}
|
{q.toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
@@ -355,27 +234,29 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button className="w-full mt-6 bg-white text-black hover:bg-gray-200" onClick={() => setShowSettings(false)}>OK</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Video Feed */}
|
{/* Webcam Feed */}
|
||||||
<div className="relative flex-1 bg-black flex items-center justify-center overflow-hidden">
|
<div className="flex-1 bg-black flex items-center justify-center overflow-hidden relative">
|
||||||
<video
|
<Webcam
|
||||||
ref={videoRef}
|
audio={true}
|
||||||
autoPlay
|
ref={webcamRef}
|
||||||
playsInline
|
videoConstraints={videoConstraints}
|
||||||
muted
|
onUserMediaError={handleUserMediaError}
|
||||||
className="absolute w-full h-full object-cover"
|
className="absolute w-full h-full object-cover"
|
||||||
|
muted // Local preview always muted to avoid feedback
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls Footer */}
|
{/* Footer Controls */}
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-8 pb-12 flex flex-col items-center justify-end bg-gradient-to-t from-black/80 to-transparent h-48 pointer-events-none">
|
<div className="absolute bottom-0 left-0 right-0 p-8 pb-12 flex flex-col items-center justify-end bg-gradient-to-t from-black/80 to-transparent h-48 pointer-events-none">
|
||||||
<div className="pointer-events-auto">
|
<div className="pointer-events-auto">
|
||||||
<button
|
<button
|
||||||
onClick={isRecording ? stopRecording : startRecording}
|
onClick={isRecording ? stopRecording : startRecording}
|
||||||
className="group relative flex items-center justify-center transition-all hover:scale-110 active:scale-95"
|
className="group relative flex items-center justify-center transition-all hover:scale-105 active:scale-95"
|
||||||
>
|
>
|
||||||
<div className={`w-20 h-20 rounded-full border-[6px] border-white flex items-center justify-center p-1 ${isRecording ? 'opacity-100' : ''}`}>
|
<div className={`w-20 h-20 rounded-full border-[6px] border-white flex items-center justify-center p-1 ${isRecording ? 'opacity-100' : ''}`}>
|
||||||
<div className={`transition-all duration-300 ${isRecording ? 'w-8 h-8 rounded-md bg-red-500' : 'w-full h-full rounded-full bg-red-500'}`} />
|
<div className={`transition-all duration-300 ${isRecording ? 'w-8 h-8 rounded-md bg-red-500' : 'w-full h-full rounded-full bg-red-500'}`} />
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { CameraRecorder } from '@/components/camera-recorder';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export default function TestCameraPage() {
|
||||||
|
const [capturedFile, setCapturedFile] = useState<File | null>(null);
|
||||||
|
const [showCamera, setShowCamera] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 bg-gray-900 min-h-screen text-white">
|
||||||
|
<h1 className="text-3xl font-bold mb-8">Test Caméra Isolé</h1>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Button onClick={() => setShowCamera(true)}>Ouvrir Caméra (react-webcam)</Button>
|
||||||
|
|
||||||
|
{capturedFile && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Fichier Capturé :</h2>
|
||||||
|
<p>Nom: {capturedFile.name}</p>
|
||||||
|
<p>Taille: {(capturedFile.size / 1024 / 1024).toFixed(2)} MB</p>
|
||||||
|
<p>Type: {capturedFile.type}</p>
|
||||||
|
<video controls src={URL.createObjectURL(capturedFile)} className="mt-4 max-w-sm border border-white/20 rounded" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCamera && (
|
||||||
|
<CameraRecorder
|
||||||
|
onCapture={(file) => {
|
||||||
|
console.log("Captured:", file);
|
||||||
|
setCapturedFile(file);
|
||||||
|
setShowCamera(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowCamera(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -60,6 +60,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
||||||
|
volumes:
|
||||||
|
- ./ffmpeg-service:/app
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
expose:
|
expose:
|
||||||
|
|||||||
Generated
+11
@@ -84,6 +84,7 @@
|
|||||||
"react-hook-form": "^7.55.0",
|
"react-hook-form": "^7.55.0",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
|
"react-webcam": "^7.2.0",
|
||||||
"recharts": "^2.15.2",
|
"recharts": "^2.15.2",
|
||||||
"sharp": "^0.34.4",
|
"sharp": "^0.34.4",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
@@ -8055,6 +8056,16 @@
|
|||||||
"react-dom": ">=16.6.0"
|
"react-dom": ">=16.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-webcam": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-webcam/-/react-webcam-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-xkrzYPqa1ag2DP+2Q/kLKBmCIfEx49bVdgCCCcZf88oF+0NPEbkwYk3/s/C7Zy0mhM8k+hpdNkBLzxg8H0aWcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.2.0",
|
||||||
|
"react-dom": ">=16.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
"react-hook-form": "^7.55.0",
|
"react-hook-form": "^7.55.0",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
|
"react-webcam": "^7.2.0",
|
||||||
"recharts": "^2.15.2",
|
"recharts": "^2.15.2",
|
||||||
"sharp": "^0.34.4",
|
"sharp": "^0.34.4",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
|
|||||||
Reference in new issue
Block a user