feat: add waveform voice messages end-to-end
All checks were successful
CI / test (push) Successful in 23s

This commit is contained in:
2026-03-08 12:40:49 +03:00
parent 3b82b5e558
commit 30169a3a27
13 changed files with 361 additions and 19 deletions

View File

@@ -444,7 +444,7 @@ export function MessageList() {
</div>
) : null}
{renderMessageContent(message.type, message.text, {
{renderMessageContent(message, {
onAttachmentContextMenu: (event, url) => {
event.preventDefault();
void ensureReactionsLoaded(message.id);
@@ -784,13 +784,14 @@ export function MessageList() {
}
function renderMessageContent(
messageType: string,
text: string | null,
message: Message,
opts: {
onAttachmentContextMenu: (event: MouseEvent<HTMLElement>, url: string) => void;
onOpenMedia: (url: string, type: "image" | "video") => void;
}
) {
const messageType = message.type;
const text = message.text;
if (!text) return <p className="opacity-80">[empty]</p>;
if (messageType === "image") {
@@ -832,11 +833,7 @@ function renderMessageContent(
event.stopPropagation();
opts.onAttachmentContextMenu(event, text);
}}>
<div className="mb-1 flex items-center gap-2 text-xs text-slate-300">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-700/80">🎤</span>
<span className="font-semibold">Voice message</span>
</div>
<AudioInlinePlayer src={text} title="Voice message" />
<VoiceInlinePlayer src={text} title="Voice message" waveform={message.attachment_waveform ?? null} />
</div>
);
}
@@ -1031,6 +1028,92 @@ function AudioInlinePlayer({ src, title }: { src: string; title: string }) {
);
}
function VoiceInlinePlayer({
src,
title,
waveform,
}: {
src: string;
title: string;
waveform: number[] | null;
}) {
const track = useAudioPlayerStore((s) => s.track);
const isPlayingGlobal = useAudioPlayerStore((s) => s.isPlaying);
const durationGlobal = useAudioPlayerStore((s) => s.duration);
const positionGlobal = useAudioPlayerStore((s) => s.position);
const playTrack = useAudioPlayerStore((s) => s.playTrack);
const togglePlayGlobal = useAudioPlayerStore((s) => s.togglePlay);
const seekToGlobal = useAudioPlayerStore((s) => s.seekTo);
const isActiveTrack = track?.src === src;
const isPlaying = isActiveTrack && isPlayingGlobal;
const duration = isActiveTrack ? durationGlobal : 0;
const position = isActiveTrack ? positionGlobal : 0;
const bars = waveform && waveform.length >= 8 ? waveform : buildFallbackWaveform(src);
async function togglePlay() {
if (isActiveTrack) {
await togglePlayGlobal();
return;
}
await playTrack({ src, title });
}
function handleWaveClick(index: number) {
if (!isActiveTrack || duration <= 0) {
return;
}
const ratio = index / Math.max(1, bars.length - 1);
seekToGlobal(duration * ratio);
}
const progressRatio = duration > 0 ? Math.min(1, Math.max(0, position / duration)) : 0;
const activeBars = Math.floor(progressRatio * bars.length);
return (
<div className="flex items-center gap-2 rounded-lg bg-slate-900/40 px-2 py-1.5">
<button
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-sky-500/80 text-xs text-slate-950 hover:bg-sky-400"
onClick={() => void togglePlay()}
type="button"
>
{isPlaying ? "❚❚" : "▶"}
</button>
<div className="flex min-w-0 flex-1 items-end gap-[2px]">
{bars.map((value, index) => (
<button
className={`w-[3px] rounded-full p-0 ${index <= activeBars ? "bg-sky-300" : "bg-slate-500/60"}`}
key={`${src}-${index}`}
onClick={() => handleWaveClick(index)}
style={{ height: `${Math.max(6, value)}px` }}
type="button"
/>
))}
</div>
<span className="w-10 text-right text-[11px] tabular-nums text-slate-200">
{formatAudioTime(duration > 0 ? duration : position)}
</span>
</div>
);
}
function buildFallbackWaveform(seed: string, bars = 48): number[] {
let hash = 0;
for (let i = 0; i < seed.length; i += 1) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
}
const result: number[] = [];
let value = hash || 1;
for (let i = 0; i < bars; i += 1) {
value ^= value << 13;
value ^= value >> 17;
value ^= value << 5;
const normalized = Math.abs(value % 20) + 6;
result.push(normalized);
}
return result;
}
function formatAudioTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const total = Math.floor(seconds);