Files
IdP/apps/frontend/components/chat/chat-video-capture-dialog.tsx
2026-07-07 12:46:43 +03:00

219 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Loader2, Video, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
export type ChatVideoCaptureMode = 'video' | 'note';
const MAX_DURATION_MS: Record<ChatVideoCaptureMode, number> = {
note: 60_000,
video: 5 * 60_000
};
function pickRecorderMimeType() {
const candidates = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'];
return candidates.find((type) => MediaRecorder.isTypeSupported(type)) ?? '';
}
function formatTimer(ms: number) {
const total = Math.max(0, Math.floor(ms / 1000));
const mins = Math.floor(total / 60);
const secs = total % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
interface ChatVideoCaptureDialogProps {
open: boolean;
mode: ChatVideoCaptureMode;
onClose: () => void;
onCaptured: (file: File, durationMs: number) => void;
}
export function ChatVideoCaptureDialog({ open, mode, onClose, onCaptured }: ChatVideoCaptureDialogProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const startedAtRef = useRef<number | null>(null);
const timerRef = useRef<number | null>(null);
const [initializing, setInitializing] = useState(false);
const [recording, setRecording] = useState(false);
const [elapsedMs, setElapsedMs] = useState(0);
const [error, setError] = useState<string | null>(null);
const stopStream = useCallback(() => {
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
if (videoRef.current) {
videoRef.current.srcObject = null;
}
}, []);
const cleanup = useCallback(() => {
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
if (recorderRef.current && recorderRef.current.state !== 'inactive') {
recorderRef.current.stop();
}
recorderRef.current = null;
chunksRef.current = [];
startedAtRef.current = null;
setRecording(false);
setElapsedMs(0);
stopStream();
}, [stopStream]);
useEffect(() => {
if (!open) {
cleanup();
setError(null);
return;
}
let cancelled = false;
setInitializing(true);
setError(null);
void (async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: mode === 'note' ? 'user' : 'environment' },
audio: true
});
if (cancelled) {
stream.getTracks().forEach((track) => track.stop());
return;
}
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
} catch {
if (!cancelled) {
setError('Не удалось получить доступ к камере и микрофону');
}
} finally {
if (!cancelled) setInitializing(false);
}
})();
return () => {
cancelled = true;
cleanup();
};
}, [cleanup, mode, open]);
function stopRecording() {
recorderRef.current?.stop();
recorderRef.current = null;
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
setRecording(false);
}
function startRecording() {
const stream = streamRef.current;
if (!stream || recording) return;
const mimeType = pickRecorderMimeType();
const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
chunksRef.current = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) chunksRef.current.push(event.data);
};
recorder.onstop = () => {
const durationMs = startedAtRef.current ? Date.now() - startedAtRef.current : 0;
startedAtRef.current = null;
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'video/webm' });
const extension = blob.type.includes('mp4') ? 'mp4' : 'webm';
const prefix = mode === 'note' ? 'video-note' : 'video';
const file = new File([blob], `${prefix}-${Date.now()}.${extension}`, { type: blob.type || 'video/webm' });
onCaptured(file, durationMs);
onClose();
};
recorderRef.current = recorder;
startedAtRef.current = Date.now();
setElapsedMs(0);
setRecording(true);
recorder.start(250);
timerRef.current = window.setInterval(() => {
const startedAt = startedAtRef.current;
if (!startedAt) return;
const nextElapsed = Date.now() - startedAt;
setElapsedMs(nextElapsed);
if (nextElapsed >= MAX_DURATION_MS[mode]) {
stopRecording();
}
}, 200);
}
const title = mode === 'note' ? 'Запись кружка' : 'Запись видео';
const hint = mode === 'note' ? 'Короткое круглое видеосообщение до 1 минуты' : 'Видеосообщение до 5 минут';
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="overflow-hidden rounded-[24px] p-0 sm:max-w-[420px]">
<div className="border-b border-[#eceef4] px-5 py-4">
<DialogHeader className="mb-0">
<DialogTitle>{title}</DialogTitle>
<p className="text-sm text-[#667085]">{hint}</p>
</DialogHeader>
</div>
<div className="space-y-4 px-5 py-5">
{error ? (
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
) : (
<div className="flex justify-center">
<div
className={cn(
'relative overflow-hidden bg-black',
mode === 'note' ? 'h-[240px] w-[240px] rounded-full' : 'h-[280px] w-full max-w-[360px] rounded-2xl'
)}
>
<video ref={videoRef} className="h-full w-full object-cover" playsInline muted />
{initializing ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/35">
<Loader2 className="h-8 w-8 animate-spin text-white" />
</div>
) : null}
</div>
</div>
)}
<div className="flex items-center justify-center gap-2 text-sm font-medium text-[#667085]">
<Video className="h-4 w-4" />
{recording ? formatTimer(elapsedMs) : 'Готовы к записи'}
</div>
</div>
<div className="flex items-center justify-between border-t border-[#eceef4] px-5 py-3">
<Button type="button" variant="ghost" onClick={onClose}>
<X className="mr-1 h-4 w-4" />
Отмена
</Button>
<Button
type="button"
disabled={Boolean(error) || initializing}
className={cn(recording && 'bg-red-500 hover:bg-red-600')}
onClick={() => (recording ? stopRecording() : startRecording())}
>
{recording ? 'Остановить' : 'Начать запись'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}