169 lines
5.1 KiB
TypeScript
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>
|
|
);
|
|
}
|