Compare commits
16 Commits
bugfix/bea
...
bugfix/upl
| Author | SHA1 | Date | |
|---|---|---|---|
| 630261ac21 | |||
| c948192f10 | |||
| af1dabc8fc | |||
| a839502421 | |||
| 39629897d4 | |||
| a8abed2246 | |||
| cf72a1e1d3 | |||
| c8eed55801 | |||
| 907555cb0d | |||
| a64fa76ecb | |||
| 93dcde4459 | |||
| 350d7cf41c | |||
| 78e5da45ca | |||
| f264f4e808 | |||
| 0c6e4b57be | |||
| 4519700561 |
@@ -32,7 +32,6 @@ const SoundSettingsFields = () => {
|
||||
hotlistSoundVolume: state.hotlistSoundVolume,
|
||||
soundOptions: [...(state.soundOptions ?? [])],
|
||||
};
|
||||
|
||||
dispatch({ type: "UPDATE", payload: updatedValues });
|
||||
|
||||
const result = await mutation.mutateAsync({
|
||||
|
||||
@@ -4,16 +4,21 @@ import type { SoundUploadValue } from "../../../types/types";
|
||||
import { useSoundContext } from "../../../context/SoundContext";
|
||||
import { toast } from "sonner";
|
||||
import { useCameraBlackboard } from "../../../hooks/useCameraBlackboard";
|
||||
import { useFileUpload } from "../../../hooks/useFileUpload";
|
||||
|
||||
const SoundUpload = () => {
|
||||
const { state, dispatch } = useSoundContext();
|
||||
const { mutation } = useCameraBlackboard();
|
||||
const { mutation: fileMutation } = useFileUpload({
|
||||
queryKey: state.sightingSound ? [state.sightingSound] : undefined,
|
||||
});
|
||||
|
||||
const initialValues: SoundUploadValue = {
|
||||
name: "",
|
||||
soundFile: null,
|
||||
soundFileName: "",
|
||||
soundUrl: "",
|
||||
uploadedAt: Date.now(),
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: SoundUploadValue) => {
|
||||
@@ -37,10 +42,9 @@ const SoundUpload = () => {
|
||||
path: "soundSettings",
|
||||
value: updatedValues,
|
||||
});
|
||||
await fileMutation.mutateAsync(values.soundFile);
|
||||
if (result.reason !== "OK") {
|
||||
toast.error("Cannot update sound settings");
|
||||
} else {
|
||||
toast.success(`${values.name} file added`);
|
||||
}
|
||||
|
||||
dispatch({ type: "ADD", payload: values });
|
||||
@@ -48,7 +52,7 @@ const SoundUpload = () => {
|
||||
|
||||
return (
|
||||
<Formik initialValues={initialValues} onSubmit={handleSubmit} enableReinitialize>
|
||||
{({ setFieldValue, errors, setFieldError, values }) => (
|
||||
{({ setFieldValue, errors, setFieldError }) => (
|
||||
<Form>
|
||||
<label htmlFor="soundFile" className="">
|
||||
Sound File
|
||||
@@ -67,6 +71,12 @@ const SoundUpload = () => {
|
||||
setFieldValue("name", e.target.files[0].name);
|
||||
setFieldValue("soundFileName", e.target.files[0].name);
|
||||
setFieldValue("soundFile", e.target.files[0]);
|
||||
setFieldValue("uploadedAt", Date.now());
|
||||
if (e?.target?.files[0]?.size >= 1 * 1024 * 1024) {
|
||||
setFieldError("soundFile", "larger than 1mb");
|
||||
toast.error("File larger than 1MB");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setFieldError("soundFile", "Not an mp3 file");
|
||||
toast.error("Not an mp3 file");
|
||||
@@ -76,11 +86,6 @@ const SoundUpload = () => {
|
||||
</FormGroup>
|
||||
|
||||
<div className="mt-4 flex flex-col items-center justify-center rounded-2xl border border-slate-800 bg-slate-900/40 p-10 text-center">
|
||||
{!values.soundFile && (
|
||||
<div className="mb-3 rounded-xl bg-slate-800 px-3 py-1 text-xs uppercase tracking-wider text-slate-400">
|
||||
No uploaded sound files
|
||||
</div>
|
||||
)}
|
||||
<p className="max-w-md text-slate-300">
|
||||
Uploaded Sound files will appear in the <span className="font-bold">drop downs</span> once they are
|
||||
uploaded. They can be used for any <span className="text-blue-400">Sighting,</span>{" "}
|
||||
|
||||
@@ -4,7 +4,7 @@ import SoundUpload from "./SoundUpload";
|
||||
|
||||
const SoundUploadCard = () => {
|
||||
return (
|
||||
<Card className="p-4 col-span-3 w-full">
|
||||
<Card className="p-4 col-span-5 lg:col-span-3 w-full">
|
||||
<CardHeader title={"Sound upload"} />
|
||||
<SoundUpload />
|
||||
</Card>
|
||||
|
||||
@@ -10,10 +10,7 @@ type BlobFileUpload = {
|
||||
};
|
||||
};
|
||||
|
||||
export async function sendBlobFileUpload({
|
||||
file,
|
||||
opts,
|
||||
}: BlobFileUpload): Promise<string> {
|
||||
export async function sendBlobFileUpload({ file, opts }: BlobFileUpload): Promise<string> {
|
||||
if (!file) throw new Error("No file supplied");
|
||||
if (!opts?.uploadUrl) throw new Error("No URL supplied");
|
||||
|
||||
@@ -42,9 +39,7 @@ export async function sendBlobFileUpload({
|
||||
const bodyText = await resp.text();
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(
|
||||
`Upload failed (${resp.status} ${resp.statusText}) from ${opts.uploadUrl} — ${bodyText}`
|
||||
);
|
||||
throw new Error(`Upload failed (${resp.status} ${resp.statusText}) from ${opts.uploadUrl} — ${bodyText}`);
|
||||
}
|
||||
|
||||
return bodyText;
|
||||
@@ -54,9 +49,7 @@ export async function sendBlobFileUpload({
|
||||
}
|
||||
// In browsers, fetch throws TypeError on network-level failures
|
||||
if (err instanceof TypeError) {
|
||||
throw new Error(
|
||||
`HTTP error uploading to ${opts.uploadUrl}: ${err.message}`
|
||||
);
|
||||
throw new Error(`HTTP error uploading to ${opts.uploadUrl}: ${err.message}`);
|
||||
}
|
||||
// Todo: fix error message response
|
||||
return `Hotlist Load OK`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { HitKind, QueuedHit, ReducedSightingType, SightingType } from "../../types/types";
|
||||
import { BLANK_IMG, getSoundFileURL } from "../../utils/utils";
|
||||
import { BLANK_IMG } from "../../utils/utils";
|
||||
import NumberPlate from "../PlateStack/NumberPlate";
|
||||
import Card from "../UI/Card";
|
||||
import CardHeader from "../UI/CardHeader";
|
||||
@@ -19,6 +19,7 @@ import { useIntegrationsContext } from "../../context/IntegrationsContext";
|
||||
import { useSoundContext } from "../../context/SoundContext";
|
||||
import Loading from "../UI/Loading";
|
||||
import { checkIsHotListHit, getNPEDCategory } from "../../utils/utils";
|
||||
import { useCachedSoundSrc } from "../../hooks/usecachedSoundSrc";
|
||||
|
||||
function useNow(tickMs = 1000) {
|
||||
const [, setNow] = useState(() => Date.now());
|
||||
@@ -43,21 +44,8 @@ export default function SightingHistoryWidget({ className, title }: SightingHist
|
||||
useNow(1000);
|
||||
const { state } = useSoundContext();
|
||||
|
||||
const soundSrcNped = useMemo(() => {
|
||||
if (state?.NPEDsound?.includes(".mp3") || state.NPEDsound?.includes(".wav")) {
|
||||
const file = state.soundOptions?.find((item) => item.name === state.NPEDsound);
|
||||
return file?.soundUrl ?? popup;
|
||||
}
|
||||
return getSoundFileURL(state.NPEDsound) ?? popup;
|
||||
}, [state.NPEDsound, state.soundOptions]);
|
||||
|
||||
const soundSrcHotlist = useMemo(() => {
|
||||
if (state?.hotlistSound?.includes(".mp3") || state.hotlistSound?.includes(".wav")) {
|
||||
const file = state.soundOptions?.find((item) => item.name === state.hotlistSound);
|
||||
return file?.soundUrl ?? notification;
|
||||
}
|
||||
return getSoundFileURL(state?.hotlistSound) ?? notification;
|
||||
}, [state.hotlistSound, state.soundOptions]);
|
||||
const { src: soundSrcHotlist } = useCachedSoundSrc(state?.hotlistSound, state?.soundOptions, notification);
|
||||
const { src: soundSrcNped } = useCachedSoundSrc(state?.NPEDsound, state?.soundOptions, popup);
|
||||
|
||||
const { play: npedSound } = useSound(soundSrcNped, { volume: state.NPEDsoundVolume });
|
||||
const { play: hotlistsound } = useSound(soundSrcHotlist, { volume: state.hotlistSoundVolume });
|
||||
@@ -181,8 +169,8 @@ export default function SightingHistoryWidget({ className, title }: SightingHist
|
||||
if (!isSightingModalOpen && modalQueue.length > 0) {
|
||||
const next = modalQueue[0];
|
||||
|
||||
// if (next.kind === "NPED") npedSound();
|
||||
// else hotlistsound();
|
||||
if (next.kind === "NPED") npedSound();
|
||||
else hotlistsound();
|
||||
|
||||
setSelectedSighting(next.sighting);
|
||||
setSightingModalOpen(true);
|
||||
|
||||
@@ -11,25 +11,18 @@ type CameraOverviewHeaderProps = {
|
||||
sighting?: SightingType | null;
|
||||
};
|
||||
|
||||
const CardHeader = ({
|
||||
title,
|
||||
icon,
|
||||
img,
|
||||
sighting,
|
||||
}: CameraOverviewHeaderProps) => {
|
||||
const CardHeader = ({ title, icon, img, sighting }: CameraOverviewHeaderProps) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"w-full border-b border-gray-600 flex flex-row items-center space-x-2 md:mb-6 relative justify-between"
|
||||
"w-full border-b border-gray-600 flex flex-row items-center space-x-2 mb-6 relative justify-between"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{icon && <FontAwesomeIcon icon={icon} className="size-4" />}
|
||||
<h2 className="text-xl">{title}</h2>
|
||||
</div>
|
||||
{img && (
|
||||
<img src={img} alt="Logo" width={100} height={50} className="ml-auto" />
|
||||
)}
|
||||
{img && <img src={img} alt="Logo" width={100} height={50} className="ml-auto" />}
|
||||
{sighting?.vrm && <NumberPlate vrm={sighting.vrm} motion={false} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,10 +13,12 @@ export const initialState: SoundState = {
|
||||
{ name: "Ding", soundFileName: "ding" },
|
||||
{ name: "Shutter", soundFileName: "shutter" },
|
||||
{ name: "Warning (voice)", soundFileName: "warning" },
|
||||
{ name: "Attention (voice)", soundFileName: "attention" },
|
||||
],
|
||||
sightingVolume: 1,
|
||||
NPEDsoundVolume: 1,
|
||||
hotlistSoundVolume: 1,
|
||||
uploadedSound: null,
|
||||
};
|
||||
|
||||
export function reducer(state: SoundState, action: SoundAction): SoundState {
|
||||
@@ -62,7 +64,11 @@ export function reducer(state: SoundState, action: SoundAction): SoundState {
|
||||
...state,
|
||||
hotlistSoundVolume: action.payload,
|
||||
};
|
||||
|
||||
case "UPLOADEDSOUND":
|
||||
return {
|
||||
...state,
|
||||
uploadedSound: action.payload,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
45
src/hooks/useFileUpload.ts
Normal file
45
src/hooks/useFileUpload.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CAM_BASE } from "../utils/config";
|
||||
import { toast } from "sonner";
|
||||
import { getOrCacheBlob } from "../utils/cacheSound";
|
||||
const camBase = import.meta.env.MODE !== "development" ? CAM_BASE : CAM_BASE;
|
||||
|
||||
type UseFileUploadProps = {
|
||||
queryKey?: string[];
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
const form = new FormData();
|
||||
form.append("upload", file, file.name);
|
||||
const response = await fetch(`${camBase}/upload/file-upload/3`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Cannot reach upload file endpoint");
|
||||
}
|
||||
return response.text();
|
||||
};
|
||||
|
||||
const getUploadFiles = async ({ queryKey }: { queryKey: string[] }) => {
|
||||
const [, fileName] = queryKey;
|
||||
const url = fileName;
|
||||
return getOrCacheBlob(url);
|
||||
};
|
||||
|
||||
export const useFileUpload = ({ queryKey }: UseFileUploadProps) => {
|
||||
const query = useQuery({
|
||||
queryKey: ["getUploadFiles", ...(queryKey ?? [])],
|
||||
queryFn: () => getUploadFiles({ queryKey: ["getUploadFiles", ...(queryKey ?? [])] }),
|
||||
enabled: !!queryKey,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (file: File) => uploadFile(file),
|
||||
mutationKey: ["uploadFile"],
|
||||
onError: (err) => toast.error(err ? err.message : ""),
|
||||
onSuccess: async (msg) => toast.success(msg),
|
||||
});
|
||||
|
||||
return { query: queryKey ? query : undefined, mutation };
|
||||
};
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { Query, useQuery } from "@tanstack/react-query";
|
||||
import type { SightingType } from "../types/types";
|
||||
import { useSound } from "react-sounds";
|
||||
import { useSoundContext } from "../context/SoundContext";
|
||||
import { checkIsHotListHit, getNPEDCategory, getSoundFileURL } from "../utils/utils";
|
||||
import { checkIsHotListHit, getNPEDCategory } from "../utils/utils";
|
||||
import switchSound from "../assets/sounds/ui/switch.mp3";
|
||||
import notification from "../assets/sounds/ui/notification.mp3";
|
||||
import popup from "../assets/sounds/ui/popup_open.mp3";
|
||||
import { useCachedSoundSrc } from "./usecachedSoundSrc";
|
||||
|
||||
async function fetchSighting(url: string | undefined, ref: number): Promise<SightingType> {
|
||||
const res = await fetch(`${url}${ref}`, {
|
||||
@@ -24,29 +25,9 @@ export function useSightingFeed(url: string | undefined) {
|
||||
const [sessionStarted, setSessionStarted] = useState(false);
|
||||
const [selectedSighting, setSelectedSighting] = useState<SightingType | null>(null);
|
||||
|
||||
const soundSrcHotlist = useMemo(() => {
|
||||
if (state?.hotlistSound?.includes(".mp3") || state.hotlistSound?.includes(".wav")) {
|
||||
const file = state.soundOptions?.find((item) => item.name === state.hotlistSound);
|
||||
return file?.soundUrl ?? notification;
|
||||
}
|
||||
return getSoundFileURL(state?.hotlistSound) ?? notification;
|
||||
}, [state.hotlistSound, state.soundOptions]);
|
||||
|
||||
const soundSrcNped = useMemo(() => {
|
||||
if (state?.NPEDsound?.includes(".mp3") || state.NPEDsound?.includes(".wav")) {
|
||||
const file = state.soundOptions?.find((item) => item.name === state.NPEDsound);
|
||||
return file?.soundUrl ?? popup;
|
||||
}
|
||||
return getSoundFileURL(state.NPEDsound) ?? popup;
|
||||
}, [state.NPEDsound, state.soundOptions]);
|
||||
|
||||
const soundSrc = useMemo(() => {
|
||||
if (state?.sightingSound?.includes(".mp3") || state.sightingSound?.includes(".wav")) {
|
||||
const file = state.soundOptions?.find((item) => item.name === state.sightingSound);
|
||||
return file?.soundUrl ?? switchSound;
|
||||
}
|
||||
return getSoundFileURL(state?.sightingSound) ?? switchSound;
|
||||
}, [state.sightingSound, state.soundOptions]);
|
||||
const { src: soundSrc } = useCachedSoundSrc(state?.sightingSound, state?.soundOptions, switchSound);
|
||||
const { src: soundSrcHotlist } = useCachedSoundSrc(state?.hotlistSound, state?.soundOptions, notification);
|
||||
const { src: soundSrcNped } = useCachedSoundSrc(state?.NPEDsound, state?.soundOptions, popup);
|
||||
|
||||
const { play: hotlistsound } = useSound(soundSrcHotlist, { volume: state.hotlistSoundVolume });
|
||||
const { play: npedSound } = useSound(soundSrcNped, { volume: state.NPEDsoundVolume });
|
||||
|
||||
56
src/hooks/usecachedSoundSrc.ts
Normal file
56
src/hooks/usecachedSoundSrc.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useFileUpload } from "./useFileUpload";
|
||||
import { getSoundFileURL } from "../utils/utils";
|
||||
import type { SoundUploadValue } from "../types/types";
|
||||
import { resolveSoundSource } from "../utils/soundResolver";
|
||||
|
||||
export function useCachedSoundSrc(
|
||||
selected: string | undefined,
|
||||
soundOptions: SoundUploadValue[] | undefined,
|
||||
fallbackUrl: string
|
||||
) {
|
||||
const isUploaded = !!selected && (selected.endsWith(".mp3") || selected.endsWith(".wav"));
|
||||
|
||||
const resolved = resolveSoundSource(selected, soundOptions);
|
||||
|
||||
const { query } = useFileUpload({
|
||||
queryKey: resolved?.type === "uploaded" ? [resolved?.url] : undefined,
|
||||
});
|
||||
|
||||
const [objectUrl, setObjectUrl] = useState<string>();
|
||||
const objRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const blob = query?.data;
|
||||
|
||||
if (blob instanceof Blob) {
|
||||
if (objRef.current) URL.revokeObjectURL(objRef.current);
|
||||
const url = URL.createObjectURL(blob);
|
||||
objRef.current = url;
|
||||
setObjectUrl(url);
|
||||
} else {
|
||||
if (objRef.current) URL.revokeObjectURL(objRef.current);
|
||||
objRef.current = null;
|
||||
setObjectUrl(undefined);
|
||||
}
|
||||
}, [query?.data]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (objRef.current) URL.revokeObjectURL(objRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const src = useMemo(() => {
|
||||
if (isUploaded && objectUrl) return objectUrl;
|
||||
if (!selected) return fallbackUrl;
|
||||
return getSoundFileURL(selected) ?? fallbackUrl;
|
||||
}, [isUploaded, objectUrl, selected, fallbackUrl]);
|
||||
|
||||
return {
|
||||
src,
|
||||
isUploaded,
|
||||
isLoading: !!query?.isLoading,
|
||||
error: (query?.error as Error) || undefined,
|
||||
};
|
||||
}
|
||||
@@ -302,6 +302,7 @@ export type SoundUploadValue = {
|
||||
soundFileName?: string;
|
||||
soundFile?: File | null;
|
||||
soundUrl?: string;
|
||||
uploadedAt?: number;
|
||||
};
|
||||
|
||||
export type SoundState = {
|
||||
@@ -313,6 +314,7 @@ export type SoundState = {
|
||||
sightingVolume: number;
|
||||
NPEDsoundVolume: number;
|
||||
hotlistSoundVolume: number;
|
||||
uploadedSound?: Blob | null;
|
||||
};
|
||||
|
||||
type UpdateAction = {
|
||||
@@ -339,7 +341,12 @@ type VolumeAction = {
|
||||
payload: number;
|
||||
};
|
||||
|
||||
export type SoundAction = UpdateAction | AddAction | VolumeAction;
|
||||
type UploadedState = {
|
||||
type: "UPLOADEDSOUND";
|
||||
payload: Blob | undefined;
|
||||
};
|
||||
|
||||
export type SoundAction = UpdateAction | AddAction | VolumeAction | UploadedState;
|
||||
export type WifiSettingValues = {
|
||||
ssid: string;
|
||||
password: string;
|
||||
|
||||
16
src/utils/cacheSound.ts
Normal file
16
src/utils/cacheSound.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export async function getOrCacheBlob(url: string) {
|
||||
const cache = await caches.open("app-sounds-v1");
|
||||
const hit = await cache.match(url);
|
||||
if (hit) return await hit.blob();
|
||||
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
|
||||
|
||||
await cache.put(url, res.clone());
|
||||
return await res.blob();
|
||||
}
|
||||
|
||||
export async function evictFromCache(url: string) {
|
||||
const cache = await caches.open("app-sounds-v1");
|
||||
await cache.delete(url);
|
||||
}
|
||||
24
src/utils/soundResolver.ts
Normal file
24
src/utils/soundResolver.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { getSoundFileURL } from "./utils";
|
||||
import { CAM_BASE } from "./config";
|
||||
import type { SoundUploadValue } from "../types/types";
|
||||
|
||||
export function resolveSoundSource(
|
||||
selected: string | undefined,
|
||||
soundOptions: SoundUploadValue[] | undefined
|
||||
): { type: "uploaded"; url: string } | { type: "builtin"; url: string } | undefined {
|
||||
if (!selected) return undefined;
|
||||
|
||||
const isFile = selected.endsWith(".mp3") || selected.endsWith(".wav");
|
||||
|
||||
if (isFile) {
|
||||
const match = soundOptions?.find((o) => o.soundFileName === selected);
|
||||
const version = match?.uploadedAt ?? 0;
|
||||
const url = `${CAM_BASE}/Mobile/${encodeURIComponent(selected)}?v=${version}`;
|
||||
return { type: "uploaded", url };
|
||||
}
|
||||
|
||||
const builtin = getSoundFileURL(selected);
|
||||
if (builtin) return { type: "builtin", url: builtin };
|
||||
|
||||
return undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user