(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
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