diff --git a/bun.lock b/bun.lock index dd2c2a9..70fc25b 100644 --- a/bun.lock +++ b/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=="], diff --git a/packages/call/package.json b/packages/call/package.json index b240ca2..4e8249b 100644 --- a/packages/call/package.json +++ b/packages/call/package.json @@ -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" } } diff --git a/packages/call/src/components/buttons/deaf.tsx b/packages/call/src/components/buttons/deaf.tsx new file mode 100644 index 0000000..7fa4032 --- /dev/null +++ b/packages/call/src/components/buttons/deaf.tsx @@ -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 ( + + {deaf ? : } + + ); +} diff --git a/packages/call/src/components/buttons/mute.tsx b/packages/call/src/components/buttons/mute.tsx new file mode 100644 index 0000000..da4b2c4 --- /dev/null +++ b/packages/call/src/components/buttons/mute.tsx @@ -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 ( + + {micEnabled ? : } + + ); +} diff --git a/packages/call/src/components/sidebarBox.tsx b/packages/call/src/components/sidebarBox.tsx index 0b0d4b8..41f90a6 100644 --- a/packages/call/src/components/sidebarBox.tsx +++ b/packages/call/src/components/sidebarBox.tsx @@ -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 : ( - - - + + + - - openCallPage(callId || "")}> - - - - disconnect()} - variant="destructive" - > - - + + + + + + disconnect()} + variant="destructive" + > + + + ); } -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 ( - - + openCallPage(callId || "")} size="lg" variant={ state === "open" && encrypted ? "subtleDefault" : "destructive" @@ -73,24 +69,133 @@ function ConnectionBar({ {state === "closed" && "Closed"} {state === "closing" && "Closing"} - - - ) : ( - - ) - } - /> - - {encrypted ? "Encrypted" : "Unencrypted"} - - + + + {encrypted ? ( + + ) : ( + + )} } /> - Test stuff - + Click to open call page + ); } + +export function TinyPingGraph() { + const { room } = useCall(); + + const [mapData, setMapData] = useState>( + () => 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 ( + + + + + + + + + + + + + + + + + } + /> + {data.at(-1)?.ping} ms + + ); +} + +async function getPing(room: Room): Promise { + 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); +} diff --git a/packages/call/src/context.tsx b/packages/call/src/context.tsx index bc07ead..031ffca 100644 --- a/packages/call/src/context.tsx +++ b/packages/call/src/context.tsx @@ -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; callId: string | null; + deaf: boolean; + toggleDeaf: () => void; }; export function useCall(): contextType {