feat: add waveform voice messages end-to-end
All checks were successful
CI / test (push) Successful in 23s
All checks were successful
CI / test (push) Successful in 23s
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user