Files
ChatApp/apps/desktop/src/components/AttachmentAudio.tsx
T

283 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
}