270 lines
8.5 KiB
TypeScript
270 lines
8.5 KiB
TypeScript
'use client';
|
||
|
||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { Loader2, Pause, Play, Volume2, VolumeX, X } from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
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 ChatVideoLightbox({
|
||
open,
|
||
src,
|
||
fileName,
|
||
durationMs,
|
||
onClose
|
||
}: {
|
||
open: boolean;
|
||
src: string;
|
||
fileName?: string;
|
||
durationMs?: number;
|
||
onClose: () => void;
|
||
}) {
|
||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const [mounted, setMounted] = useState(false);
|
||
const [playing, setPlaying] = useState(false);
|
||
const [loading, setLoading] = useState(true);
|
||
const [muted, setMuted] = useState(false);
|
||
const [progress, setProgress] = useState(0);
|
||
const [duration, setDuration] = useState(durationMs ? durationMs / 1000 : 0);
|
||
const [controlsVisible, setControlsVisible] = useState(true);
|
||
const [scrubbing, setScrubbing] = useState(false);
|
||
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
}, []);
|
||
|
||
const revealControls = useCallback(() => {
|
||
setControlsVisible(true);
|
||
if (hideControlsTimerRef.current) clearTimeout(hideControlsTimerRef.current);
|
||
hideControlsTimerRef.current = setTimeout(() => {
|
||
if (playing) setControlsVisible(false);
|
||
}, 2800);
|
||
}, [playing]);
|
||
|
||
const togglePlayback = useCallback(() => {
|
||
const video = videoRef.current;
|
||
if (!video) return;
|
||
if (video.paused) {
|
||
void video.play();
|
||
} else {
|
||
video.pause();
|
||
}
|
||
revealControls();
|
||
}, [revealControls]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
function handleKeyDown(event: KeyboardEvent) {
|
||
if (event.key === 'Escape') onClose();
|
||
if (event.key === ' ') {
|
||
event.preventDefault();
|
||
togglePlayback();
|
||
}
|
||
}
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||
}, [open, onClose, togglePlayback]);
|
||
|
||
useEffect(() => {
|
||
if (!open || typeof document === 'undefined') return;
|
||
const previousOverflow = document.body.style.overflow;
|
||
document.body.style.overflow = 'hidden';
|
||
return () => {
|
||
document.body.style.overflow = previousOverflow;
|
||
};
|
||
}, [open]);
|
||
|
||
useEffect(() => {
|
||
const video = videoRef.current;
|
||
if (!video || !open) return;
|
||
|
||
const onLoaded = () => {
|
||
if (Number.isFinite(video.duration) && video.duration > 0) {
|
||
setDuration(video.duration);
|
||
}
|
||
setLoading(false);
|
||
};
|
||
const onTimeUpdate = () => {
|
||
if (!scrubbing && video.duration) {
|
||
setProgress(video.currentTime / video.duration);
|
||
}
|
||
};
|
||
const onPlay = () => {
|
||
setPlaying(true);
|
||
revealControls();
|
||
};
|
||
const onPause = () => {
|
||
setPlaying(false);
|
||
setControlsVisible(true);
|
||
};
|
||
const onEnded = () => {
|
||
setPlaying(false);
|
||
setControlsVisible(true);
|
||
};
|
||
|
||
video.addEventListener('loadedmetadata', onLoaded);
|
||
video.addEventListener('timeupdate', onTimeUpdate);
|
||
video.addEventListener('play', onPlay);
|
||
video.addEventListener('pause', onPause);
|
||
video.addEventListener('ended', onEnded);
|
||
|
||
return () => {
|
||
video.removeEventListener('loadedmetadata', onLoaded);
|
||
video.removeEventListener('timeupdate', onTimeUpdate);
|
||
video.removeEventListener('play', onPlay);
|
||
video.removeEventListener('pause', onPause);
|
||
video.removeEventListener('ended', onEnded);
|
||
};
|
||
}, [open, revealControls, scrubbing, src]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const video = videoRef.current;
|
||
if (!video) return;
|
||
setLoading(true);
|
||
setProgress(0);
|
||
setPlaying(false);
|
||
video.src = src;
|
||
video.load();
|
||
void video.play().catch(() => undefined);
|
||
}, [open, src]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (hideControlsTimerRef.current) clearTimeout(hideControlsTimerRef.current);
|
||
};
|
||
}, []);
|
||
|
||
function seekTo(ratio: number) {
|
||
const video = videoRef.current;
|
||
if (!video || !video.duration) return;
|
||
const next = Math.min(Math.max(ratio, 0), 1);
|
||
video.currentTime = next * video.duration;
|
||
setProgress(next);
|
||
}
|
||
|
||
if (!open || !mounted) return null;
|
||
|
||
return createPortal(
|
||
<div
|
||
className="fixed inset-0 z-[5500] flex flex-col bg-[#05070d]/96"
|
||
onMouseMove={revealControls}
|
||
onClick={revealControls}
|
||
>
|
||
<div
|
||
className={cn(
|
||
'flex items-center justify-between px-4 py-3 text-white transition-opacity duration-300',
|
||
controlsVisible ? 'opacity-100' : 'opacity-0'
|
||
)}
|
||
>
|
||
<div className="min-w-0">
|
||
<p className="truncate text-sm font-medium">{fileName?.trim() || 'Видео'}</p>
|
||
<p className="text-xs text-white/55">Полноэкранный просмотр</p>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
size="icon"
|
||
variant="ghost"
|
||
className="text-white hover:bg-white/10"
|
||
aria-label="Закрыть"
|
||
onClick={onClose}
|
||
>
|
||
<X className="h-5 w-5" />
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="relative flex min-h-0 flex-1 items-center justify-center px-4 pb-4">
|
||
<video
|
||
ref={videoRef}
|
||
src={src}
|
||
className="max-h-full max-w-full rounded-2xl bg-black shadow-[0_24px_80px_rgba(0,0,0,0.45)]"
|
||
playsInline
|
||
onClick={togglePlayback}
|
||
/>
|
||
|
||
{loading ? (
|
||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||
<Loader2 className="h-12 w-12 animate-spin text-white/80" />
|
||
</div>
|
||
) : null}
|
||
|
||
{!playing && !loading ? (
|
||
<button
|
||
type="button"
|
||
onClick={togglePlayback}
|
||
className="absolute flex h-20 w-20 items-center justify-center rounded-full bg-white/15 text-white backdrop-blur-sm transition hover:bg-white/25"
|
||
aria-label="Воспроизвести"
|
||
>
|
||
<Play className="ml-1 h-10 w-10" />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
|
||
<div
|
||
className={cn(
|
||
'px-4 pb-[max(1rem,env(safe-area-inset-bottom))] transition-opacity duration-300',
|
||
controlsVisible ? 'opacity-100' : 'opacity-0'
|
||
)}
|
||
>
|
||
<div className="mx-auto w-full max-w-3xl rounded-2xl bg-black/45 px-4 py-3 backdrop-blur-md">
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={1000}
|
||
value={Math.round(progress * 1000)}
|
||
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-white/20 accent-[#3390ec]"
|
||
onChange={(event) => {
|
||
setScrubbing(true);
|
||
seekTo(Number(event.target.value) / 1000);
|
||
}}
|
||
onMouseUp={() => setScrubbing(false)}
|
||
onTouchEnd={() => setScrubbing(false)}
|
||
aria-label="Позиция воспроизведения"
|
||
/>
|
||
|
||
<div className="mt-3 flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={togglePlayback}
|
||
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||
aria-label={playing ? 'Пауза' : 'Воспроизвести'}
|
||
>
|
||
{playing ? <Pause className="h-5 w-5" /> : <Play className="ml-0.5 h-5 w-5" />}
|
||
</button>
|
||
|
||
<span className="min-w-[88px] text-xs tabular-nums text-white/80">
|
||
{formatDuration(progress * duration)} / {formatDuration(duration)}
|
||
</span>
|
||
|
||
<div className="flex-1" />
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const video = videoRef.current;
|
||
if (!video) return;
|
||
video.muted = !video.muted;
|
||
setMuted(video.muted);
|
||
}}
|
||
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||
aria-label={muted ? 'Включить звук' : 'Выключить звук'}
|
||
>
|
||
{muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
);
|
||
}
|