1c67a5c97f
A — Call core: - Deafen now implies mute + remembers pre-deafen mic state so un-deafen restores it (Discord-parity). Peers still see the headphones-off + mic-off badges in sync via the existing data-channel broadcast. - Self-join sound fires on the local peer's r.connect() too, not just on remote ParticipantConnected, so the user gets the "I'm in" cue. - New CallState.reconnecting holds the UI steady when LiveKit drops the signaling socket and retries; duration keeps ticking, status label switches to "Verbinde neu…". Full teardown only on terminal Disconnected (after LK gives up). - joinActiveCall falls back to connected after 5s if no peer arrived — avoids hanging in "Verbinde…" when peers left the room mid-rejoin. B — Ringtone: - Oscillator base gain up (incoming 0.22 -> 0.4, outgoing 0.14 -> 0.22) so the default pattern survives laptop speakers + background music. - New ringtoneVolume slider in Settings, default 0.9, live-applies to both the oscillator fallback and the custom-file <audio> element. C — Participant tile: - Split the speaking indicator: video tiles get the emerald border + inset glow; audio tiles rely on the existing avatar pulse. No more double-chrome when someone talks in grid/focus view. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
284 lines
9.4 KiB
TypeScript
284 lines
9.4 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import {
|
|
getAudioSettings,
|
|
subscribeAudioSettings,
|
|
updateAudioSettings,
|
|
} from '../lib/audioSettings';
|
|
import {
|
|
clearIncomingRingtone,
|
|
getIncomingRingtone,
|
|
MAX_RINGTONE_BYTES,
|
|
saveIncomingRingtone,
|
|
type StoredRingtone,
|
|
} from '../lib/ringtoneStorage';
|
|
import { PhoneIcon, SpinnerIcon, TrashIcon } from './icons';
|
|
|
|
interface Props {
|
|
/** Disable interactions while a parent action is in flight. */
|
|
disabled?: boolean;
|
|
}
|
|
|
|
const BYTES_PER_MB = 1024 * 1024;
|
|
|
|
// UI for the custom incoming-call ringtone. Single file slot. Upload
|
|
// validates size + mime and surfaces errors inline. Preview button plays
|
|
// the stored blob through a local <audio> element without touching the
|
|
// shared ringtone singleton so we don't interfere with a live call.
|
|
export function RingtoneSettings({ disabled = false }: Props) {
|
|
const { t } = useTranslation(['app']);
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
const previewRef = useRef<HTMLAudioElement | null>(null);
|
|
const previewUrlRef = useRef<string | null>(null);
|
|
const [current, setCurrent] = useState<StoredRingtone | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [playing, setPlaying] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [volume, setVolume] = useState<number>(() => getAudioSettings().ringtoneVolume);
|
|
|
|
// Subscribe so cross-tab / in-call slider moves stay in sync here too.
|
|
useEffect(() => subscribeAudioSettings((s) => setVolume(s.ringtoneVolume)), []);
|
|
|
|
const refresh = useCallback(async () => {
|
|
try {
|
|
const cur = await getIncomingRingtone();
|
|
setCurrent(cur);
|
|
} catch (err: unknown) {
|
|
console.error('getIncomingRingtone failed', err);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
}, [refresh]);
|
|
|
|
useEffect(() => {
|
|
// Revoke any preview blob URL when the component unmounts so long-lived
|
|
// pages don't leak memory.
|
|
return () => {
|
|
stopPreview();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
function stopPreview(): void {
|
|
const el = previewRef.current;
|
|
if (el) {
|
|
try {
|
|
el.pause();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
el.src = '';
|
|
}
|
|
previewRef.current = null;
|
|
if (previewUrlRef.current) {
|
|
URL.revokeObjectURL(previewUrlRef.current);
|
|
previewUrlRef.current = null;
|
|
}
|
|
setPlaying(false);
|
|
}
|
|
|
|
async function handleFile(file: File): Promise<void> {
|
|
setError(null);
|
|
setBusy(true);
|
|
try {
|
|
await saveIncomingRingtone(file);
|
|
await refresh();
|
|
} catch (err: unknown) {
|
|
const code = err instanceof Error ? err.message : 'upload_failed';
|
|
if (code === 'ringtone_too_large') {
|
|
setError(
|
|
t('app:settings.ringtone_error_too_large', {
|
|
defaultValue: 'Datei zu groß (max {{max}} MB).',
|
|
max: MAX_RINGTONE_BYTES / BYTES_PER_MB,
|
|
}),
|
|
);
|
|
} else if (code === 'ringtone_not_audio') {
|
|
setError(
|
|
t('app:settings.ringtone_error_not_audio', {
|
|
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
|
|
}),
|
|
);
|
|
} else {
|
|
setError(
|
|
t('app:settings.ringtone_error_generic', {
|
|
defaultValue: 'Ringtone konnte nicht gespeichert werden.',
|
|
}),
|
|
);
|
|
}
|
|
} finally {
|
|
setBusy(false);
|
|
if (inputRef.current) inputRef.current.value = '';
|
|
}
|
|
}
|
|
|
|
async function handleReset(): Promise<void> {
|
|
setError(null);
|
|
setBusy(true);
|
|
stopPreview();
|
|
try {
|
|
await clearIncomingRingtone();
|
|
await refresh();
|
|
} catch (err: unknown) {
|
|
console.error('clearIncomingRingtone failed', err);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function handlePreview(): void {
|
|
if (!current) return;
|
|
if (playing) {
|
|
stopPreview();
|
|
return;
|
|
}
|
|
const url = URL.createObjectURL(current.blob);
|
|
const el = new Audio(url);
|
|
el.loop = false;
|
|
el.volume = volume;
|
|
el.onended = () => stopPreview();
|
|
el.onerror = () => {
|
|
setError(
|
|
t('app:settings.ringtone_error_play', {
|
|
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
|
|
}),
|
|
);
|
|
stopPreview();
|
|
};
|
|
el.play().catch(() => {
|
|
setError(
|
|
t('app:settings.ringtone_error_play', {
|
|
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
|
|
}),
|
|
);
|
|
stopPreview();
|
|
});
|
|
previewRef.current = el;
|
|
previewUrlRef.current = url;
|
|
setPlaying(true);
|
|
}
|
|
|
|
const hasCustom = current !== null;
|
|
const sizeMb = current ? (current.blob.size / BYTES_PER_MB).toFixed(2) : null;
|
|
const interactionsDisabled = disabled || busy;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 text-sm font-medium text-fg">
|
|
<PhoneIcon className="h-4 w-4 text-fg-muted" />
|
|
{t('app:settings.ringtone_incoming', { defaultValue: 'Eingehender Anruf' })}
|
|
</div>
|
|
<div className="mt-1 text-xs text-fg-muted">
|
|
{hasCustom && current
|
|
? t('app:settings.ringtone_custom_active', {
|
|
defaultValue: '{{name}} · {{size}} MB',
|
|
name: current.filename,
|
|
size: sizeMb,
|
|
})
|
|
: t('app:settings.ringtone_default_active', {
|
|
defaultValue: 'Standard-Klingelton (Doppelton)',
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{hasCustom && (
|
|
<button
|
|
type="button"
|
|
onClick={handlePreview}
|
|
disabled={interactionsDisabled}
|
|
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs font-semibold text-fg transition hover:brightness-95 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{playing
|
|
? t('app:settings.ringtone_stop', { defaultValue: 'Stop' })
|
|
: t('app:settings.ringtone_preview', { defaultValue: 'Vorhören' })}
|
|
</button>
|
|
)}
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="audio/*"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const f = e.target.files?.[0];
|
|
if (f) void handleFile(f);
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => inputRef.current?.click()}
|
|
disabled={interactionsDisabled}
|
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
|
<span>
|
|
{hasCustom
|
|
? t('app:settings.ringtone_replace', { defaultValue: 'Ersetzen' })
|
|
: t('app:settings.ringtone_upload', { defaultValue: 'Hochladen' })}
|
|
</span>
|
|
</button>
|
|
{hasCustom && (
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleReset()}
|
|
disabled={interactionsDisabled}
|
|
aria-label={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
|
|
title={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
|
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-rose-500/40 bg-rose-500/10 text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
|
>
|
|
<TrashIcon className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3">
|
|
<label
|
|
htmlFor="ringtone-volume"
|
|
className="shrink-0 text-xs font-medium text-fg-muted"
|
|
>
|
|
{t('app:settings.ringtone_volume', { defaultValue: 'Lautstärke' })}
|
|
</label>
|
|
<input
|
|
id="ringtone-volume"
|
|
type="range"
|
|
min={0}
|
|
max={1}
|
|
step={0.01}
|
|
value={volume}
|
|
onChange={(e) => {
|
|
const v = Number(e.target.value);
|
|
setVolume(v);
|
|
updateAudioSettings({ ringtoneVolume: v });
|
|
// Apply to the currently-playing preview so the user hears the
|
|
// slider effect immediately while dragging.
|
|
if (previewRef.current) previewRef.current.volume = v;
|
|
}}
|
|
aria-label={t('app:settings.ringtone_volume', { defaultValue: 'Lautstärke' })}
|
|
className="flex-1 accent-accent"
|
|
/>
|
|
<span className="w-10 shrink-0 text-right text-xs tabular-nums text-fg-muted">
|
|
{Math.round(volume * 100)}%
|
|
</span>
|
|
</div>
|
|
|
|
<p className="text-[11px] text-fg-muted">
|
|
{t('app:settings.ringtone_hint', {
|
|
defaultValue:
|
|
'MP3, WAV, OGG oder M4A bis 8 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.',
|
|
})}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|