Files
IdP/apps/frontend/components/chat/voice-message-player.tsx
2026-06-24 23:17:24 +03:00

169 lines
5.1 KiB
TypeScript

'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Loader2, Pause, Play } from 'lucide-react';
import { cn } from '@/lib/utils';
function seedWaveform(seed: string, count = 36) {
let hash = 0;
for (let index = 0; index < seed.length; index += 1) {
hash = (hash * 31 + seed.charCodeAt(index)) | 0;
}
return Array.from({ length: count }, (_, index) => {
const wave = Math.abs(Math.sin((hash + 1) * (index + 1) * 0.17));
return 0.25 + wave * 0.75;
});
}
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 VoiceMessagePlayer({
src,
seed,
durationMs,
variant = 'theirs'
}: {
src?: string;
seed: string;
durationMs?: number;
variant?: 'mine' | 'theirs';
}) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [playing, setPlaying] = useState(false);
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(durationMs ? durationMs / 1000 : 0);
const bars = useMemo(() => seedWaveform(seed), [seed]);
useEffect(() => {
const audio = new Audio();
audio.preload = 'metadata';
audioRef.current = audio;
const onLoaded = () => {
if (Number.isFinite(audio.duration) && audio.duration > 0) {
setDuration(audio.duration);
}
setLoading(false);
};
const onTimeUpdate = () => {
if (!audio.duration) return;
setProgress(audio.currentTime / audio.duration);
};
const onEnded = () => {
setPlaying(false);
setProgress(0);
audio.currentTime = 0;
};
const onWaiting = () => setLoading(true);
const onCanPlay = () => setLoading(false);
const onError = () => {
setLoading(false);
setPlaying(false);
};
audio.addEventListener('loadedmetadata', onLoaded);
audio.addEventListener('timeupdate', onTimeUpdate);
audio.addEventListener('ended', onEnded);
audio.addEventListener('waiting', onWaiting);
audio.addEventListener('canplay', onCanPlay);
audio.addEventListener('error', onError);
return () => {
audio.pause();
audio.removeEventListener('loadedmetadata', onLoaded);
audio.removeEventListener('timeupdate', onTimeUpdate);
audio.removeEventListener('ended', onEnded);
audio.removeEventListener('waiting', onWaiting);
audio.removeEventListener('canplay', onCanPlay);
audio.removeEventListener('error', onError);
audioRef.current = null;
};
}, []);
useEffect(() => {
const audio = audioRef.current;
if (!audio || !src) return;
setLoading(true);
audio.src = src;
audio.load();
}, [src]);
async function togglePlayback() {
const audio = audioRef.current;
if (!audio || !src) return;
if (playing) {
audio.pause();
setPlaying(false);
return;
}
try {
setLoading(true);
await audio.play();
setPlaying(true);
} catch {
setPlaying(false);
} finally {
setLoading(false);
}
}
function seekTo(ratio: number) {
const audio = audioRef.current;
if (!audio || !audio.duration) return;
const next = Math.min(Math.max(ratio, 0), 1);
audio.currentTime = next * audio.duration;
setProgress(next);
}
const accent = variant === 'mine' ? 'bg-[#5bb85d]' : 'bg-[#3390ec]';
const accentSoft = variant === 'mine' ? 'bg-[#5bb85d]/25' : 'bg-[#3390ec]/25';
const remaining = duration * (1 - progress);
return (
<div className="flex min-w-[240px] max-w-[320px] items-center gap-3 py-1">
<button
type="button"
disabled={!src}
onClick={() => void togglePlayback()}
className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-white transition hover:opacity-90 disabled:opacity-50',
accent
)}
aria-label={playing ? 'Пауза' : 'Воспроизвести'}
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : playing ? <Pause className="h-4 w-4" /> : <Play className="ml-0.5 h-4 w-4" />}
</button>
<button type="button" className="flex h-10 flex-1 items-end gap-[2px]" onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
seekTo((event.clientX - rect.left) / rect.width);
}}>
{bars.map((height, index) => {
const barProgress = (index + 1) / bars.length;
const active = barProgress <= progress;
return (
<span
key={`${seed}-${index}`}
className={cn('w-[3px] rounded-full transition-colors', active ? accent : accentSoft)}
style={{ height: `${Math.round(height * 28 + 8)}px` }}
/>
);
})}
</button>
<span className="w-10 shrink-0 text-right text-[11px] tabular-nums text-[#667085]">
{formatDuration(playing || progress > 0 ? remaining : duration)}
</span>
</div>
);
}