family update
This commit is contained in:
168
apps/frontend/components/chat/voice-message-player.tsx
Normal file
168
apps/frontend/components/chat/voice-message-player.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user