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:
@@ -75,7 +75,13 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
} = props;
|
||||
|
||||
const small = size === 'small';
|
||||
const borderClass = speaking
|
||||
// Split the speaking indicator per-mode so we don't stack a tile border
|
||||
// + inset glow on top of the avatar pulse (visual double-chrome). Video
|
||||
// tiles get the border (the avatar is hidden behind the stream so the
|
||||
// pulse wouldn't be visible anyway); audio tiles rely on the avatar
|
||||
// pulse rendered inside AudioContent.
|
||||
const videoSpeaking = speaking && video;
|
||||
const borderClass = videoSpeaking
|
||||
? 'border-emerald-500 shadow-[0_0_0_2px_rgba(22,163,74,0.25)] dark:border-emerald-400'
|
||||
: focused
|
||||
? 'border-accent'
|
||||
@@ -98,9 +104,10 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
<AudioContent {...props} small={small} />
|
||||
)}
|
||||
|
||||
{/* Speaking indicator visible regardless of content type (video or
|
||||
audio). z-10 ensures it sits above the video element. */}
|
||||
{speaking && (
|
||||
{/* Video-only speaking indicator. Audio tiles use the avatar pulse
|
||||
from AudioContent so we don't double-render chrome. z-10 keeps
|
||||
it above the video element. */}
|
||||
{videoSpeaking && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-10 rounded-[14px] border-[3px] border-emerald-400 shadow-[inset_0_0_18px_rgba(34,197,94,0.55)]"
|
||||
|
||||
@@ -158,6 +158,7 @@ function PipCall() {
|
||||
const active =
|
||||
state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'reconnecting' ||
|
||||
state.kind === 'outgoing';
|
||||
if (!active) return null;
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ export function InCallPanel({ conversation }: Props) {
|
||||
const active =
|
||||
(state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'reconnecting' ||
|
||||
state.kind === 'outgoing') &&
|
||||
state.conversationId === conversation.id;
|
||||
if (!active) return null;
|
||||
@@ -117,6 +118,9 @@ export function InCallPanel({ conversation }: Props) {
|
||||
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
|
||||
});
|
||||
|
||||
// Duration keeps ticking during reconnecting so the user sees the call is
|
||||
// still alive — but the status label below takes precedence in the header
|
||||
// so the "Verbinde neu…" message is prominent, not buried under the timer.
|
||||
const duration =
|
||||
state.kind === 'connected'
|
||||
? <LiveDuration startedAt={state.startedAt} />
|
||||
@@ -127,9 +131,11 @@ export function InCallPanel({ conversation }: Props) {
|
||||
? t('app:call.outgoing_ringing')
|
||||
: state.kind === 'connecting'
|
||||
? t('app:call.connecting')
|
||||
: remoteParticipants.length === 0
|
||||
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
||||
: t('app:call.connected');
|
||||
: state.kind === 'reconnecting'
|
||||
? t('app:call.reconnecting', { defaultValue: 'Verbinde neu…' })
|
||||
: remoteParticipants.length === 0
|
||||
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
||||
: t('app:call.connected');
|
||||
|
||||
// A screen-share tile becomes the auto-focus target when no one explicitly
|
||||
// picked a tile yet. Focus ids now use Tile.id (kind-prefixed) so we can
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user