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:
@@ -28,6 +28,12 @@ export interface AudioSettings {
|
||||
// @livekit/track-processors + its MediaPipe selfie-segmentation model
|
||||
// (~1.5MB) which downloads on first activation.
|
||||
videoBackgroundBlur: boolean;
|
||||
// Ringtone volume for both the generated oscillator fallback and the
|
||||
// custom incoming-call audio file. 0..1; applied on top of the base
|
||||
// oscillator gain so the fallback stays audible at 100% without being
|
||||
// harsh at 25%. Separate from any system / call audio volume so users
|
||||
// can have loud rings + soft in-call audio.
|
||||
ringtoneVolume: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: AudioSettings = {
|
||||
@@ -37,6 +43,7 @@ const DEFAULTS: AudioSettings = {
|
||||
voiceThreshold: 0.03,
|
||||
noiseSuppression: true,
|
||||
videoBackgroundBlur: false,
|
||||
ringtoneVolume: 0.9,
|
||||
};
|
||||
|
||||
export interface AudioQualityParams {
|
||||
@@ -118,6 +125,13 @@ function read(): AudioSettings {
|
||||
typeof parsed.videoBackgroundBlur === 'boolean'
|
||||
? parsed.videoBackgroundBlur
|
||||
: DEFAULTS.videoBackgroundBlur,
|
||||
ringtoneVolume:
|
||||
typeof parsed.ringtoneVolume === 'number' &&
|
||||
Number.isFinite(parsed.ringtoneVolume) &&
|
||||
parsed.ringtoneVolume >= 0 &&
|
||||
parsed.ringtoneVolume <= 1
|
||||
? parsed.ringtoneVolume
|
||||
: DEFAULTS.ringtoneVolume,
|
||||
};
|
||||
return cached;
|
||||
} catch {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// back to the generated oscillator pattern so ringing never misses an
|
||||
// incoming call due to an IO failure.
|
||||
|
||||
import { getAudioSettings, subscribeAudioSettings } from './audioSettings';
|
||||
import { getIncomingRingtone } from './ringtoneStorage';
|
||||
|
||||
type Pattern = 'outgoing' | 'incoming';
|
||||
@@ -21,6 +22,15 @@ class Ringtone {
|
||||
private customUrl: string | null = null;
|
||||
// Sequence token to ignore slow IO completing after user changed state.
|
||||
private startSeq = 0;
|
||||
// Live-subscribe so settings-slider changes reflect while the ringtone
|
||||
// is playing (user can hear the effect of their slider immediately).
|
||||
private unsubVolume: (() => void) | null = null;
|
||||
|
||||
private get volume(): number {
|
||||
const v = getAudioSettings().ringtoneVolume;
|
||||
if (!Number.isFinite(v)) return 0.9;
|
||||
return Math.min(1, Math.max(0, v));
|
||||
}
|
||||
|
||||
start(pattern: Pattern): void {
|
||||
if (this.pattern === pattern) return; // already playing this pattern
|
||||
@@ -28,6 +38,12 @@ class Ringtone {
|
||||
this.pattern = pattern;
|
||||
const seq = ++this.startSeq;
|
||||
|
||||
// Track live slider moves so the user can dial in the volume while a
|
||||
// call is ringing and hear the change immediately.
|
||||
this.unsubVolume = subscribeAudioSettings(() => {
|
||||
if (this.audioEl) this.audioEl.volume = this.volume;
|
||||
});
|
||||
|
||||
if (pattern === 'incoming') {
|
||||
// Kick off oscillator immediately so we never miss ringing feedback
|
||||
// while the custom file (if any) loads asynchronously. Once the blob
|
||||
@@ -44,6 +60,10 @@ class Ringtone {
|
||||
this.stopOscillator();
|
||||
this.stopCustom();
|
||||
this.pattern = null;
|
||||
if (this.unsubVolume) {
|
||||
this.unsubVolume();
|
||||
this.unsubVolume = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Custom file path (incoming only) ----------------------------------
|
||||
@@ -61,7 +81,7 @@ class Ringtone {
|
||||
const url = URL.createObjectURL(stored.blob);
|
||||
const el = new Audio(url);
|
||||
el.loop = true;
|
||||
el.volume = 0.85;
|
||||
el.volume = this.volume;
|
||||
// Chrome/WKWebView autoplay policy: muted playback is always allowed,
|
||||
// but ringtones must be audible, so play() may reject the first time
|
||||
// before the user interacted. If it rejects, we keep the oscillator.
|
||||
@@ -132,25 +152,31 @@ class Ringtone {
|
||||
osc.connect(g);
|
||||
g.connect(ctx.destination);
|
||||
const t0 = ctx.currentTime + delaySec;
|
||||
// Volume slider multiplies the base gain so the fallback tone tracks
|
||||
// the user's preference. A flat user-setting of 0 keeps the pattern
|
||||
// running visually (oscillator nodes alive) but inaudible.
|
||||
const effectiveGain = gain * this.volume;
|
||||
g.gain.setValueAtTime(0, t0);
|
||||
g.gain.linearRampToValueAtTime(gain, t0 + 0.02);
|
||||
g.gain.linearRampToValueAtTime(effectiveGain, t0 + 0.02);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, t0 + durationSec);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + durationSec + 0.02);
|
||||
}
|
||||
|
||||
private playOutgoing(): void {
|
||||
// Soft calling tone — single warm note.
|
||||
this.beep(440, 0.4, 0, 0.14);
|
||||
this.beep(440, 0.4, 0.6, 0.14);
|
||||
// Soft calling tone — single warm note. Slightly bumped from 0.14 so
|
||||
// it's audible on laptop speakers without blasting.
|
||||
this.beep(440, 0.4, 0, 0.22);
|
||||
this.beep(440, 0.4, 0.6, 0.22);
|
||||
}
|
||||
|
||||
private playIncoming(): void {
|
||||
// Classic double-ring "ring ring".
|
||||
this.beep(880, 0.18, 0, 0.22);
|
||||
this.beep(660, 0.18, 0.22, 0.22);
|
||||
this.beep(880, 0.18, 0.6, 0.22);
|
||||
this.beep(660, 0.18, 0.82, 0.22);
|
||||
// Classic double-ring "ring ring". Bumped from 0.22 → 0.4 so it's
|
||||
// unmissable through music / background noise.
|
||||
this.beep(880, 0.18, 0, 0.4);
|
||||
this.beep(660, 0.18, 0.22, 0.4);
|
||||
this.beep(880, 0.18, 0.6, 0.4);
|
||||
this.beep(660, 0.18, 0.82, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user