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>
270 lines
8.1 KiB
TypeScript
270 lines
8.1 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
|
|
import { CrownIcon, HeadphonesOffIcon, LockIcon, MicOffIcon } from './icons';
|
|
|
|
export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal';
|
|
|
|
const AVATAR_COLORS: Record<AvatarColorKey, { bg: string; fg: string }> = {
|
|
// Tailwind classes tuned per spec: violet/amber/rose/teal swatches in both
|
|
// light and dark modes. Dark pairs invert to keep contrast readable.
|
|
violet: {
|
|
bg: 'bg-violet-200 dark:bg-violet-900/60',
|
|
fg: 'text-violet-800 dark:text-violet-200',
|
|
},
|
|
amber: {
|
|
bg: 'bg-amber-200 dark:bg-amber-900/60',
|
|
fg: 'text-amber-900 dark:text-amber-200',
|
|
},
|
|
rose: {
|
|
bg: 'bg-rose-200 dark:bg-rose-900/60',
|
|
fg: 'text-rose-900 dark:text-rose-200',
|
|
},
|
|
teal: {
|
|
bg: 'bg-teal-200 dark:bg-teal-900/60',
|
|
fg: 'text-teal-900 dark:text-teal-200',
|
|
},
|
|
};
|
|
|
|
const COLOR_KEYS: AvatarColorKey[] = ['violet', 'amber', 'rose', 'teal'];
|
|
|
|
// Stable per-user color from identity string — keeps avatars visually
|
|
// consistent across re-mounts without needing a color field on the profile.
|
|
export function colorKeyFor(id: string): AvatarColorKey {
|
|
let hash = 0;
|
|
for (let i = 0; i < id.length; i++) {
|
|
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
|
}
|
|
return COLOR_KEYS[hash % COLOR_KEYS.length]!;
|
|
}
|
|
|
|
export interface ParticipantTileProps {
|
|
userId: string;
|
|
displayName: string;
|
|
avatarUrl: string | null;
|
|
me: boolean;
|
|
muted: boolean;
|
|
/** Local-only: true when THIS user has muted everyone else via the deafen
|
|
* toggle. Remote deafen state is not propagated, so only the own tile
|
|
* ever carries a truthy value. */
|
|
deafened: boolean;
|
|
speaking: boolean;
|
|
video: boolean;
|
|
e2ee: boolean;
|
|
/** MediaStreamTrack for the participant's active camera, when `video` is
|
|
* true. When null the tile falls back to the avatar placeholder. */
|
|
videoTrack?: MediaStreamTrack | null;
|
|
size?: 'default' | 'small';
|
|
focused?: boolean;
|
|
onClick?: () => void;
|
|
onContextMenu?: (e: React.MouseEvent) => void;
|
|
}
|
|
|
|
export function CallParticipantTile(props: ParticipantTileProps) {
|
|
const {
|
|
displayName,
|
|
me,
|
|
muted,
|
|
deafened,
|
|
speaking,
|
|
video,
|
|
e2ee,
|
|
size = 'default',
|
|
focused = false,
|
|
onClick,
|
|
onContextMenu,
|
|
} = props;
|
|
|
|
const small = size === 'small';
|
|
// 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'
|
|
: 'border-line hover:border-accent';
|
|
|
|
return (
|
|
<div
|
|
onClick={onClick}
|
|
onContextMenu={onContextMenu}
|
|
className={
|
|
'relative flex flex-col overflow-hidden rounded-[14px] border bg-surface-3 transition ' +
|
|
(onClick ? 'cursor-pointer ' : '') +
|
|
borderClass +
|
|
(small ? ' min-w-[140px]' : '')
|
|
}
|
|
>
|
|
{video ? (
|
|
<VideoStub {...props} small={small} />
|
|
) : (
|
|
<AudioContent {...props} small={small} />
|
|
)}
|
|
|
|
{/* 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)]"
|
|
/>
|
|
)}
|
|
|
|
<div className="pointer-events-none absolute inset-x-2 bottom-2 flex items-center justify-between gap-2 rounded-lg glass-chip px-2.5 py-1.5">
|
|
<div className="flex min-w-0 items-center gap-1.5 text-xs font-semibold">
|
|
{me && <CrownIcon className="h-3 w-3 shrink-0 text-amber-300" />}
|
|
<span className="truncate">
|
|
{displayName}
|
|
{me ? ' (du)' : ''}
|
|
</span>
|
|
{e2ee && (
|
|
<span
|
|
aria-label="E2E verschlüsselt"
|
|
title="E2E verschlüsselt"
|
|
className="flex opacity-70"
|
|
>
|
|
<LockIcon className="h-3 w-3" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{muted && (
|
|
<span
|
|
aria-label="Mikro stumm"
|
|
title="Mikro stumm"
|
|
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
|
|
>
|
|
<MicOffIcon className="h-3 w-3" />
|
|
</span>
|
|
)}
|
|
{deafened && (
|
|
<span
|
|
aria-label="Ton aus"
|
|
title="Ton aus"
|
|
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
|
|
>
|
|
<HeadphonesOffIcon className="h-3 w-3" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AudioContent({
|
|
userId,
|
|
displayName,
|
|
avatarUrl,
|
|
speaking,
|
|
small,
|
|
}: ParticipantTileProps & { small: boolean }) {
|
|
const key = colorKeyFor(userId);
|
|
const colors = AVATAR_COLORS[key];
|
|
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
|
return (
|
|
<div className="flex min-h-0 flex-1 items-center justify-center p-4">
|
|
<div
|
|
className={
|
|
'relative flex items-center justify-center rounded-full ' +
|
|
(small ? 'h-11 w-11' : 'h-[72px] w-[72px]')
|
|
}
|
|
>
|
|
{speaking && (
|
|
<span
|
|
aria-hidden="true"
|
|
className="absolute -inset-1.5 animate-audio-pulse rounded-full border-2 border-emerald-500 dark:border-emerald-400"
|
|
/>
|
|
)}
|
|
{avatarUrl ? (
|
|
<img
|
|
src={avatarUrl}
|
|
alt=""
|
|
className="relative h-full w-full rounded-full object-cover"
|
|
/>
|
|
) : (
|
|
<span
|
|
className={
|
|
'relative flex h-full w-full items-center justify-center rounded-full font-bold ' +
|
|
colors.bg + ' ' + colors.fg + ' ' +
|
|
(small ? 'text-lg' : 'text-2xl')
|
|
}
|
|
>
|
|
{letter}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function VideoStub({
|
|
userId,
|
|
displayName,
|
|
avatarUrl,
|
|
videoTrack,
|
|
me,
|
|
small,
|
|
}: ParticipantTileProps & { small: boolean }) {
|
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
useEffect(() => {
|
|
const el = videoRef.current;
|
|
if (!el || !videoTrack) return;
|
|
el.srcObject = new MediaStream([videoTrack]);
|
|
return () => {
|
|
if (el.srcObject) {
|
|
(el.srcObject as MediaStream).getTracks().forEach((t) => {
|
|
// Don't stop the live track — other consumers may still need it.
|
|
// Just detach from this element.
|
|
void t;
|
|
});
|
|
el.srcObject = null;
|
|
}
|
|
};
|
|
}, [videoTrack]);
|
|
|
|
if (videoTrack) {
|
|
return (
|
|
<div className="relative flex min-h-0 flex-1 items-center justify-center bg-black">
|
|
<video
|
|
ref={videoRef}
|
|
autoPlay
|
|
playsInline
|
|
muted
|
|
className={
|
|
'h-full w-full object-cover ' +
|
|
(me ? 'scale-x-[-1]' : '') /* mirror local preview */
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Camera flag true but no track yet (publishing / subscribing race).
|
|
const key = colorKeyFor(userId);
|
|
const colors = AVATAR_COLORS[key];
|
|
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
|
return (
|
|
<div className="flex min-h-0 flex-1 items-center justify-center bg-gradient-to-br from-accent/20 to-surface-2 p-4">
|
|
<div
|
|
className={
|
|
'flex items-center justify-center rounded-full font-bold ' +
|
|
colors.bg + ' ' + colors.fg + ' ' +
|
|
(small ? 'h-11 w-11 text-lg' : 'h-[72px] w-[72px] text-2xl')
|
|
}
|
|
>
|
|
{avatarUrl ? (
|
|
<img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" />
|
|
) : (
|
|
letter
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|