(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

@ -111,6 +111,7 @@
"lucide-react": "^0.564.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "^3.8.1",
"zod": "^4.3.6",
},
},
@ -1389,7 +1390,7 @@
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
"recharts": ["recharts@3.8.0", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ=="],
"recharts": ["recharts@3.8.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
@ -1667,6 +1668,8 @@
"@tensamin/ui/lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="],
"@tensamin/ui/recharts": ["recharts@3.8.0", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ=="],
"@tensamin/ui/shadcn": ["shadcn@3.8.5", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA=="],
"@tensamin/ui/sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],

View file

@ -31,6 +31,7 @@
"lucide-react": "^0.564.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "^3.8.1",
"zod": "^4.3.6"
}
}

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,26 +12,25 @@ 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>
<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"
@ -44,23 +40,23 @@ export default function SidebarBox() {
>
<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 ? (
<TinyPingGraph />
{encrypted ? (
<Lock color="var(--primary-foreground-alt)" />
) : (
<LockOpen color="var(--destructive)" />
)
}
/>
<TooltipContent>
{encrypted ? "Encrypted" : "Unencrypted"}
</TooltipContent>
</Tooltip>
)}
</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);
}

View file

@ -116,6 +116,17 @@ export default function Provider(props: { children: React.ReactNode }) {
typeof ttp.call_data.response
> | null>(null);
const [deaf, setDeaf] = useState(false);
const toggleDeaf = () => {
setDeaf((wasDeaf) => {
room.remoteParticipants.forEach((participant) => {
participant.setVolume(wasDeaf ? 100 : 0);
});
return !wasDeaf;
});
};
const openCallPage = (callId: string) =>
navigate({ to: "/call", search: { id: callId } });
@ -166,6 +177,9 @@ export default function Provider(props: { children: React.ReactNode }) {
callId,
openCallPage,
deaf,
toggleDeaf,
disconnect,
joinCall,
currentCallData,
@ -252,6 +266,8 @@ type contextType = {
room: Room;
openCallPage: (callId: string) => Promise<void>;
callId: string | null;
deaf: boolean;
toggleDeaf: () => void;
};
export function useCall(): contextType {