(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

@ -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"
}
}

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>
);
}

View file

@ -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;
}

View file

@ -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
View 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]);
}

View file

@ -1,3 +1,3 @@
export default function View() {
return <div>Grid</div>;
return <div className="w-full h-full bg-red-500">Grid</div>;
}

View file

@ -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>
);
}

View file

@ -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>;
}