fix
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, Pause, Play } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const SIZE = 240;
|
||||
const RING_WIDTH = 4;
|
||||
const RADIUS = (SIZE - RING_WIDTH) / 2;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return '0:00';
|
||||
const whole = Math.floor(seconds);
|
||||
@@ -12,6 +17,16 @@ function formatDuration(seconds: number) {
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function progressFromPointer(clientX: number, clientY: number, rect: DOMRect) {
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
const dx = clientX - cx;
|
||||
const dy = clientY - cy;
|
||||
let angle = Math.atan2(dy, dx) + Math.PI / 2;
|
||||
if (angle < 0) angle += Math.PI * 2;
|
||||
return Math.min(Math.max(angle / (Math.PI * 2), 0), 1);
|
||||
}
|
||||
|
||||
export function ChatVideoNotePlayer({
|
||||
src,
|
||||
durationMs,
|
||||
@@ -21,11 +36,27 @@ export function ChatVideoNotePlayer({
|
||||
durationMs?: number;
|
||||
variant?: 'mine' | 'theirs';
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const scrubbingRef = useRef(false);
|
||||
const wasPlayingRef = useRef(false);
|
||||
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [scrubbing, setScrubbing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [duration, setDuration] = useState(durationMs ? durationMs / 1000 : 0);
|
||||
|
||||
const ringColor = variant === 'mine' ? '#7ea06a' : '#3390ec';
|
||||
|
||||
const seekTo = useCallback((ratio: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return;
|
||||
const next = Math.min(Math.max(ratio, 0), 1);
|
||||
video.currentTime = next * video.duration;
|
||||
setProgress(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
@@ -36,26 +67,64 @@ export function ChatVideoNotePlayer({
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
const onTimeUpdate = () => {
|
||||
if (scrubbingRef.current || !video.duration) return;
|
||||
setProgress(video.currentTime / video.duration);
|
||||
};
|
||||
const onPlay = () => setPlaying(true);
|
||||
const onPause = () => setPlaying(false);
|
||||
const onEnded = () => setPlaying(false);
|
||||
const onEnded = () => {
|
||||
setPlaying(false);
|
||||
setProgress(0);
|
||||
video.currentTime = 0;
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!scrubbingRef.current || !rootRef.current) return;
|
||||
const rect = rootRef.current.getBoundingClientRect();
|
||||
seekTo(progressFromPointer(event.clientX, event.clientY, rect));
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
if (!scrubbingRef.current) return;
|
||||
scrubbingRef.current = false;
|
||||
setScrubbing(false);
|
||||
const video = videoRef.current;
|
||||
if (video && wasPlayingRef.current) {
|
||||
void video.play();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
window.addEventListener('pointercancel', handlePointerUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('pointercancel', handlePointerUp);
|
||||
};
|
||||
}, [seekTo]);
|
||||
|
||||
function togglePlayback() {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (!video || scrubbingRef.current) return;
|
||||
if (video.paused) {
|
||||
void video.play();
|
||||
} else {
|
||||
@@ -63,35 +132,103 @@ export function ChatVideoNotePlayer({
|
||||
}
|
||||
}
|
||||
|
||||
function handleScrubStart(event: React.PointerEvent<HTMLDivElement>) {
|
||||
if (!rootRef.current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
scrubbingRef.current = true;
|
||||
setScrubbing(true);
|
||||
const video = videoRef.current;
|
||||
wasPlayingRef.current = Boolean(video && !video.paused);
|
||||
video?.pause();
|
||||
const rect = rootRef.current.getBoundingClientRect();
|
||||
seekTo(progressFromPointer(event.clientX, event.clientY, rect));
|
||||
}
|
||||
|
||||
const showControls = !playing || scrubbing || loading;
|
||||
const remaining = duration * (1 - progress);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlayback}
|
||||
<div
|
||||
ref={rootRef}
|
||||
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]'
|
||||
'group relative h-[240px] w-[240px] select-none',
|
||||
variant === 'mine' ? 'text-[#7ea06a]' : 'text-[#3390ec]'
|
||||
)}
|
||||
aria-label={playing ? 'Пауза' : 'Воспроизвести кружок'}
|
||||
data-chat-interactive="true"
|
||||
>
|
||||
<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" />
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-full w-full overflow-hidden rounded-full shadow-md transition hover:shadow-lg',
|
||||
variant === 'mine' ? 'ring-2 ring-[#7ea06a]/40' : 'ring-2 ring-[#dce3ec]'
|
||||
)}
|
||||
</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>
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
className="h-full w-full object-cover"
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0"
|
||||
width={SIZE}
|
||||
height={SIZE}
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
aria-hidden
|
||||
>
|
||||
<circle
|
||||
cx={SIZE / 2}
|
||||
cy={SIZE / 2}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.28)"
|
||||
strokeWidth={RING_WIDTH}
|
||||
/>
|
||||
<circle
|
||||
cx={SIZE / 2}
|
||||
cy={SIZE / 2}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke={ringColor}
|
||||
strokeWidth={RING_WIDTH}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={CIRCUMFERENCE}
|
||||
strokeDashoffset={CIRCUMFERENCE * (1 - progress)}
|
||||
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
|
||||
className="transition-[stroke-dashoffset] duration-75"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className="absolute inset-0 rounded-full"
|
||||
onPointerDown={handleScrubStart}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlayback}
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center rounded-full transition',
|
||||
showControls ? 'bg-black/25 opacity-100' : 'bg-transparent opacity-0 group-hover:bg-black/20 group-hover:opacity-100'
|
||||
)}
|
||||
aria-label={playing ? 'Пауза' : 'Воспроизвести кружок'}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-10 w-10 animate-spin text-white drop-shadow" />
|
||||
) : playing && !scrubbing ? (
|
||||
<Pause className="h-10 w-10 text-white drop-shadow" />
|
||||
) : (
|
||||
<Play className="h-10 w-10 text-white drop-shadow" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<span className="pointer-events-none 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(scrubbing || playing || progress > 0 ? remaining : duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user