Files
IdP/apps/frontend/components/chat/chat-video-lightbox.tsx
lendry f00f3d411d fix
2026-07-10 12:21:44 +03:00

270 lines
8.5 KiB
TypeScript
Raw Permalink 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 { 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
);
}