added HPG VEhicles Mission, Audio settings; mission Context menu
This commit is contained in:
@@ -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>
|
||||
|
||||
75
apps/dispatch/app/_components/MicVolumeIndication.tsx
Normal file
75
apps/dispatch/app/_components/MicVolumeIndication.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
apps/dispatch/app/_components/Settings.tsx
Normal file
186
apps/dispatch/app/_components/Settings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -128,6 +128,8 @@ export const ContextMenu = () => {
|
||||
setMissionFormValues({
|
||||
...parsed,
|
||||
state: "draft",
|
||||
addressLat: contextMenu.lat,
|
||||
addressLng: contextMenu.lng,
|
||||
addressOSMways: [closestToContext],
|
||||
});
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user