add video

This commit is contained in:
lendry
2026-07-07 12:46:43 +03:00
parent 911e76f232
commit 1bd95fa99e
15 changed files with 626 additions and 42 deletions

View File

@@ -0,0 +1,218 @@
'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>
);
}