204 lines
5.7 KiB
TypeScript
204 lines
5.7 KiB
TypeScript
import { Lock, LockOpen } from "lucide-react";
|
|
import { openCallPage, useCall } from "../store";
|
|
import {
|
|
Button,
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
useIsMobile,
|
|
} from "@tensamin/ui";
|
|
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 { AreaChart, Area } from "recharts";
|
|
import LeaveButton from "./buttons/leave";
|
|
|
|
export default function SidebarBox() {
|
|
const state = useCall((store) => store.state);
|
|
const isMobile = useIsMobile();
|
|
|
|
return state === "closed" ? null : (
|
|
<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 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" defaultPortal />
|
|
<LeaveButton className="w-9 h-9" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function ConnectionBar() {
|
|
const state = useCall((store) => store.state);
|
|
const isEncrypted = useCall((store) => store.isEncrypted);
|
|
const callId = useCall((store) => store.callId);
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<Button
|
|
onClick={() => openCallPage(callId || "")}
|
|
size="lg"
|
|
variant={
|
|
state === "open" && isEncrypted ? "subtleDefault" : "destructive"
|
|
}
|
|
className="flex justify-between items-center"
|
|
>
|
|
{state === "encrypting" && "Encrypting..."}
|
|
{state === "connecting" && "Connecting..."}
|
|
{state === "open" && "Connected"}
|
|
{state === "closed" && "Closed"}
|
|
{state === "closing" && "Closing"}
|
|
|
|
<TinyPingGraph />
|
|
|
|
{isEncrypted ? (
|
|
<Lock color="var(--primary-foreground-alt)" />
|
|
) : (
|
|
<LockOpen color="var(--destructive)" />
|
|
)}
|
|
</Button>
|
|
}
|
|
/>
|
|
<TooltipContent>Click to open call page</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
export function TinyPingGraph() {
|
|
const room = useCall((store) => store.room);
|
|
|
|
const [mapData, setMapData] = useState<Map<number, number>>(() => new Map());
|
|
|
|
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);
|
|
|
|
if (ping != null && ping > 0) {
|
|
next.set(now, ping);
|
|
}
|
|
|
|
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="h-6 w-24 min-w-0 shrink-0"
|
|
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%)",
|
|
}}
|
|
>
|
|
{data.length > 1 && (
|
|
<AreaChart
|
|
width={96}
|
|
height={24}
|
|
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>
|
|
)}
|
|
</div>
|
|
}
|
|
/>
|
|
<TooltipContent>
|
|
{data.length > 0 ? `${data.at(-1)?.ping} ms` : "Measuring ping..."}
|
|
</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;
|
|
}
|
|
});
|
|
|
|
if (bestRtt == null || bestRtt <= 0) return;
|
|
|
|
const roundedRtt = Math.round(bestRtt);
|
|
|
|
if (roundedRtt <= 0) return;
|
|
|
|
return roundedRtt;
|
|
}
|