feat(web): voice messages (record, send, playback)
Click-to-record (README §3.3's explicit 取消/发送 buttons), not the old
app's press-and-hold-with-swipe-to-cancel gesture -- the new design replaces
that touch gesture with plain buttons, a reasonable desktop-first
adaptation; the underlying MediaRecorder mechanics (audio/webm or
audio/mp4 depending on browser support, 24kbps, 2s minimum/10min maximum
duration) are ported as-is from the old app's audio-recorder.jsx.
- VoiceRecorder.tsx: recording UI + MediaRecorder lifecycle. Silently
discards (no send) recordings under MIN_DURATION, matching the old app.
Deliberately skips the `webm-duration-fix` package the old app pulled in
for Chrome's well-known missing-webm-duration-metadata bug -- this app's
own displayed duration comes from its own recording timer, not the file's
embedded metadata, so only a browser's native <audio> scrubber would be
affected, not anything this app actually shows.
- attachments.ts: sendVoice() -- inline base64 or upload-then-reference,
same size-based branch as images/files, Drafty.insertAudio's confirmed
{mime,val/ref,duration,size} shape.
- AttachmentPreview.tsx: AudioAttachment play/pause button + static
waveform (CSS repeating-gradient, not real waveform analysis) + duration.
Verified the failure path live (this sandbox blocks getUserMedia): the
recorder's error state shows a clear message and recovers cleanly back to
the normal composer with no crash. The actual record -> encode -> send ->
playback round trip could not be exercised in this environment and needs
a manual check with real microphone access.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
aa3e464b35
commit
03b8098b3c
@@ -100,3 +100,50 @@ export function FileAttachment({ data }: { data: FileData }) {
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
interface AudioData {
|
||||
val?: string;
|
||||
ref?: string;
|
||||
mime?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
function formatAudioDuration(ms: number | undefined): string {
|
||||
const totalSeconds = Math.round((ms || 0) / 1000);
|
||||
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function AudioAttachment({ data }: { data: AudioData }) {
|
||||
const inline = typeof data.val === 'string' ? `data:${data.mime || 'audio/webm'};base64,${data.val}` : null;
|
||||
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
|
||||
const src = inline || remote.url;
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [audioEl] = useState(() => (typeof Audio !== 'undefined' ? new Audio() : null));
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioEl) return undefined;
|
||||
const onEnded = () => setPlaying(false);
|
||||
audioEl.addEventListener('ended', onEnded);
|
||||
return () => audioEl.removeEventListener('ended', onEnded);
|
||||
}, [audioEl]);
|
||||
|
||||
const toggle = () => {
|
||||
if (!audioEl || !src) return;
|
||||
if (playing) {
|
||||
audioEl.pause();
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
if (audioEl.src !== src) audioEl.src = src;
|
||||
audioEl.play().catch(() => {});
|
||||
setPlaying(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<button type="button" onClick={toggle} disabled={!src} className="flex w-[200px] items-center gap-2">
|
||||
<Icon name={playing ? 'pause_circle' : 'play_circle'} className="text-[24px] text-accent" />
|
||||
<span className="h-4 flex-1 rounded bg-[repeating-linear-gradient(90deg,#b7c3d4_0px,#b7c3d4_2px,transparent_2px,transparent_5px)]" />
|
||||
<span className="flex-none text-xs text-text-secondary">{remote.loading ? '…' : formatAudioDuration(data.duration)}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user