feat(call): Discord-style polish pass (groups A-C)

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>
This commit is contained in:
byGalax
2026-04-22 19:53:58 +02:00
parent 6301ebb392
commit 1c67a5c97f
7 changed files with 237 additions and 22 deletions
@@ -1,6 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
getAudioSettings,
subscribeAudioSettings,
updateAudioSettings,
} from '../lib/audioSettings';
import {
clearIncomingRingtone,
getIncomingRingtone,
@@ -30,6 +35,10 @@ export function RingtoneSettings({ disabled = false }: Props) {
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 {
@@ -128,7 +137,7 @@ export function RingtoneSettings({ disabled = false }: Props) {
const url = URL.createObjectURL(current.blob);
const el = new Audio(url);
el.loop = false;
el.volume = 0.85;
el.volume = volume;
el.onended = () => stopPreview();
el.onerror = () => {
setError(
@@ -233,6 +242,36 @@ export function RingtoneSettings({ disabled = false }: Props) {
</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: