283 lines
9.5 KiB
TypeScript
283 lines
9.5 KiB
TypeScript
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
|
||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||
import { getVoiceSpeed, setVoiceSpeed, VOICE_SPEEDS, type VoiceSpeed } from '../lib/voiceSpeedSettings';
|
||
import { supabase } from '../lib/supabase';
|
||
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
||
|
||
interface Props {
|
||
handle: AttachmentHandle;
|
||
}
|
||
|
||
const BAR_COUNT = 48;
|
||
|
||
// Custom voice-message player with waveform visualisation. Decoded peaks are
|
||
// computed once per blob via OfflineAudioContext so playback only carries the
|
||
// rendered DOM. Falls back to a rectangular bar if decoding fails (e.g. the
|
||
// blob mime is recognised by <audio> but not by AudioContext).
|
||
export function AttachmentAudio({ handle }: Props) {
|
||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||
const [arrayBuf, setArrayBuf] = useState<ArrayBuffer | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [peaks, setPeaks] = useState<number[] | null>(null);
|
||
const [duration, setDuration] = useState<number>(0);
|
||
const [position, setPosition] = useState<number>(0);
|
||
const [playing, setPlaying] = useState(false);
|
||
const [speed, setSpeed] = useState<VoiceSpeed>(() => getVoiceSpeed());
|
||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
let url: string | null = null;
|
||
setError(null);
|
||
setBlobUrl(null);
|
||
setArrayBuf(null);
|
||
|
||
void (async () => {
|
||
const cached = await getCachedAttachment(handle.id);
|
||
if (cached) {
|
||
if (cancelled) return;
|
||
url = URL.createObjectURL(cached);
|
||
setBlobUrl(url);
|
||
const buf = await cached.arrayBuffer();
|
||
if (!cancelled) setArrayBuf(buf);
|
||
return;
|
||
}
|
||
try {
|
||
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||
if (cancelled) return;
|
||
url = URL.createObjectURL(blob);
|
||
setBlobUrl(url);
|
||
const buf = await blob.arrayBuffer();
|
||
if (!cancelled) setArrayBuf(buf);
|
||
void putCachedAttachment(handle.id, blob);
|
||
} catch (err: unknown) {
|
||
if (!cancelled) {
|
||
setError(err instanceof Error ? err.message : 'download failed');
|
||
}
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
if (url) URL.revokeObjectURL(url);
|
||
};
|
||
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||
|
||
// Compute peaks via OfflineAudioContext. Cheap O(n) scan over PCM samples
|
||
// bucketed into BAR_COUNT bars. Done once per attachment.
|
||
useEffect(() => {
|
||
if (!arrayBuf) return;
|
||
let cancelled = false;
|
||
const Ctx =
|
||
window.AudioContext ||
|
||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||
const ctx = new Ctx();
|
||
ctx
|
||
.decodeAudioData(arrayBuf.slice(0))
|
||
.then((decoded) => {
|
||
if (cancelled) return;
|
||
setDuration(decoded.duration);
|
||
const channel = decoded.getChannelData(0);
|
||
const bucket = Math.max(1, Math.floor(channel.length / BAR_COUNT));
|
||
const out = new Array<number>(BAR_COUNT).fill(0);
|
||
for (let i = 0; i < BAR_COUNT; i++) {
|
||
let max = 0;
|
||
const start = i * bucket;
|
||
const end = Math.min(channel.length, start + bucket);
|
||
for (let j = start; j < end; j++) {
|
||
const v = Math.abs(channel[j]!);
|
||
if (v > max) max = v;
|
||
}
|
||
out[i] = max;
|
||
}
|
||
// Normalize so loudest peak is 1; keeps quiet recordings visible.
|
||
const peak = Math.max(...out, 0.001);
|
||
setPeaks(out.map((v) => v / peak));
|
||
})
|
||
.catch(() => {
|
||
// Fall through — UI shows a flat bar but playback still works.
|
||
})
|
||
.finally(() => {
|
||
void ctx.close().catch(() => {});
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [arrayBuf]);
|
||
|
||
useEffect(() => {
|
||
const el = audioRef.current;
|
||
if (!el) return;
|
||
el.playbackRate = speed;
|
||
}, [speed, blobUrl]);
|
||
|
||
const fallbackPeaks = useMemo(
|
||
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
|
||
[peaks],
|
||
);
|
||
const visiblePeaks = peaks ?? fallbackPeaks!;
|
||
const progress = duration > 0 ? position / duration : 0;
|
||
|
||
const onTogglePlay = () => {
|
||
const el = audioRef.current;
|
||
if (!el || !blobUrl) return;
|
||
if (el.paused) void el.play();
|
||
else el.pause();
|
||
};
|
||
|
||
const onSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||
const el = audioRef.current;
|
||
if (!el || duration === 0) return;
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||
el.currentTime = ratio * duration;
|
||
};
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
|
||
<AlertIcon className="h-4 w-4" />
|
||
<span>{error}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="mt-2 flex w-[280px] min-w-[280px] items-center gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2">
|
||
<button
|
||
type="button"
|
||
onClick={onTogglePlay}
|
||
disabled={!blobUrl}
|
||
aria-label={playing ? 'Pause' : 'Wiedergabe'}
|
||
title={playing ? 'Pause' : 'Wiedergabe'}
|
||
className="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
{!blobUrl ? (
|
||
<SpinnerIcon className="h-4 w-4" />
|
||
) : playing ? (
|
||
<PauseGlyph />
|
||
) : (
|
||
<PlayGlyph />
|
||
)}
|
||
</button>
|
||
<div className="flex shrink-0 items-center gap-0.5 rounded-md bg-surface-3 p-0.5 text-[10px] font-semibold text-fg-muted">
|
||
{VOICE_SPEEDS.map((s) => {
|
||
const active = s === speed;
|
||
return (
|
||
<button
|
||
key={s}
|
||
type="button"
|
||
onClick={() => {
|
||
setSpeed(s);
|
||
setVoiceSpeed(s);
|
||
}}
|
||
className={
|
||
'flex h-6 w-7 cursor-pointer items-center justify-center rounded transition ' +
|
||
(active ? 'bg-accent text-accent-fg' : 'hover:bg-surface hover:text-fg')
|
||
}
|
||
aria-pressed={active}
|
||
title={'Wiedergabegeschwindigkeit ' + s + '×'}
|
||
>
|
||
{s}×
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||
<div
|
||
role="slider"
|
||
aria-label="Position"
|
||
aria-valuemin={0}
|
||
aria-valuemax={Math.max(1, Math.floor(duration))}
|
||
aria-valuenow={Math.floor(position)}
|
||
tabIndex={0}
|
||
onClick={onSeek}
|
||
className="flex h-7 cursor-pointer items-center gap-[2px]"
|
||
>
|
||
{visiblePeaks.map((v, i) => {
|
||
const playedRatio = (i + 0.5) / BAR_COUNT;
|
||
const played = playedRatio <= progress;
|
||
const h = Math.max(2, Math.round(v * 22));
|
||
return (
|
||
<span
|
||
key={i}
|
||
style={{ height: h + 'px' }}
|
||
className={
|
||
'w-[3px] rounded-full ' +
|
||
(played ? 'bg-accent' : 'bg-fg-muted/40')
|
||
}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="flex items-center justify-between text-[10px] tabular-nums text-fg-muted">
|
||
<span className="inline-flex items-center gap-1">
|
||
<MicIcon className="h-3 w-3" />
|
||
<span>{formatSec(playing ? position : duration)}</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{blobUrl && (
|
||
<audio
|
||
ref={audioRef}
|
||
src={blobUrl}
|
||
preload="metadata"
|
||
onLoadedMetadata={(e) => {
|
||
// Some webm/opus blobs report Infinity until first seek (Chrome
|
||
// bug). Force a seek to flush real duration.
|
||
const el = e.currentTarget;
|
||
if (!Number.isFinite(el.duration)) {
|
||
el.currentTime = 1e9;
|
||
setTimeout(() => {
|
||
el.currentTime = 0;
|
||
}, 0);
|
||
} else if (duration === 0) {
|
||
setDuration(el.duration);
|
||
}
|
||
}}
|
||
onDurationChange={(e) => {
|
||
const d = e.currentTarget.duration;
|
||
if (Number.isFinite(d) && d > 0) setDuration(d);
|
||
}}
|
||
onTimeUpdate={(e) => setPosition(e.currentTarget.currentTime)}
|
||
onPlay={() => setPlaying(true)}
|
||
onPause={() => setPlaying(false)}
|
||
onEnded={() => {
|
||
setPlaying(false);
|
||
setPosition(0);
|
||
}}
|
||
className="hidden"
|
||
>
|
||
<track kind="captions" />
|
||
</audio>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PlayGlyph() {
|
||
return (
|
||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||
<path d="M5 3.5l8 4.5-8 4.5z" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function PauseGlyph() {
|
||
return (
|
||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||
<rect x="4" y="3" width="3" height="10" rx="1" />
|
||
<rect x="9" y="3" width="3" height="10" rx="1" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function formatSec(sec: number): string {
|
||
if (!Number.isFinite(sec) || sec < 0) sec = 0;
|
||
const m = Math.floor(sec / 60);
|
||
const s = Math.floor(sec % 60);
|
||
return m + ':' + s.toString().padStart(2, '0');
|
||
}
|