(feat): move call context to zustand
(feat): add actions, basic call page
This commit is contained in:
parent
d01df7c02c
commit
413764f33e
17 changed files with 673 additions and 382 deletions
|
|
@ -4,7 +4,7 @@
|
|||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./context": "./src/context.tsx",
|
||||
"./store": "./src/store.tsx",
|
||||
"./screen": "./src/screen.tsx",
|
||||
"./utils": "./src/utils.ts",
|
||||
"./sidebarBox": "./src/components/sidebarBox.tsx"
|
||||
|
|
@ -32,6 +32,7 @@
|
|||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"recharts": "^3.8.1",
|
||||
"zustand": "^5.0.8",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
21
packages/call/src/components/buttons/leave.tsx
Normal file
21
packages/call/src/components/buttons/leave.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
20
packages/call/src/components/top.tsx
Normal file
20
packages/call/src/components/top.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,279 +0,0 @@
|
|||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { useTTP } from "@tensamin/ttp";
|
||||
import z from "zod";
|
||||
import { ttp } from "@tensamin/shared/data";
|
||||
|
||||
import { LiveKitRoom, useLocalParticipant } from "@livekit/components-react";
|
||||
import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter";
|
||||
import { ExternalE2EEKeyProvider, LocalAudioTrack, Room } from "livekit-client";
|
||||
import { useCrypto } from "@tensamin/crypto/context";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useUser } from "@tensamin/user/context";
|
||||
|
||||
export const context = createContext<contextType | undefined>(undefined);
|
||||
|
||||
export default function Provider(props: { children: React.ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const { send } = useTTP();
|
||||
const { getSharedSecret, decryptText } = useCrypto();
|
||||
const { load } = useStorage();
|
||||
const { get } = useUser();
|
||||
const location = useLocation();
|
||||
const search = new URLSearchParams(location.searchStr);
|
||||
|
||||
const [state, setState] = useState<
|
||||
"closed" | "closing" | "connecting" | "open" | "encrypting"
|
||||
>("closed");
|
||||
|
||||
const [callId, setCallId] = useState<string | null>(
|
||||
location.pathname.startsWith("/call") ? search.get("id") : null,
|
||||
);
|
||||
const [callSecret, setCallSecret] = useState<string | null>(null);
|
||||
const [livekitToken, setLivekitToken] = useState<string | null>(null);
|
||||
|
||||
const getCallToken = async (callId: string) => {
|
||||
const response = await send("call_token", {
|
||||
call_id: callId,
|
||||
}).catch((err) => {
|
||||
log(1, "call", "red", "Failed to get call secret", err);
|
||||
throw err;
|
||||
});
|
||||
return response.data.call_token;
|
||||
};
|
||||
|
||||
async function connect(callId: string) {
|
||||
setState("encrypting");
|
||||
setCallId(callId);
|
||||
setLivekitToken(await getCallToken(callId));
|
||||
log(2, "call", "purple", "Connecting to call", { callId, callSecret });
|
||||
}
|
||||
|
||||
// Master reset function
|
||||
function disconnect() {
|
||||
setState("closing");
|
||||
setCallId(null);
|
||||
setCallSecret(null);
|
||||
setLivekitToken(null);
|
||||
room.disconnect();
|
||||
e2eeWorker.terminate();
|
||||
}
|
||||
|
||||
// Utils
|
||||
async function joinCall(
|
||||
userId: number,
|
||||
callSecret?: string,
|
||||
callId?: string,
|
||||
) {
|
||||
log(2, "call", "purple", "Call creation initialised");
|
||||
if (callSecret) {
|
||||
try {
|
||||
const sharedSecret = await getSharedSecret(
|
||||
await load("private_key"),
|
||||
await get((await load("user_id")) as number).then(
|
||||
(res) => res.public_key,
|
||||
),
|
||||
await get(userId).then((res) => res.public_key),
|
||||
);
|
||||
const decryptedSecret = await decryptText(sharedSecret, callSecret);
|
||||
await keyProvider.setKey(decryptedSecret);
|
||||
await room.setE2EEEnabled(true);
|
||||
setCallSecret(decryptedSecret);
|
||||
setState("connecting");
|
||||
} catch (err) {
|
||||
log(1, "call", "red", "Failed getting call secret", err);
|
||||
disconnect();
|
||||
}
|
||||
} else {
|
||||
const random = crypto.randomUUID();
|
||||
await keyProvider.setKey(random);
|
||||
await room.setE2EEEnabled(true);
|
||||
setCallSecret(random);
|
||||
setState("connecting");
|
||||
}
|
||||
try {
|
||||
// check if user is already in call
|
||||
|
||||
// connect to call
|
||||
const finalId = callId || crypto.randomUUID();
|
||||
connect(finalId);
|
||||
setView("grid");
|
||||
openCallPage(finalId);
|
||||
} catch (err) {
|
||||
log(1, "call", "red", "Failed to join call [navbar level]", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Event listener for incoming calls
|
||||
useEffect(() => {}, []);
|
||||
|
||||
/**
|
||||
* All of UI
|
||||
*/
|
||||
const [view, setView] = useState<"preview" | "focused" | "grid">("preview");
|
||||
const [currentCallData, setCurrentCallData] = useState<z.infer<
|
||||
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 } });
|
||||
|
||||
// Get current call information for preview view
|
||||
useEffect(() => {
|
||||
if (view !== "preview" || !callId) return;
|
||||
|
||||
send("call_data", { call_id: callId })
|
||||
.then((data) => {
|
||||
setCurrentCallData(data.data);
|
||||
})
|
||||
.catch((err) => {
|
||||
log(1, "Call", "red", "Failed to get call data", {
|
||||
callId,
|
||||
error: err,
|
||||
});
|
||||
setCurrentCallData({
|
||||
users: [],
|
||||
});
|
||||
});
|
||||
}, [callId, send, view]);
|
||||
|
||||
// Room & Encryption
|
||||
const [keyProvider] = useState(() => new ExternalE2EEKeyProvider());
|
||||
const [e2eeWorker] = useState(
|
||||
() => new Worker(new URL("livekit-client/e2ee-worker", import.meta.url)),
|
||||
);
|
||||
const [room] = useState(
|
||||
() =>
|
||||
new Room({
|
||||
dynacast: true,
|
||||
adaptiveStream: true,
|
||||
encryption: {
|
||||
keyProvider,
|
||||
worker: e2eeWorker,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<context.Provider
|
||||
value={{
|
||||
state,
|
||||
view,
|
||||
setView,
|
||||
room,
|
||||
|
||||
callId,
|
||||
openCallPage,
|
||||
|
||||
deaf,
|
||||
toggleDeaf,
|
||||
|
||||
disconnect,
|
||||
joinCall,
|
||||
currentCallData,
|
||||
}}
|
||||
>
|
||||
<LiveKitRoom
|
||||
serverUrl="wss://call.tensamin.net"
|
||||
token={livekitToken || ""}
|
||||
connect={state !== "closed" && livekitToken !== ""}
|
||||
room={room}
|
||||
onConnected={() => {
|
||||
setState("open");
|
||||
log(2, "call", "purple", "Connected to call", {
|
||||
callId,
|
||||
callSecret,
|
||||
});
|
||||
}}
|
||||
onDisconnected={() => {
|
||||
setState("closed");
|
||||
log(2, "call", "purple", "Disconnected from call", {
|
||||
callId,
|
||||
callSecret,
|
||||
});
|
||||
}}
|
||||
onError={(error) => {
|
||||
log(1, "call", "red", error.message);
|
||||
toast("error", error.message);
|
||||
}}
|
||||
onMediaDeviceFailure={(failure, kind) => {
|
||||
log(1, "call", "red", "Media device failure", { failure, kind });
|
||||
toast("error", "Media device failure. See console for details.");
|
||||
}}
|
||||
onEncryptionError={(error) => {
|
||||
log(1, "call", "red", "Encryption error", { error });
|
||||
toast(
|
||||
"error",
|
||||
"Error during call encryption. See console for details.",
|
||||
);
|
||||
}}
|
||||
audio={true}
|
||||
>
|
||||
<NoiseFilter />
|
||||
{props.children}
|
||||
</LiveKitRoom>
|
||||
</context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function NoiseFilter() {
|
||||
const { microphoneTrack } = useLocalParticipant();
|
||||
const noiseFilter = useMemo(
|
||||
() =>
|
||||
new DeepFilterNoiseFilterProcessor({
|
||||
enabled: true,
|
||||
enableNoiseReduction: true,
|
||||
noiseReductionLevel: 80,
|
||||
sampleRate: 48000,
|
||||
assetConfig: {
|
||||
cdnUrl: "/assets",
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const track = microphoneTrack?.track;
|
||||
if (!(track instanceof LocalAudioTrack) || track.getProcessor()) return;
|
||||
|
||||
track.setProcessor(noiseFilter).catch((err) => {
|
||||
log(1, "call", "red", "Failed to enable noise filter", err);
|
||||
});
|
||||
}, [microphoneTrack, noiseFilter]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type contextType = {
|
||||
state: "closed" | "closing" | "connecting" | "open" | "encrypting";
|
||||
view: "preview" | "focused" | "grid";
|
||||
setView: (view: "preview" | "focused" | "grid") => void;
|
||||
disconnect: () => void;
|
||||
joinCall: (userId: number, callSecret?: string, callId?: string) => void;
|
||||
currentCallData: z.infer<typeof ttp.call_data.response> | null;
|
||||
room: Room;
|
||||
openCallPage: (callId: string) => Promise<void>;
|
||||
callId: string | null;
|
||||
deaf: boolean;
|
||||
toggleDeaf: () => void;
|
||||
};
|
||||
|
||||
export function useCall(): contextType {
|
||||
const ctx = useContext(context);
|
||||
if (!ctx) {
|
||||
throw new Error("useCall must be used within a CallProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCall } from "./context";
|
||||
import { useCall } from "./store";
|
||||
|
||||
import MainLayout from "./views/main/layout";
|
||||
import MainGrid from "./views/main/grid";
|
||||
|
|
@ -6,7 +6,7 @@ import MainFocused from "./views/main/focused";
|
|||
import Preview from "./views/preview";
|
||||
|
||||
export default function Screen() {
|
||||
const { view } = useCall();
|
||||
const view = useCall((state) => state.view);
|
||||
|
||||
return view === "preview" ? (
|
||||
<Preview />
|
||||
|
|
|
|||
464
packages/call/src/store.tsx
Normal file
464
packages/call/src/store.tsx
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { create } from "zustand";
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { useTTP } from "@tensamin/ttp";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
import { ttp } from "@tensamin/shared/data";
|
||||
import { useCrypto } from "@tensamin/crypto/context";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useUser } from "@tensamin/user/context";
|
||||
import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter";
|
||||
import {
|
||||
ExternalE2EEKeyProvider,
|
||||
LocalAudioTrack,
|
||||
Room,
|
||||
RoomEvent,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import z from "zod";
|
||||
|
||||
type CallState = "closed" | "closing" | "connecting" | "open" | "encrypting";
|
||||
type CallView = "preview" | "focused" | "grid";
|
||||
type CurrentCallData = z.infer<typeof ttp.call_data.response> | null;
|
||||
|
||||
type NavigateFn = (options: {
|
||||
to: string;
|
||||
search?: Record<string, unknown>;
|
||||
}) => Promise<void>;
|
||||
type SendFn = (
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
) => Promise<{ data: unknown }>;
|
||||
type GetSharedSecretFn = (
|
||||
privateKey: unknown,
|
||||
ownPublicKey: string,
|
||||
remotePublicKey: string,
|
||||
) => Promise<string>;
|
||||
type DecryptTextFn = (sharedSecret: string, text: string) => Promise<string>;
|
||||
type LoadFn = (key: string) => Promise<unknown>;
|
||||
type GetUserFn = (userId: number) => Promise<{ public_key: string }>;
|
||||
|
||||
type Runtime = {
|
||||
navigate: NavigateFn;
|
||||
send: SendFn;
|
||||
getSharedSecret: GetSharedSecretFn;
|
||||
decryptText: DecryptTextFn;
|
||||
load: LoadFn;
|
||||
getUser: GetUserFn;
|
||||
};
|
||||
|
||||
type CallStore = {
|
||||
state: CallState;
|
||||
view: CallView;
|
||||
callId: string | null;
|
||||
callSecret: string | null;
|
||||
livekitToken: string | null;
|
||||
currentCallData: CurrentCallData;
|
||||
deaf: boolean;
|
||||
micEnabled: boolean;
|
||||
screenShareEnabled: boolean;
|
||||
isEncrypted: boolean;
|
||||
room: Room;
|
||||
keyProvider: ExternalE2EEKeyProvider;
|
||||
e2eeWorker: Worker;
|
||||
runtime: Runtime | null;
|
||||
};
|
||||
|
||||
const keyProvider = new ExternalE2EEKeyProvider();
|
||||
const e2eeWorker = new Worker(
|
||||
new URL("livekit-client/e2ee-worker", import.meta.url),
|
||||
);
|
||||
const room = new Room({
|
||||
dynacast: true,
|
||||
adaptiveStream: true,
|
||||
encryption: {
|
||||
keyProvider,
|
||||
worker: e2eeWorker,
|
||||
},
|
||||
});
|
||||
|
||||
function requireRuntime(runtime: Runtime | null): Runtime {
|
||||
if (!runtime) {
|
||||
throw new Error("Call store is not initialized");
|
||||
}
|
||||
|
||||
return runtime;
|
||||
}
|
||||
|
||||
export function syncParticipantState() {
|
||||
const { room } = useCall.getState();
|
||||
|
||||
useCall.setState({
|
||||
micEnabled: room.localParticipant.isMicrophoneEnabled,
|
||||
screenShareEnabled: room.localParticipant.isScreenShareEnabled,
|
||||
isEncrypted:
|
||||
room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted,
|
||||
});
|
||||
}
|
||||
|
||||
export function setCallRuntime(runtime: Runtime) {
|
||||
useCall.setState({ runtime });
|
||||
}
|
||||
|
||||
export function setCallState(state: CallState) {
|
||||
useCall.setState({ state });
|
||||
}
|
||||
|
||||
export function setCallView(view: CallView) {
|
||||
useCall.setState({ view });
|
||||
}
|
||||
|
||||
export function setCallId(callId: string | null) {
|
||||
useCall.setState({ callId });
|
||||
}
|
||||
|
||||
export function setCurrentCallData(currentCallData: CurrentCallData) {
|
||||
useCall.setState({ currentCallData });
|
||||
}
|
||||
|
||||
export async function openCallPage(callId: string) {
|
||||
await requireRuntime(useCall.getState().runtime).navigate({
|
||||
to: "/call",
|
||||
search: { id: callId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCallToken(callId: string): Promise<string> {
|
||||
const response = await requireRuntime(useCall.getState().runtime)
|
||||
.send("call_token", {
|
||||
call_id: callId,
|
||||
})
|
||||
.catch((err) => {
|
||||
log(1, "call", "red", "Failed to get call secret", err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
const data = response.data as { call_token: string };
|
||||
return data.call_token;
|
||||
}
|
||||
|
||||
export async function connect(callId: string) {
|
||||
const token = await getCallToken(callId);
|
||||
|
||||
useCall.setState({
|
||||
state: "connecting",
|
||||
callId,
|
||||
livekitToken: token,
|
||||
});
|
||||
|
||||
log(2, "call", "purple", "Connecting to call", {
|
||||
callId,
|
||||
callSecret: useCall.getState().callSecret,
|
||||
});
|
||||
|
||||
await room.connect("wss://call.tensamin.net", token).catch((error) => {
|
||||
useCall.setState({ state: "closed", livekitToken: null });
|
||||
log(1, "call", "red", "Failed to connect to room", error);
|
||||
toast(
|
||||
"error",
|
||||
error instanceof Error ? error.message : "Failed to connect to call.",
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
|
||||
await room.localParticipant.setMicrophoneEnabled(true).catch((error) => {
|
||||
log(1, "call", "red", "Failed to enable microphone", error);
|
||||
toast("error", "Failed to enable microphone.");
|
||||
throw error;
|
||||
});
|
||||
|
||||
syncParticipantState();
|
||||
}
|
||||
|
||||
export function disconnect() {
|
||||
useCall.setState({
|
||||
state: "closing",
|
||||
callId: null,
|
||||
callSecret: null,
|
||||
livekitToken: null,
|
||||
currentCallData: null,
|
||||
deaf: false,
|
||||
view: "preview",
|
||||
});
|
||||
|
||||
room.remoteParticipants.forEach((participant) => {
|
||||
participant.setVolume(100);
|
||||
});
|
||||
|
||||
room.disconnect();
|
||||
syncParticipantState();
|
||||
}
|
||||
|
||||
export async function joinCall(
|
||||
userId: number,
|
||||
callSecret?: string,
|
||||
existingCallId?: string,
|
||||
) {
|
||||
const runtime = requireRuntime(useCall.getState().runtime);
|
||||
|
||||
log(2, "call", "purple", "Call creation initialised");
|
||||
useCall.setState({ state: "encrypting" });
|
||||
|
||||
if (callSecret) {
|
||||
try {
|
||||
const sharedSecret = await runtime.getSharedSecret(
|
||||
await runtime.load("private_key"),
|
||||
await runtime
|
||||
.getUser((await runtime.load("user_id")) as number)
|
||||
.then((res) => res.public_key),
|
||||
await runtime.getUser(userId).then((res) => res.public_key),
|
||||
);
|
||||
const decryptedSecret = await runtime.decryptText(
|
||||
sharedSecret,
|
||||
callSecret,
|
||||
);
|
||||
|
||||
await keyProvider.setKey(decryptedSecret);
|
||||
await room.setE2EEEnabled(true);
|
||||
useCall.setState({ callSecret: decryptedSecret });
|
||||
} catch (err) {
|
||||
log(1, "call", "red", "Failed getting call secret", err);
|
||||
disconnect();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const random = crypto.randomUUID();
|
||||
|
||||
await keyProvider.setKey(random);
|
||||
await room.setE2EEEnabled(true);
|
||||
useCall.setState({ callSecret: random });
|
||||
}
|
||||
|
||||
const finalId = existingCallId || crypto.randomUUID();
|
||||
|
||||
try {
|
||||
useCall.setState({ view: "grid", callId: finalId });
|
||||
await openCallPage(finalId);
|
||||
await connect(finalId);
|
||||
} catch (err) {
|
||||
log(1, "call", "red", "Failed to join call [navbar level]", err);
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleDeaf() {
|
||||
const nextDeaf = !useCall.getState().deaf;
|
||||
|
||||
room.remoteParticipants.forEach((participant) => {
|
||||
participant.setVolume(nextDeaf ? 0 : 100);
|
||||
});
|
||||
|
||||
if (nextDeaf && room.localParticipant.isMicrophoneEnabled) {
|
||||
toggleMute();
|
||||
}
|
||||
|
||||
useCall.setState({ deaf: nextDeaf });
|
||||
}
|
||||
|
||||
export async function toggleMute() {
|
||||
const micEnabled = useCall.getState().micEnabled;
|
||||
|
||||
if (!micEnabled && useCall.getState().deaf) {
|
||||
toggleDeaf();
|
||||
}
|
||||
|
||||
await room.localParticipant.setMicrophoneEnabled(!micEnabled);
|
||||
syncParticipantState();
|
||||
}
|
||||
|
||||
export async function setScreenShareEnabled(enabled: boolean) {
|
||||
await room.localParticipant.setScreenShareEnabled(enabled);
|
||||
syncParticipantState();
|
||||
}
|
||||
|
||||
export function resetCallState() {
|
||||
useCall.setState({
|
||||
state: "closed",
|
||||
view: "preview",
|
||||
callId: null,
|
||||
callSecret: null,
|
||||
livekitToken: null,
|
||||
currentCallData: null,
|
||||
deaf: false,
|
||||
});
|
||||
|
||||
syncParticipantState();
|
||||
}
|
||||
|
||||
async function ensureNoiseFilter(
|
||||
noiseFilter: DeepFilterNoiseFilterProcessor,
|
||||
): Promise<void> {
|
||||
const microphoneTrack = room.localParticipant.getTrackPublication(
|
||||
Track.Source.Microphone,
|
||||
)?.track;
|
||||
|
||||
if (
|
||||
!(microphoneTrack instanceof LocalAudioTrack) ||
|
||||
microphoneTrack.getProcessor()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await microphoneTrack.setProcessor(noiseFilter).catch((err) => {
|
||||
log(1, "call", "red", "Failed to enable noise filter", err);
|
||||
});
|
||||
}
|
||||
|
||||
export const useCall = create<CallStore>(() => ({
|
||||
state: "closed",
|
||||
view: "preview",
|
||||
callId: null,
|
||||
callSecret: null,
|
||||
livekitToken: null,
|
||||
currentCallData: null,
|
||||
deaf: false,
|
||||
micEnabled: room.localParticipant.isMicrophoneEnabled,
|
||||
screenShareEnabled: room.localParticipant.isScreenShareEnabled,
|
||||
isEncrypted:
|
||||
room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted,
|
||||
room,
|
||||
keyProvider,
|
||||
e2eeWorker,
|
||||
runtime: null,
|
||||
}));
|
||||
|
||||
export function useInitializeCall() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { send } = useTTP();
|
||||
const { getSharedSecret, decryptText } = useCrypto();
|
||||
const { load } = useStorage();
|
||||
const { get } = useUser();
|
||||
|
||||
const callId = useCall((state) => state.callId);
|
||||
const view = useCall((state) => state.view);
|
||||
|
||||
const listenersRegistered = useRef(false);
|
||||
const noiseFilter = useMemo(
|
||||
() =>
|
||||
new DeepFilterNoiseFilterProcessor({
|
||||
enabled: true,
|
||||
enableNoiseReduction: true,
|
||||
noiseReductionLevel: 80,
|
||||
sampleRate: 48000,
|
||||
assetConfig: {
|
||||
cdnUrl: "/assets",
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCallRuntime({
|
||||
navigate,
|
||||
send: send as SendFn,
|
||||
getSharedSecret: getSharedSecret as GetSharedSecretFn,
|
||||
decryptText: decryptText as DecryptTextFn,
|
||||
load: load as LoadFn,
|
||||
getUser: get as GetUserFn,
|
||||
});
|
||||
}, [decryptText, get, getSharedSecret, load, navigate, send]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.pathname.startsWith("/call")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const search = new URLSearchParams(location.searchStr);
|
||||
const routeCallId = search.get("id");
|
||||
|
||||
if (routeCallId) {
|
||||
setCallId(routeCallId);
|
||||
}
|
||||
}, [location.pathname, location.searchStr]);
|
||||
|
||||
useEffect(() => {
|
||||
if (listenersRegistered.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
listenersRegistered.current = true;
|
||||
|
||||
const onConnected = () => {
|
||||
useCall.setState({ state: "open" });
|
||||
syncParticipantState();
|
||||
log(2, "call", "purple", "Connected to call", {
|
||||
callId: useCall.getState().callId,
|
||||
callSecret: useCall.getState().callSecret,
|
||||
});
|
||||
};
|
||||
|
||||
const onDisconnected = () => {
|
||||
useCall.setState({ state: "closed" });
|
||||
syncParticipantState();
|
||||
log(2, "call", "purple", "Disconnected from call", {
|
||||
callId: useCall.getState().callId,
|
||||
callSecret: useCall.getState().callSecret,
|
||||
});
|
||||
};
|
||||
|
||||
const onMediaDeviceFailure = (error: Error, kind?: MediaDeviceKind) => {
|
||||
log(1, "call", "red", "Media device failure", { error, kind });
|
||||
toast("error", "Media device failure. See console for details.");
|
||||
};
|
||||
|
||||
const onEncryptionError = (error: Error) => {
|
||||
log(1, "call", "red", "Encryption error", { error });
|
||||
toast("error", "Error during call encryption. See console for details.");
|
||||
};
|
||||
|
||||
const onParticipantStateChange = () => {
|
||||
syncParticipantState();
|
||||
void ensureNoiseFilter(noiseFilter);
|
||||
};
|
||||
|
||||
room.on(RoomEvent.Connected, onConnected);
|
||||
room.on(RoomEvent.Reconnected, onConnected);
|
||||
room.on(RoomEvent.Disconnected, onDisconnected);
|
||||
room.on(RoomEvent.TrackMuted, onParticipantStateChange);
|
||||
room.on(RoomEvent.TrackUnmuted, onParticipantStateChange);
|
||||
room.on(RoomEvent.LocalTrackPublished, onParticipantStateChange);
|
||||
room.on(RoomEvent.LocalTrackUnpublished, onParticipantStateChange);
|
||||
room.on(RoomEvent.MediaDevicesError, onMediaDeviceFailure);
|
||||
room.on(RoomEvent.EncryptionError, onEncryptionError);
|
||||
room.on(RoomEvent.ConnectionStateChanged, onParticipantStateChange);
|
||||
|
||||
void ensureNoiseFilter(noiseFilter);
|
||||
syncParticipantState();
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.Connected, onConnected);
|
||||
room.off(RoomEvent.Reconnected, onConnected);
|
||||
room.off(RoomEvent.Disconnected, onDisconnected);
|
||||
room.off(RoomEvent.TrackMuted, onParticipantStateChange);
|
||||
room.off(RoomEvent.TrackUnmuted, onParticipantStateChange);
|
||||
room.off(RoomEvent.LocalTrackPublished, onParticipantStateChange);
|
||||
room.off(RoomEvent.LocalTrackUnpublished, onParticipantStateChange);
|
||||
room.off(RoomEvent.MediaDevicesError, onMediaDeviceFailure);
|
||||
room.off(RoomEvent.EncryptionError, onEncryptionError);
|
||||
room.off(RoomEvent.ConnectionStateChanged, onParticipantStateChange);
|
||||
listenersRegistered.current = false;
|
||||
room.disconnect();
|
||||
e2eeWorker.terminate();
|
||||
};
|
||||
}, [noiseFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== "preview" || !callId) {
|
||||
return;
|
||||
}
|
||||
|
||||
send("call_data", { call_id: callId })
|
||||
.then((data) => {
|
||||
setCurrentCallData(data.data as z.infer<typeof ttp.call_data.response>);
|
||||
})
|
||||
.catch((err) => {
|
||||
log(1, "Call", "red", "Failed to get call data", {
|
||||
callId,
|
||||
error: err,
|
||||
});
|
||||
setCurrentCallData({
|
||||
users: [],
|
||||
});
|
||||
});
|
||||
}, [callId, send, view]);
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
export default function View() {
|
||||
return <div>Grid</div>;
|
||||
return <div className="w-full h-full bg-red-500">Grid</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import Actions from "../../components/actions";
|
||||
import TopBar from "../../components/top";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
Layout
|
||||
{children}
|
||||
<div className="w-full h-full flex flex-col bg-green-500">
|
||||
<TopBar />
|
||||
<div className="w-full h-full">{children}</div>
|
||||
<Actions />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCall } from "../context";
|
||||
import { useCall } from "../store";
|
||||
|
||||
export default function Preview() {
|
||||
const { currentCallData } = useCall();
|
||||
const currentCallData = useCall((state) => state.currentCallData);
|
||||
|
||||
return <div>Preview View {JSON.stringify(currentCallData?.users)}</div>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue