(feat): move call context to zustand

(feat): add actions, basic call page
This commit is contained in:
Alois 2026-04-29 14:49:53 +02:00
commit 413764f33e
17 changed files with 673 additions and 382 deletions

View file

@ -1,3 +1,20 @@
import { Card, CardContent } from "@tensamin/ui";
import MuteButton from "./buttons/mute";
import DeafButton from "./buttons/deaf";
import ScreenshareButton from "./buttons/screenshare";
import LeaveButton from "./buttons/leave";
export default function Actions() {
return <div>Actions</div>;
return (
<div className="w-full flex justify-center py-3">
<Card className="p-1.75">
<CardContent className="p-0! flex gap-1.75">
<MuteButton className="w-14 h-10" iconSize={25} />
<DeafButton className="w-14 h-10" iconSize={25} />
<ScreenshareButton className="w-14 h-10" iconSize={25} />
<LeaveButton className="w-14 h-10" iconSize={25} />
</CardContent>
</Card>
</div>
);
}

View file

@ -1,9 +1,15 @@
import { Button } from "@tensamin/ui";
import { useCall } from "../../context";
import { toggleDeaf, useCall } from "../../store";
import { HeadphoneOff, Headphones } from "lucide-react";
export default function DeafButton({ className }: { className?: string }) {
const { toggleDeaf, deaf } = useCall();
export default function DeafButton({
className,
iconSize,
}: {
className?: string;
iconSize?: number;
}) {
const deaf = useCall((state) => state.deaf);
return (
<Button
@ -11,7 +17,15 @@ export default function DeafButton({ className }: { className?: string }) {
onClick={toggleDeaf}
className={className}
>
{deaf ? <HeadphoneOff /> : <Headphones />}
{deaf ? (
<HeadphoneOff
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
/>
) : (
<Headphones
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
/>
)}
</Button>
);
}

View file

@ -0,0 +1,21 @@
import { LeaveIcon } from "@livekit/components-react";
import { Button } from "@tensamin/ui";
import { disconnect } from "../../store";
export default function LeaveButton({
className,
iconSize,
}: {
className?: string;
iconSize?: number;
}) {
return (
<Button
className={className}
onClick={() => disconnect()}
variant="destructive"
>
<LeaveIcon style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }} />
</Button>
);
}

View file

@ -1,17 +1,27 @@
import { Button } from "@tensamin/ui";
import { useCall } from "../../context";
import { toggleMute, useCall } from "../../store";
import { Mic, MicOff } from "lucide-react";
export default function MuteButton({ className }: { className?: string }) {
const { room } = useCall();
const micEnabled = room.localParticipant.isMicrophoneEnabled;
export default function MuteButton({
className,
iconSize,
}: {
className?: string;
iconSize?: number;
}) {
const micEnabled = useCall((state) => state.micEnabled);
return (
<Button
variant={micEnabled ? "default" : "destructive"}
className={className}
onClick={() => void toggleMute()}
>
{micEnabled ? <Mic /> : <MicOff />}
{micEnabled ? (
<Mic style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }} />
) : (
<MicOff style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }} />
)}
</Button>
);
}

View file

