add video
This commit is contained in:
218
apps/frontend/components/chat/chat-video-capture-dialog.tsx
Normal file
218
apps/frontend/components/chat/chat-video-capture-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
apps/frontend/components/chat/chat-video-note-player.tsx
Normal file
97
apps/frontend/components/chat/chat-video-note-player.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, Pause, Play } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return '0:00';
|
||||
const whole = Math.floor(seconds);
|
||||
const mins = Math.floor(whole / 60);
|
||||
const secs = whole % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function ChatVideoNotePlayer({
|
||||
src,
|
||||
durationMs,
|
||||
variant = 'theirs'
|
||||
}: {
|
||||
src: string;
|
||||
durationMs?: number;
|
||||
variant?: 'mine' | 'theirs';
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [duration, setDuration] = useState(durationMs ? durationMs / 1000 : 0);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const onLoaded = () => {
|
||||
if (Number.isFinite(video.duration) && video.duration > 0) {
|
||||
setDuration(video.duration);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
const onPlay = () => setPlaying(true);
|
||||
const onPause = () => setPlaying(false);
|
||||
const onEnded = () => setPlaying(false);
|
||||
|
||||
video.addEventListener('loadedmetadata', onLoaded);
|
||||
video.addEventListener('play', onPlay);
|
||||
video.addEventListener('pause', onPause);
|
||||
video.addEventListener('ended', onEnded);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', onLoaded);
|
||||
video.removeEventListener('play', onPlay);
|
||||
video.removeEventListener('pause', onPause);
|
||||
video.removeEventListener('ended', onEnded);
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
function togglePlayback() {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (video.paused) {
|
||||
void video.play();
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlayback}
|
||||
className={cn(
|
||||
'group relative h-[240px] w-[240px] overflow-hidden rounded-full shadow-md transition hover:shadow-lg',
|
||||
variant === 'mine' ? 'ring-2 ring-[#7ea06a]/40' : 'ring-2 ring-[#dce3ec]'
|
||||
)}
|
||||
aria-label={playing ? 'Пауза' : 'Воспроизвести кружок'}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
className="h-full w-full object-cover"
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
<span className="absolute inset-0 flex items-center justify-center bg-black/20 transition group-hover:bg-black/30">
|
||||
{loading ? (
|
||||
<Loader2 className="h-10 w-10 animate-spin text-white" />
|
||||
) : playing ? (
|
||||
<Pause className="h-10 w-10 text-white drop-shadow" />
|
||||
) : (
|
||||
<Play className="h-10 w-10 text-white drop-shadow" />
|
||||
)}
|
||||
</span>
|
||||
<span className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/55 px-2 py-0.5 text-[11px] text-white">
|
||||
{formatDuration(duration)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
107
apps/frontend/components/chat/chat-video-player.tsx
Normal file
107
apps/frontend/components/chat/chat-video-player.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, Pause, Play } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return '0:00';
|
||||
const whole = Math.floor(seconds);
|
||||
const mins = Math.floor(whole / 60);
|
||||
const secs = whole % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function ChatVideoPlayer({
|
||||
src,
|
||||
durationMs,
|
||||
fileName,
|
||||
variant = 'theirs'
|
||||
}: {
|
||||
src: string;
|
||||
durationMs?: number;
|
||||
fileName?: string;
|
||||
variant?: 'mine' | 'theirs';
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [duration, setDuration] = useState(durationMs ? durationMs / 1000 : 0);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const onLoaded = () => {
|
||||
if (Number.isFinite(video.duration) && video.duration > 0) {
|
||||
setDuration(video.duration);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
const onPlay = () => setPlaying(true);
|
||||
const onPause = () => setPlaying(false);
|
||||
const onEnded = () => setPlaying(false);
|
||||
|
||||
video.addEventListener('loadedmetadata', onLoaded);
|
||||
video.addEventListener('play', onPlay);
|
||||
video.addEventListener('pause', onPause);
|
||||
video.addEventListener('ended', onEnded);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', onLoaded);
|
||||
video.removeEventListener('play', onPlay);
|
||||
video.removeEventListener('pause', onPause);
|
||||
video.removeEventListener('ended', onEnded);
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
function togglePlayback() {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (video.paused) {
|
||||
void video.play();
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative max-w-[320px] overflow-hidden rounded-xl',
|
||||
variant === 'mine' ? 'bg-[#d9f7c8]/40' : 'bg-black/5'
|
||||
)}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
className="max-h-72 w-full bg-black object-contain"
|
||||
playsInline
|
||||
preload="metadata"
|
||||
onClick={togglePlayback}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlayback}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/15 transition hover:bg-black/25"
|
||||
aria-label={playing ? 'Пауза' : 'Воспроизвести'}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-10 w-10 animate-spin text-white" />
|
||||
) : playing ? (
|
||||
<Pause className="h-10 w-10 text-white drop-shadow" />
|
||||
) : (
|
||||
<Play className="h-10 w-10 text-white drop-shadow" />
|
||||
)}
|
||||
</button>
|
||||
<div className="absolute bottom-2 left-2 rounded-full bg-black/55 px-2 py-0.5 text-[11px] text-white">
|
||||
{formatDuration(duration)}
|
||||
</div>
|
||||
{fileName ? (
|
||||
<div className="truncate px-2 py-1 text-xs text-[#667085]" title={fileName}>
|
||||
{fileName}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user