(feat): add deaf & mute button

(feat): add ping graph (via recharts)
(feat): remove old expand button
This commit is contained in:
Alois 2026-04-28 22:44:56 +02:00
commit d01df7c02c
6 changed files with 212 additions and 53 deletions

View file

@ -0,0 +1,17 @@
import { Button } from "@tensamin/ui";
import { useCall } from "../../context";
import { HeadphoneOff, Headphones } from "lucide-react";
export default function DeafButton({ className }: { className?: string }) {
const { toggleDeaf, deaf } = useCall();
return (
<Button
variant={deaf ? "destructive" : "default"}
onClick={toggleDeaf}
className={className}
>
{deaf ? <HeadphoneOff /> : <Headphones />}
</Button>
);
}

View file

@ -0,0 +1,17 @@
import { Button } from "@tensamin/ui";
import { useCall } from "../../context";
import { Mic, MicOff } from "lucide-react";
export default function MuteButton({ className }: { className?: string }) {
const { room } = useCall();
const micEnabled = room.localParticipant.isMicrophoneEnabled;
return (
<Button
variant={micEnabled ? "default" : "destructive"}
className={className}
>
{micEnabled ? <Mic /> : <MicOff />}
</Button>
);
}

View file

@ -1,13 +1,10 @@
import { Expand, Lock, LockOpen } from "lucide-react";
import { Lock, LockOpen } from "lucide-react";
import { useCall } from "../context";
import {
Button,
Card,
CardContent,
CardHeader,
Popover,
PopoverContent,
PopoverTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
@ -15,52 +12,51 @@ import {
} from "@tensamin/ui";
import { LeaveIcon } from "@livekit/components-react";
import ScreenshareButton from "./buttons/screenshare";
import MuteButton from "./buttons/mute";
import DeafButton from "./buttons/deaf";
import { Room, Track } from "livekit-client";
import { useEffect, useState } from "react";
import { ResponsiveContainer, AreaChart, Area } from "recharts";
export default function SidebarBox() {
const { state, room, disconnect, openCallPage, callId } = useCall();
const { state, disconnect } = useCall();
const isMobile = useIsMobile();
return state === "closed" ? null : (
<Card className="p-1.5 gap-1" hidden={isMobile}>
<CardHeader className="p-0!">
<ConnectionBar
state={state}
encrypted={
room.localParticipant.isE2EEEnabled &&
room.localParticipant.isEncrypted
}
/>
<Card className="p-1.5 gap-2" hidden={isMobile}>
<CardHeader className="p-0! pb-2! border-b-2">
<ConnectionBar />
</CardHeader>
<CardContent className="p-0! flex justify-end gap-1">
<Button className="w-9 h-9" onClick={() => openCallPage(callId || "")}>
<Expand />
</Button>
<ScreenshareButton className="w-9 h-9" />
<Button
size="lg"
className="w-9 h-9"
onClick={() => disconnect()}
variant="destructive"
>
<LeaveIcon />
</Button>
<CardContent className="p-0! flex flex-col gap-1">
<div className="flex justify-start gap-1">
<MuteButton className="w-9 h-9" />
<DeafButton className="w-9 h-9" />
<ScreenshareButton className="w-9 h-9" />
<Button
size="lg"
className="w-9 h-9"
onClick={() => disconnect()}
variant="destructive"
>
<LeaveIcon />
</Button>
</div>
</CardContent>
</Card>
);
}
function ConnectionBar({
state,
encrypted,
}: {
state: "closed" | "closing" | "connecting" | "open" | "encrypting";
encrypted: boolean;
}) {
function ConnectionBar() {
const { state, room, openCallPage, callId } = useCall();
const encrypted =
room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted;
return (
<Popover>
<PopoverTrigger
<Tooltip>
<TooltipTrigger
render={
<Button
onClick={() => openCallPage(callId || "")}
size="lg"
variant={
state === "open" && encrypted ? "subtleDefault" : "destructive"
@ -73,24 +69,133 @@ function ConnectionBar({
{state === "closed" && "Closed"}
{state === "closing" && "Closing"}
<Tooltip>
<TooltipTrigger
render={
encrypted ? (
<Lock color="var(--primary-foreground-alt)" />
) : (
<LockOpen color="var(--destructive)" />
)
}
/>
<TooltipContent>
{encrypted ? "Encrypted" : "Unencrypted"}
</TooltipContent>
</Tooltip>
<TinyPingGraph />
{encrypted ? (
<Lock color="var(--primary-foreground-alt)" />
) : (
<LockOpen color="var(--destructive)" />
)}
</Button>
}
/>
<PopoverContent side="top">Test stuff</PopoverContent>
</Popover>
<TooltipContent>Click to open call page</TooltipContent>
</Tooltip>
);
}
export function TinyPingGraph() {
const { room } = useCall();
const [mapData, setMapData] = useState<Map<number, number>>(
() => new Map([[Date.now(), 0]]),
);
const data = Array.from(mapData, ([time, ping]) => ({ time, ping }));
useEffect(() => {
const interval = setInterval(async () => {
const now = Date.now();
const ping = await getPing(room);
const cutoff = now - 10_000;
setMapData((prev) => {
const next = new Map(prev);
next.set(now, ping || 0);
for (const time of next.keys()) {
if (time < cutoff) next.delete(time);
}
return next;
});
}, 2_000);
return () => clearInterval(interval);
}, [room]);
return (
<Tooltip>
<TooltipTrigger
render={
<div
className="w-full h-6"
style={{
WebkitMaskImage:
"linear-gradient(to right, transparent 0%, var(--primary) 15%, var(--primary) 85%, transparent 100%)",
maskImage:
"linear-gradient(to right, transparent 0%, var(--primary) 15%, var(--primary) 85%, transparent 100%)",
}}
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{ top: 2, right: 0, bottom: 2, left: 0 }}
>
<defs>
<linearGradient id="pingGradient" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor="currentColor"
stopOpacity={0.35}
/>
<stop
offset="70%"
stopColor="currentColor"
stopOpacity={0.08}
/>
<stop
offset="100%"
stopColor="currentColor"
stopOpacity={0}
/>
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="ping"
stroke="currentColor"
strokeWidth={2}
fill="url(#pingGradient)"
fillOpacity={1}
dot={false}
activeDot={false}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
}
/>
<TooltipContent>{data.at(-1)?.ping} ms</TooltipContent>
</Tooltip>
);
}
async function getPing(room: Room): Promise<number | undefined> {
const report = await room.localParticipant
.getTrackPublication(Track.Source.Microphone)
?.track?.getRTCStatsReport();
if (!report) return;
let bestRtt: number | undefined;
report.forEach((stat) => {
if (
stat.type === "candidate-pair" &&
stat.state === "succeeded" &&
stat.currentRoundTripTime != null
) {
bestRtt = stat.currentRoundTripTime * 1000;
}
if (stat.type === "remote-inbound-rtp" && stat.roundTripTime != null) {
bestRtt = stat.roundTripTime * 1000;
}
});
return Math.round(bestRtt || 0);
}