@ -1,39 +1,47 @@
import { Button, Popover, PopoverContent, PopoverTrigger } from "@tensamin/ui";
import { MonitorDot, ScreenShare } from "lucide-react";
import { useCall } from "../../context";
import { setScreenShareEnabled, useCall } from "../../store";
export default function ScreenshareButton({
className,
iconSize,
}: {
className?: string;
iconSize?: number;
}) {
const { room } = useCall();
const isScreensharing = room.localParticipant.isScreenShareEnabled;
const isScreensharing = useCall((state) => state.screenShareEnabled);
return (
<Popover>
<PopoverTrigger
render={
<Button
variant={isScreensharing ? "secondary" : "default"}
variant={isScreensharing ? "subtleDefault" : "default"}
className={className}
>
{isScreensharing ? <MonitorDot /> : <ScreenShare />}
{isScreensharing ? (
<MonitorDot
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
/>
) : (
<ScreenShare
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
/>
)}
</Button>
}
/>
<PopoverContent className="w-auto flex flex-col gap-2">
<Button
disabled={isScreensharing}
onClick={() => room.localParticipant.setScreenShareEnabled(true)}
onClick={() => void setScreenShareEnabled(true)}
>
Start
</Button>
<Button
variant="destructive"
disabled={!isScreensharing}
onClick={() => room.localParticipant.setScreenShareEnabled(false)}
onClick={() => void setScreenShareEnabled(false)}
>
Stop
</Button>

View file

@ -1,5 +1,5 @@
import { Lock, LockOpen } from "lucide-react";
import { useCall } from "../context";
import { openCallPage, useCall } from "../store";
import {
Button,
Card,
@ -10,16 +10,16 @@ import {
TooltipTrigger,
useIsMobile,
} 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";
import LeaveButton from "./buttons/leave";
export default function SidebarBox() {
const { state, disconnect } = useCall();
const state = useCall((store) => store.state);
const isMobile = useIsMobile();
return state === "closed" ? null : (
@ -32,14 +32,7 @@ export default function SidebarBox() {
<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>
<LeaveButton className="w-9 h-9" />
</div>
</CardContent>
</Card>
@ -47,9 +40,9 @@ export default function SidebarBox() {
}
function ConnectionBar() {
const { state, room, openCallPage, callId } = useCall();
const encrypted =
room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted;
const state = useCall((store) => store.state);
const isEncrypted = useCall((store) => store.isEncrypted);
const callId = useCall((store) => store.callId);
return (
<Tooltip>
@ -59,7 +52,7 @@ function ConnectionBar() {
onClick={() => openCallPage(callId || "")}
size="lg"
variant={
state === "open" && encrypted ? "subtleDefault" : "destructive"
state === "open" && isEncrypted ? "subtleDefault" : "destructive"
}
className="flex justify-between items-center"
>
@ -71,7 +64,7 @@ function ConnectionBar() {
<TinyPingGraph />
{encrypted ? (
{isEncrypted ? (
<Lock color="var(--primary-foreground-alt)" />
) : (
<LockOpen color="var(--destructive)" />
@ -85,11 +78,9 @@ function ConnectionBar() {
}
export function TinyPingGraph() {
const { room } = useCall();
const room = useCall((store) => store.room);
const [mapData, setMapData] = useState<Map<number, number>>(
() => new Map([[Date.now(), 0]]),
);
const [mapData, setMapData] = useState<Map<number, number>>(() => new Map());
const data = Array.from(mapData, ([time, ping]) => ({ time, ping }));
@ -102,7 +93,9 @@ export function TinyPingGraph() {
setMapData((prev) => {
const next = new Map(prev);
next.set(now, ping || 0);
if (ping != null && ping > 0) {
next.set(now, ping);
}
for (const time of next.keys()) {
if (time < cutoff) next.delete(time);
@ -128,48 +121,52 @@ export function TinyPingGraph() {
"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>
{data.length > 1 && (
<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>
<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>
<TooltipContent>
{data.length > 0 ? `${data.at(-1)?.ping} ms` : "Measuring ping..."}
</TooltipContent>
</Tooltip>
);
}
@ -197,5 +194,11 @@ async function getPing(room: Room): Promise<number | undefined> {
}
});
return Math.round(bestRtt || 0);
if (bestRtt == null || bestRtt <= 0) return;
const roundedRtt = Math.round(bestRtt);
if (roundedRtt <= 0) return;
return roundedRtt;
}

View file

@ -0,0 +1,20 @@
import { useCall } from "../store";
export default function TopBar() {
const room = useCall((state) => state.room);
const users = new Array(room.remoteParticipants.size).fill(0).map((_, i) => {
const participant = Array.from(room.remoteParticipants.values())[i];
return participant.identity;
});
return (
<div className="w-full flex justify-between h-12">
<div className="flex gap-1">
{users.map((user) => (
<div key={user}>{user}</div>
))}
</div>
</div>
);
}