(feat): add deaf & mute button
(feat): add ping graph (via recharts) (feat): remove old expand button
This commit is contained in:
parent
246163486c
commit
d01df7c02c
6 changed files with 212 additions and 53 deletions
5
bun.lock
5
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
packages/call/src/components/buttons/deaf.tsx
Normal file
17
packages/call/src/components/buttons/deaf.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
packages/call/src/components/buttons/mute.tsx
Normal file
17
packages/call/src/components/buttons/mute.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue