Files
var-monorepo/apps/dispatch/app/_store/audioStore.ts
2025-06-03 17:54:30 -07:00

193 lines
5.3 KiB
TypeScript

import { dispatchSocket } from "dispatch/socket";
import {
handleDisconnect,
handleLocalTrackUnpublished,
handleTrackSubscribed,
handleTrackUnsubscribed,
} from "_helpers/liveKitEventHandler";
import { ConnectionQuality, Participant, Room, RoomEvent } from "livekit-client";
import { pilotSocket } from "pilot/socket";
import { create } from "zustand";
import axios from "axios";
let interval: NodeJS.Timeout;
type TalkState = {
micDeviceId: string | null;
micVolume: number;
isTalking: boolean;
removeMessage: () => void;
state: "connecting" | "connected" | "disconnected" | "error";
message: string | null;
connectionQuality: ConnectionQuality;
remoteParticipants: number;
toggleTalking: () => void;
setMic: (micDeviceId: string | null, volume: number) => void;
connect: (roomName: string, role: string) => void;
disconnect: () => void;
speakingParticipants: Participant[];
addSpeakingParticipant: (participant: Participant) => void;
removeSpeakingParticipant: (speakingParticipants: Participant) => void;
room: Room | null;
};
const getToken = async (roomName: string) => {
const response = await axios.get(`/api/livekit-token?roomName=${roomName}`);
const data = response.data;
return data.token;
};
export const useAudioStore = create<TalkState>((set, get) => ({
isTalking: false,
message: null,
micDeviceId: null,
speakingParticipants: [],
micVolume: 1,
state: "disconnected",
source: "",
remoteParticipants: 0,
connectionQuality: ConnectionQuality.Unknown,
room: null,
addSpeakingParticipant: (participant) => {
set((state) => {
if (!state.speakingParticipants.some((p) => p.identity === participant.identity)) {
return { speakingParticipants: [...state.speakingParticipants, participant] };
}
return state;
});
},
removeMessage: () => {
set({ message: null });
},
removeSpeakingParticipant: (participant) => {
set((state) => ({
speakingParticipants: state.speakingParticipants.filter(
(p) => !(p.identity === participant.identity),
),
}));
},
setMic: (micDeviceId, micVolume) => {
set({ micDeviceId, micVolume });
},
toggleTalking: () => {
const { room, isTalking, micDeviceId, micVolume } = get();
if (!room) return;
// Todo: use micVolume
room.localParticipant.setMicrophoneEnabled(!isTalking, {
deviceId: micDeviceId ?? undefined,
});
if (!isTalking) {
// If old status was not talking, we need to emit the PTT event
if (pilotSocket.connected) {
pilotSocket.emit("ptt", {
shouldTransmit: true,
channel: room.name,
});
}
if (dispatchSocket.connected)
dispatchSocket.emit("ptt", {
shouldTransmit: true,
channel: room.name,
});
}
set((state) => ({ isTalking: !state.isTalking }));
},
connect: async (roomName, role) => {
set({ state: "connecting" });
console.log("Connecting to room: ", roomName);
try {
// Clean old room
const connectedRoom = get().room;
if (interval) clearInterval(interval);
if (connectedRoom) {
connectedRoom.disconnect();
connectedRoom.removeAllListeners();
}
const url = process.env.NEXT_PUBLIC_LIVEKIT_URL;
if (!url) return console.error("NEXT_PUBLIC_LIVEKIT_URL not set");
const token = await getToken(roomName);
if (!token) throw new Error("Fehlende Berechtigung");
const room = new Room({});
await room.prepareConnection(url, token);
room
// Connection events
.on(RoomEvent.Connected, () => {
set({ state: "connected", room, message: null });
})
.on(RoomEvent.Disconnected, () => {
set({ state: "disconnected" });
handleDisconnect();
})
.on(RoomEvent.ConnectionQualityChanged, (connectionQuality) => set({ connectionQuality }))
// Track events
.on(RoomEvent.TrackSubscribed, handleTrackSubscribed)
.on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed)
.on(RoomEvent.LocalTrackUnpublished, handleLocalTrackUnpublished);
await room.connect(url, token, {});
room.localParticipant.setAttributes({
role,
});
set({ room });
interval = setInterval(() => {
set({
remoteParticipants: room.numParticipants === 0 ? 0 : room.numParticipants - 1, // Unreliable and delayed
});
}, 500);
} catch (error: Error | unknown) {
console.error("Error occured: ", error);
if (error instanceof Error) {
set({ state: "error", message: error.message });
} else {
set({ state: "error", message: "Unknown error" });
}
}
},
disconnect: () => {
get().room?.disconnect();
},
}));
interface PTTData {
shouldTransmit: boolean;
source: string;
}
const handlePTT = (data: PTTData) => {
const { shouldTransmit, source } = data;
const { room } = useAudioStore.getState();
if (!room) return;
useAudioStore.setState({
isTalking: shouldTransmit,
});
if (shouldTransmit) {
room.localParticipant.setMicrophoneEnabled(true);
} else {
room.localParticipant.setMicrophoneEnabled(false);
}
};
const handleForceEndTransmission = ({ by }: { by?: string }) => {
const { room } = useAudioStore.getState();
if (!room) return;
room.localParticipant.setMicrophoneEnabled(false);
useAudioStore.setState({
isTalking: false,
message: `Ruf beendet durch ${by || "unknown"}`,
});
};
pilotSocket.on("ptt", handlePTT);
dispatchSocket.on("ptt", handlePTT);
pilotSocket.on("force-end-transmission", handleForceEndTransmission);
dispatchSocket.on("force-end-transmission", handleForceEndTransmission);