added HPG VEhicles Mission, Audio settings; mission Context menu

This commit is contained in:
PxlLoewe
2025-05-24 12:46:11 -07:00
parent b2890b3ecc
commit 1ca6007ac5
28 changed files with 680 additions and 369 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import {
Disc,
@@ -18,10 +18,12 @@ import { useAudioStore } from "_store/audioStore";
import { cn } from "helpers/cn";
import { ConnectionQuality } from "livekit-client";
import { ROOMS } from "_data/livekitRooms";
import { useSession } from "next-auth/react";
export const Audio = () => {
const connection = usePilotConnectionStore();
const [showSource, setShowSource] = useState(false);
const serverSession = useSession();
const {
isTalking,
@@ -46,6 +48,7 @@ export const Audio = () => {
clearTimeout(timeout);
};
}, [source, isTalking]);
useEffect(() => {
const joinRoom = async () => {
if (connection.status != "connected") return;
@@ -64,17 +67,12 @@ export const Audio = () => {
return (
<>
<div className="bg-base-200 rounded-box flex items-center gap-2 p-1">
{state === "error" && (
<div className="h-4 flex items-center">{message}</div>
)}
{showSource && source && (
<div className="h-4 flex items-center ml-2">{source}</div>
)}
{state === "error" && <div className="h-4 flex items-center">{message}</div>}
{showSource && source && <div className="h-4 flex items-center ml-2">{source}</div>}
<button
onClick={() => {
if (state === "connected") toggleTalking();
if (state === "error" || state === "disconnected")
connect(selectedRoom);
if (state === "error" || state === "disconnected") connect(selectedRoom);
}}
className={cn(
"btn btn-sm btn-soft border-none hover:bg-inherit",
@@ -82,8 +80,7 @@ export const Audio = () => {
isTalking && "bg-green-700 hover:bg-green-600",
state === "disconnected" && "bg-red-500 hover:bg-red-500",
state === "error" && "bg-red-500 hover:bg-red-500",
state === "connecting" &&
"bg-yellow-500 hover:bg-yellow-500 cursor-default",
state === "connecting" && "bg-yellow-500 hover:bg-yellow-500 cursor-default",
)}
>
{state === "connected" && <Mic className="w-5 h-5" />}
@@ -95,24 +92,14 @@ export const Audio = () => {
{state === "connected" && (
<details className="dropdown relative z-[1050]">
<summary className="dropdown btn btn-ghost flex items-center gap-1">
{connectionQuality === ConnectionQuality.Excellent && (
<Signal className="w-5 h-5" />
)}
{connectionQuality === ConnectionQuality.Good && (
<SignalMedium className="w-5 h-5" />
)}
{connectionQuality === ConnectionQuality.Poor && (
<SignalLow className="w-5 h-5" />
)}
{connectionQuality === ConnectionQuality.Lost && (
<ZapOff className="w-5 h-5" />
)}
{connectionQuality === ConnectionQuality.Excellent && <Signal className="w-5 h-5" />}
{connectionQuality === ConnectionQuality.Good && <SignalMedium className="w-5 h-5" />}
{connectionQuality === ConnectionQuality.Poor && <SignalLow className="w-5 h-5" />}
{connectionQuality === ConnectionQuality.Lost && <ZapOff className="w-5 h-5" />}
{connectionQuality === ConnectionQuality.Unknown && (
<ShieldQuestion className="w-5 h-5" />
)}
<div className="badge badge-sm badge-soft badge-success">
{remoteParticipants}
</div>
<div className="badge badge-sm badge-soft badge-success">{remoteParticipants}</div>
</summary>
<ul className="menu dropdown-content bg-base-200 rounded-box z-[1050] w-52 p-2 shadow-sm">
{ROOMS.map((r) => (
@@ -126,10 +113,7 @@ export const Audio = () => {
}}
>
{room?.name === r && (
<Disc
className="text-success text-sm absolute left-2"
width={15}
/>
<Disc className="text-success text-sm absolute left-2" width={15} />
)}
<span className="flex-1 text-center">{r}</span>
</button>
@@ -142,10 +126,7 @@ export const Audio = () => {
disconnect();
}}
>
<WifiOff
className="text-error text-sm absolute left-2"
width={15}
/>
<WifiOff className="text-error text-sm absolute left-2" width={15} />
<span className="flex-1 text-center">Disconnect</span>
</button>
</li>

View File

@@ -0,0 +1,75 @@
"use client";
import { cn } from "helpers/cn";
import { useEffect, useState } from "react";
type MicrophoneLevelProps = {
deviceId: string;
volumeInput: number; // Verstärkung der Lautstärke
};
export default function MicrophoneLevel({ deviceId, volumeInput }: MicrophoneLevelProps) {
const [volumeLevel, setVolumeLevel] = useState(0);
useEffect(() => {
let audioContext: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let source: MediaStreamAudioSourceNode | null = null;
let rafId: number;
async function start() {
audioContext = new AudioContext();
const stream = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: deviceId ? { exact: deviceId } : undefined },
});
source = audioContext.createMediaStreamSource(stream);
analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
const dataArray = new Uint8Array(analyser.frequencyBinCount);
const updateVolume = () => {
if (!analyser) return;
analyser.getByteFrequencyData(dataArray);
const avg = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
setVolumeLevel(avg * volumeInput);
rafId = requestAnimationFrame(updateVolume);
};
updateVolume();
}
start();
return () => {
cancelAnimationFrame(rafId);
audioContext?.close();
};
}, [deviceId, volumeInput]);
const barWidth = Math.max((volumeLevel / 70) * 100 - 35, 0);
return (
<div className="w-full">
<div className="relative w-full bg-base-300 h-5 rounded">
<div
className={cn("bg-primary h-full rounded", barWidth > 100 && "bg-red-400")}
style={{
width: `${barWidth > 100 ? 100 : barWidth}%`,
transition: "width 0.2s",
}}
/>
<div
className="absolute top-0 left-[60%] w-[20%] h-full bg-green-500 opacity-40 rounded"
style={{
transform: "translateX(-50%)",
}}
/>
</div>
<p className="text-gray-500 text-sm">
Lautstärke sollte beim Sprechen in dem Grünen bereich bleiben
</p>
</div>
);
}

View File

@@ -0,0 +1,186 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { GearIcon } from "@radix-ui/react-icons";
import { SettingsIcon, Volume2 } from "lucide-react";
import MicVolumeBar from "_components/MicVolumeIndication";
import { useMutation, useQuery } from "@tanstack/react-query";
import { editUserAPI, getUserAPI } from "querys/user";
import { Prisma } from "@repo/db";
import { useSession } from "next-auth/react";
import { useAudioStore } from "_store/audioStore";
import toast from "react-hot-toast";
export const SettingsBtn = () => {
const session = useSession();
const { data: user } = useQuery({
queryKey: ["user", session.data?.user.id],
queryFn: () => getUserAPI(session.data!.user.id),
});
const editUserMutation = useMutation({
mutationFn: ({ user }: { user: Prisma.UserUpdateInput }) =>
editUserAPI(session.data!.user.id, user),
});
const modalRef = useRef<HTMLDialogElement>(null);
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedDevice, setSelectedDevice] = useState<string | null>(
user?.settingsMicDevice || null,
);
const [showIndication, setShowInducation] = useState<boolean>(false);
const [micVol, setMicVol] = useState<number>(1);
const setMic = useAudioStore((state) => state.setMic);
useEffect(() => {
if (user?.settingsMicDevice) {
setSelectedDevice(user.settingsMicDevice);
setMic(user.settingsMicDevice, user.settingsMicVolume || 1);
}
}, [user, setMic]);
useEffect(() => {
navigator.mediaDevices.enumerateDevices().then((devices) => {
setInputDevices(devices.filter((d) => d.kind === "audioinput"));
});
}, []);
return (
<div>
<button
className="btn btn-ghost"
onSubmit={() => false}
onClick={() => {
modalRef.current?.showModal();
}}
>
<GearIcon className="w-5 h-5" />
</button>
<dialog ref={modalRef} className="modal">
<div className="modal-box">
<h3 className="flex items-center gap-2 text-lg font-bold mb-5">
<SettingsIcon size={20} /> Einstellungen
</h3>
<div className="flex flex-col items-center justify-center">
<fieldset className="fieldset w-full">
<label className="floating-label w-full text-base">
<span>Eingabegerät</span>
<select
className="input w-full"
value={selectedDevice ? selectedDevice : ""}
onChange={(e) => {
setSelectedDevice(e.target.value);
setShowInducation(true);
}}
>
<option key={0} value={0} disabled>
Bitte wähle ein Eingabegerät...
</option>
{inputDevices.map((device, index) => (
<option key={index} value={device.deviceId}>
{device.label}
</option>
))}
</select>
</label>
</fieldset>
<p className="flex items-center gap-2 text-base mb-2">
<Volume2 size={20} /> Microfonlautstärke
</p>
<div className="w-full">
<input
type="range"
min={0}
max={3}
step={0.01}
onChange={(e) => {
const value = parseFloat(e.target.value);
setMicVol(value);
// Hier kannst du den Lautstärkewert verwenden
setShowInducation(true);
console.log("Lautstärke:", value);
}}
value={micVol}
className="range range-xs range-accent w-full"
/>
<div className="flex justify-between px-2.5 mt-2 text-xs">
<span>0</span>
<span>100</span>
<span>200</span>
<span>300</span>
</div>
</div>
{showIndication && (
<MicVolumeBar deviceId={selectedDevice ? selectedDevice : ""} volumeInput={micVol} />
)}
{/* <MicVolumeIndication deviceId={selectedDevice ? selectedDevice : ""} volumeInput={40} /> */}
{/* <MicVolumeIndication deviceId={selectedDevice ? selectedDevice : ""} volumeInput={40
} />
{/* FÜGE HIER BITTE DEN MIKROFONAUSSCHLAG EIN WIE IN DER V1 */}
<div className="divider w-full" />
</div>
<p className="flex items-center gap-2 text-base mb-2">
<Volume2 size={20} /> Ausgabelautstärke
</p>
<div className="w-full">
<input
type="range"
min={0}
max={100}
defaultValue={40}
className="range range-xs range-accent w-full"
/>
<div className="flex justify-between px-2.5 mt-2 text-xs">
<span>0</span>
<span>25</span>
<span>50</span>
<span>75</span>
<span>100</span>
</div>
</div>
<div className="flex justify-between modal-action">
<button
className="btn btn-soft"
type="submit"
onSubmit={() => false}
onClick={() => {
modalRef.current?.close();
}}
>
Schließen
</button>
<button
className="btn btn-soft btn-success"
type="submit"
onSubmit={() => false}
onClick={async () => {
await editUserMutation.mutateAsync({
user: {
settingsMicDevice: selectedDevice,
settingsMicVolume: micVol,
},
});
setMic(selectedDevice, micVol);
modalRef.current?.close();
toast.success("Einstellungen gespeichert");
}}
>
Speichern
</button>
</div>
</div>
</dialog>
</div>
);
};
export const Settings = () => {
return (
<div>
<SettingsBtn />
</div>
);
};

View File

@@ -128,6 +128,8 @@ export const ContextMenu = () => {
setMissionFormValues({
...parsed,
state: "draft",
addressLat: contextMenu.lat,
addressLng: contextMenu.lng,
addressOSMways: [closestToContext],
});

View File

@@ -46,7 +46,6 @@ const MissionPopupContent = ({
const handleTabChange = useCallback(
(tab: "home" | "details" | "patient" | "log") => {
console.log("handleTabChange", tab);
setMissionMarker({
open: [
{
@@ -284,10 +283,6 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
mission: Mission,
anchor: "topleft" | "topright" | "bottomleft" | "bottomright",
) => {
console.log(
HPGValidationRequired(mission.missionStationIds, aircrafts, mission.hpgMissionString),
);
const markerColor = needsAction
? MISSION_STATUS_COLORS["attention"]
: MISSION_STATUS_COLORS[mission.state];

View File

@@ -21,6 +21,7 @@ import {
} from "lucide-react";
import {
getPublicUser,
HpgState,
HpgValidationState,
Mission,
MissionLog,
@@ -37,6 +38,7 @@ import { getStationsAPI } from "querys/stations";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { HPGValidationRequired } from "helpers/hpgValidationRequired";
import { getOsmAddress } from "querys/osm";
import { hpgStateToFMSStatus } from "helpers/hpgStateToFmsStatus";
const Einsatzdetails = ({
mission,
@@ -161,10 +163,11 @@ const Einsatzdetails = ({
<Navigation size={16} /> {mission.addressStreet}
</p>
<p className="flex items-center gap-2">
<LocateFixed size={16} />
{mission.addressZip && mission.addressCity ? (
`${mission.addressZip} ${mission.addressCity}`
) : (
<span className="italic text-gray-400">PLZ Ort nicht angegeben</span>
<span className="italic text-gray-400">PLZ / Ort nicht angegeben</span>
)}
</p>
</div>
@@ -366,8 +369,15 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
const sendAlertMutation = useMutation({
mutationKey: ["missions"],
mutationFn: ({ id, stationId }: { id: number; stationId?: number }) =>
sendMissionAPI(id, { stationId }),
mutationFn: ({
id,
stationId,
vehicleName,
}: {
id: number;
stationId?: number;
vehicleName?: "ambulance" | "police" | "firebrigade";
}) => sendMissionAPI(id, { stationId, vehicleName }),
onError: (error) => {
console.error(error);
toast.error("Fehler beim Alarmieren");
@@ -379,6 +389,25 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected";
const HPGVehicle = ({ state, name }: { state: HpgState; name: string }) => (
<li className="flex items-center gap-2">
<span
className="font-bold text-base"
style={{
color: FMS_STATUS_TEXT_COLORS[hpgStateToFMSStatus(state)],
}}
>
{hpgStateToFMSStatus(state)}
</span>
<span className="text-base-content">
<div>
<span className="font-bold">{name}</span>
</div>
</span>
</li>
);
return (
<div className="p-4 text-base-content">
<div className="flex items-center w-full justify-between mb-2">
@@ -435,6 +464,11 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
</li>
);
})}
{mission.hpgAmbulanceState && <HPGVehicle state={mission.hpgAmbulanceState} name="RTW" />}
{mission.hpgFireEngineState && (
<HPGVehicle state={mission.hpgFireEngineState} name="Feuerwehr" />
)}
{mission.hpgPoliceState && <HPGVehicle state={mission.hpgPoliceState} name="Polizei" />}
</ul>
{dispatcherConnected && (
<div>
@@ -475,7 +509,10 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
className="btn btn-sm btn-primary btn-outline"
onClick={async () => {
if (typeof selectedStation === "string") {
toast.error("Fahrzeuge werden aktuell nicht unterstützt");
await sendAlertMutation.mutate({
id: mission.id,
vehicleName: selectedStation,
});
} else {
if (!selectedStation?.id) return;
await updateMissionMutation.mutateAsync({
@@ -520,7 +557,6 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
queryClient.invalidateQueries({ queryKey: ["missions"] });
},
});
console.log(mission.missionLog);
if (!session.data?.user) return null;
return (