Files
var-monorepo/apps/dispatch/app/_store/audioStore.ts
2026-01-15 23:35:14 +01:00

323 lines
9.2 KiB
TypeScript

import { dispatchSocket } from "(app)/dispatch/socket";
import {
handleDisconnect,
handleLocalTrackUnpublished,
handleTrackSubscribed,
handleTrackUnsubscribed,
} from "_helpers/liveKitEventHandler";
import {
ConnectionQuality,
LocalTrackPublication,
Participant,
Room,
RoomEvent,
RpcInvocationData,
Track,
} from "livekit-client";
import { pilotSocket } from "(app)/pilot/socket";
import { create } from "zustand";
import axios from "axios";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { changeDispatcherAPI } from "_querys/dispatcher";
import { getRadioStream } from "_helpers/radioEffect";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
let interval: NodeJS.Timeout;
type TalkState = {
addSpeakingParticipant: (participant: Participant) => void;
connect: (roomName: string, role: string) => void;
connectionQuality: ConnectionQuality;
disconnect: () => void;
isTalking: boolean;
localRadioTrack: LocalTrackPublication | undefined;
message: string | null;
removeMessage: () => void;
removeSpeakingParticipant: (speakingParticipants: Participant) => void;
remoteParticipants: number;
resetSpeakingParticipants: (source: string) => void;
room: Room | null;
setSettings: (settings: Partial<TalkState["settings"]>) => void;
settings: {
micDeviceId: string | null;
micVolume: number;
radioVolume: number;
dmeVolume: number;
};
speakingParticipants: Participant[];
state: "connecting" | "connected" | "disconnected" | "error";
toggleTalking: () => void;
transmitBlocked: boolean;
};
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,
localRadioTrack: undefined,
transmitBlocked: false,
message: null,
speakingParticipants: [],
micVolume: 1,
settings: {
micDeviceId: null,
micVolume: 1,
radioVolume: 0.8,
dmeVolume: 0.8,
},
state: "disconnected" as const,
remoteParticipants: 0,
connectionQuality: ConnectionQuality.Unknown,
room: null,
resetSpeakingParticipants: (source: string) => {
set({
speakingParticipants: [],
isTalking: false,
transmitBlocked: false,
message: `Ruf beendet durch ${source || "eine unsichtbare Macht"}`,
});
get().room?.localParticipant.setMicrophoneEnabled(false);
},
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) => {
const newSpeaktingParticipants = get().speakingParticipants.filter(
(p) => !(p.identity === participant.identity),
);
set(() => ({
speakingParticipants: newSpeaktingParticipants,
}));
if (newSpeaktingParticipants.length === 0 && get().transmitBlocked) {
get().room?.localParticipant.setMicrophoneEnabled(true);
set({ transmitBlocked: false, message: null, isTalking: true });
}
},
setSettings: (newSettings) => {
const oldSettings = get().settings;
set((s) => ({
settings: {
...s.settings,
...newSettings,
},
}));
if (
get().state === "connected" &&
(oldSettings.micDeviceId !== newSettings.micDeviceId ||
oldSettings.micVolume !== newSettings.micVolume)
) {
const { room, disconnect, connect } = get();
const role = room?.localParticipant.attributes.role;
if (room?.name || role) {
disconnect();
connect(room?.name || "", role || "user");
}
}
},
toggleTalking: () => {
const { room, isTalking, speakingParticipants, transmitBlocked } = get();
if (!room) return;
if (speakingParticipants.length > 0 && !isTalking && !transmitBlocked) {
// Wenn andere sprechen, nicht reden
set({
message: "Rufgruppe besetzt",
transmitBlocked: true,
});
return;
} else if (!isTalking && transmitBlocked) {
set({
message: null,
transmitBlocked: false,
});
return;
}
const { status: DispatcherConnectionStatus } = useDispatchConnectionStore.getState();
const { status: PilotConnectionStatus } = usePilotConnectionStore.getState();
if (
!isTalking &&
!(DispatcherConnectionStatus === "connected" || PilotConnectionStatus === "connected")
) {
useAudioStore.setState({
message: "Keine Verbindung",
});
return;
}
room.localParticipant.setMicrophoneEnabled(!isTalking);
set((state) => ({ isTalking: !state.isTalking, transmitBlocked: false }));
},
connect: async (roomName, role) => {
set({ state: "connecting" });
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, async () => {
const dispatchState = useDispatchConnectionStore.getState();
if (dispatchState.status === "connected" && dispatchState.connectedDispatcher?.id) {
changeDispatcherAPI(dispatchState.connectedDispatcher?.id, {
zone: roomName,
ghostMode: dispatchState.ghostMode,
});
}
const inputStream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: get().settings.micDeviceId ?? undefined,
},
});
// Funk-Effekt anwenden
const radioStream = getRadioStream(inputStream, get().settings.micVolume);
if (!radioStream) throw new Error("Konnte Funkstream nicht erzeugen");
const [track] = radioStream.getAudioTracks();
if (!track) throw new Error("Konnte Audio-Track nicht erzeugen");
const publishedTrack = await room.localParticipant.publishTrack(track, {
name: "radio-audio",
source: Track.Source.Microphone,
});
await publishedTrack.mute();
set({ localRadioTrack: publishedTrack });
set({ state: "connected", room, isTalking: false, message: null });
})
.on(RoomEvent.Disconnected, () => {
set({
state: "disconnected",
speakingParticipants: [],
transmitBlocked: false,
isTalking: false,
});
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 });
room.registerRpcMethod("force-mute", async (data: RpcInvocationData) => {
const { by } = JSON.parse(data.payload);
get().resetSpeakingParticipants(by);
return "OK";
});
interval = setInterval(() => {
// Filter forgotten participants
const oldSpeakingParticipants = get().speakingParticipants;
const speakingParticipants = oldSpeakingParticipants.filter((oP) => {
return Array.from(room.remoteParticipants.values()).find(
(p) => p.identity === oP.identity,
);
});
if (oldSpeakingParticipants.length !== speakingParticipants.length) {
set({
remoteParticipants: room.numParticipants === 0 ? 0 : room.numParticipants - 1, // Unreliable and delayed
speakingParticipants,
});
} else {
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 } = data;
const { room, speakingParticipants } = useAudioStore.getState();
if (!room) return;
if (speakingParticipants.length > 0 && shouldTransmit) {
// Wenn andere sprechen, nicht reden
useAudioStore.setState({
message: "Rufgruppe besetzt",
transmitBlocked: true,
});
return;
}
const { status: DispatcherConnectionStatus } = useDispatchConnectionStore.getState();
const { status: PilotConnectionStatus } = usePilotConnectionStore.getState();
if (
shouldTransmit &&
!(DispatcherConnectionStatus === "connected" || PilotConnectionStatus === "connected")
) {
useAudioStore.setState({
message: "Keine Verbindung",
});
return;
}
useAudioStore.setState({
isTalking: shouldTransmit,
transmitBlocked: false,
});
if (shouldTransmit) {
room.localParticipant.setMicrophoneEnabled(true);
} else {
room.localParticipant.setMicrophoneEnabled(false);
}
};
pilotSocket.on("ptt", handlePTT);
dispatchSocket.on("ptt", handlePTT);