825160ee46
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.
Highlights:
- All 17 Tauri release commits ported (audio fixes, custom notification
sound, Discord-style chat UX, profile banner, changelog page,
Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
fix).
- Native napi-rs audio-loopback addon with WASAPI process-loopback:
* EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
never hear themselves echoed back through the capture.
* INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
picked window's audio is captured, not the whole OS mixer
(Discord parity).
* HWND -> PID resolution via Win32 GetWindowThreadProcessId.
- Discord-style screen-source picker (thumbnail grid, screens vs
apps tabs, live-refreshing thumbnails).
- Hash routing fix for packaged builds (file:// can't resolve
BrowserRouter paths).
- Tauri sources removed (apps/desktop/src-tauri).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
250 lines
8.5 KiB
TypeScript
250 lines
8.5 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
|
||
import { MicIcon, SpinnerIcon, XIcon } from './icons';
|
||
|
||
interface Props {
|
||
/** Called with the recorded audio file when the user confirms. The button
|
||
* ships as a single-attachment message, so the parent can feed it into
|
||
* the normal send flow. */
|
||
onComplete: (file: File) => Promise<void> | void;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
// Single-button voice recorder. Click to start; shows an inline pill with
|
||
// elapsed time + stop + cancel while recording. On stop, hands a File to the
|
||
// parent. No in-place preview yet — we lean on the optimistic message bubble
|
||
// to appear once the parent sends.
|
||
const MAX_RECORD_SEC = 60;
|
||
|
||
export function VoiceRecorder({ onComplete, disabled = false }: Props) {
|
||
const [state, setState] = useState<'idle' | 'recording' | 'finalizing'>('idle');
|
||
const [elapsedSec, setElapsedSec] = useState(0);
|
||
const [level, setLevel] = useState(0);
|
||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||
const chunksRef = useRef<Blob[]>([]);
|
||
const streamRef = useRef<MediaStream | null>(null);
|
||
const startedAtRef = useRef<number>(0);
|
||
const cancelledRef = useRef(false);
|
||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||
const rafRef = useRef<number>(0);
|
||
|
||
// Tick elapsed + auto-stop at MAX_RECORD_SEC.
|
||
useEffect(() => {
|
||
if (state !== 'recording') return;
|
||
const id = window.setInterval(() => {
|
||
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000);
|
||
setElapsedSec(sec);
|
||
if (sec >= MAX_RECORD_SEC) {
|
||
const rec = recorderRef.current;
|
||
if (rec && rec.state !== 'inactive') rec.stop();
|
||
}
|
||
}, 200);
|
||
return () => {
|
||
window.clearInterval(id);
|
||
};
|
||
}, [state]);
|
||
|
||
// Live mic level meter via Web Audio AnalyserNode. RMS on 0..1, smoothed.
|
||
useEffect(() => {
|
||
if (state !== 'recording') return;
|
||
const analyser = analyserRef.current;
|
||
if (!analyser) return;
|
||
const buf = new Uint8Array(analyser.frequencyBinCount);
|
||
let cancelled = false;
|
||
const tick = () => {
|
||
if (cancelled) return;
|
||
analyser.getByteFrequencyData(buf as Uint8Array<ArrayBuffer>);
|
||
let sum = 0;
|
||
for (let i = 0; i < buf.length; i++) sum += buf[i]!;
|
||
setLevel(sum / (buf.length * 255));
|
||
rafRef.current = window.requestAnimationFrame(tick);
|
||
};
|
||
rafRef.current = window.requestAnimationFrame(tick);
|
||
return () => {
|
||
cancelled = true;
|
||
window.cancelAnimationFrame(rafRef.current);
|
||
};
|
||
}, [state]);
|
||
|
||
const stopStream = () => {
|
||
if (streamRef.current) {
|
||
streamRef.current.getTracks().forEach((t) => t.stop());
|
||
streamRef.current = null;
|
||
}
|
||
if (audioCtxRef.current) {
|
||
void audioCtxRef.current.close().catch(() => {});
|
||
audioCtxRef.current = null;
|
||
}
|
||
analyserRef.current = null;
|
||
setLevel(0);
|
||
};
|
||
|
||
const start = async () => {
|
||
if (disabled) return;
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
streamRef.current = stream;
|
||
|
||
try {
|
||
const ctx = new (
|
||
window.AudioContext ||
|
||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
|
||
)();
|
||
const source = ctx.createMediaStreamSource(stream);
|
||
const analyser = ctx.createAnalyser();
|
||
analyser.fftSize = 256;
|
||
analyser.smoothingTimeConstant = 0.4;
|
||
source.connect(analyser);
|
||
audioCtxRef.current = ctx;
|
||
analyserRef.current = analyser;
|
||
} catch {
|
||
/* Analyser is best-effort; recording still works without level meter. */
|
||
}
|
||
|
||
const mime = pickMime();
|
||
const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
|
||
recorderRef.current = rec;
|
||
chunksRef.current = [];
|
||
cancelledRef.current = false;
|
||
|
||
rec.ondataavailable = (ev: BlobEvent) => {
|
||
if (ev.data && ev.data.size > 0) chunksRef.current.push(ev.data);
|
||
};
|
||
rec.onstop = () => {
|
||
const blob = new Blob(chunksRef.current, { type: rec.mimeType || 'audio/webm' });
|
||
chunksRef.current = [];
|
||
stopStream();
|
||
if (cancelledRef.current || blob.size === 0) {
|
||
setState('idle');
|
||
setElapsedSec(0);
|
||
return;
|
||
}
|
||
setState('finalizing');
|
||
const ext = extFor(blob.type);
|
||
const filename = 'voice-' + new Date().toISOString().replace(/[:.]/g, '-') + '.' + ext;
|
||
const file = new File([blob], filename, { type: blob.type });
|
||
void Promise.resolve(onComplete(file)).finally(() => {
|
||
setState('idle');
|
||
setElapsedSec(0);
|
||
});
|
||
};
|
||
|
||
rec.start();
|
||
startedAtRef.current = Date.now();
|
||
setElapsedSec(0);
|
||
setState('recording');
|
||
} catch (err: unknown) {
|
||
stopStream();
|
||
// Surface device-permission denials or capture failures via console;
|
||
// the composer doesn't have space for inline errors here and the
|
||
// browser already shows a system-level permission prompt.
|
||
console.error('VoiceRecorder.start failed', err);
|
||
}
|
||
};
|
||
|
||
const confirm = () => {
|
||
const rec = recorderRef.current;
|
||
if (!rec) return;
|
||
if (rec.state !== 'inactive') rec.stop();
|
||
};
|
||
|
||
const cancel = () => {
|
||
cancelledRef.current = true;
|
||
const rec = recorderRef.current;
|
||
if (rec && rec.state !== 'inactive') rec.stop();
|
||
else {
|
||
stopStream();
|
||
setState('idle');
|
||
setElapsedSec(0);
|
||
}
|
||
};
|
||
|
||
if (state === 'idle') {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => void start()}
|
||
disabled={disabled}
|
||
aria-label="Sprachnachricht aufnehmen"
|
||
title="Sprachnachricht aufnehmen"
|
||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60 dark:hover:bg-[#313338]"
|
||
>
|
||
<MicIcon className="h-4 w-4" />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
if (state === 'finalizing') {
|
||
return (
|
||
<div className="inline-flex h-10 items-center gap-2 rounded-lg px-3 text-xs text-fg-muted">
|
||
<SpinnerIcon className="h-4 w-4" />
|
||
<span>Wird gesendet…</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Visual level: scale from 0..1 → 0..100% width. Floor at 4% so the bar
|
||
// stays visible when silent.
|
||
const levelPct = Math.max(4, Math.min(100, Math.round(level * 180)));
|
||
const remaining = Math.max(0, MAX_RECORD_SEC - elapsedSec);
|
||
|
||
return (
|
||
<div className="inline-flex h-10 items-center gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 text-xs font-semibold text-rose-700 dark:text-rose-200">
|
||
<span className="h-2 w-2 animate-pulse rounded-full bg-rose-500" />
|
||
<span className="tabular-nums">{formatTime(elapsedSec)}</span>
|
||
<div
|
||
aria-hidden="true"
|
||
className="relative h-1.5 w-20 overflow-hidden rounded-full bg-rose-500/20"
|
||
>
|
||
<span
|
||
className="absolute inset-y-0 left-0 rounded-full bg-rose-500 transition-[width] duration-75"
|
||
style={{ width: levelPct + '%' }}
|
||
/>
|
||
</div>
|
||
<span className="tabular-nums text-[10px] text-rose-700/70 dark:text-rose-200/70">
|
||
−{remaining}s
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={cancel}
|
||
aria-label="Aufnahme abbrechen"
|
||
title="Abbrechen"
|
||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||
>
|
||
<XIcon className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={confirm}
|
||
aria-label="Aufnahme senden"
|
||
title="Senden"
|
||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md bg-accent text-accent-fg transition hover:brightness-110"
|
||
>
|
||
<MicIcon className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function pickMime(): string | null {
|
||
const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/mp4'];
|
||
for (const c of candidates) {
|
||
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(c)) return c;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function extFor(mime: string): string {
|
||
if (mime.includes('webm')) return 'webm';
|
||
if (mime.includes('ogg')) return 'ogg';
|
||
if (mime.includes('mp4')) return 'm4a';
|
||
return 'bin';
|
||
}
|
||
|
||
function formatTime(sec: number): string {
|
||
const m = Math.floor(sec / 60);
|
||
const s = sec % 60;
|
||
return m + ':' + s.toString().padStart(2, '0');
|
||
}
|