diff --git a/src/App.tsx b/src/App.tsx index 67ea871..3d9f929 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,25 +8,28 @@ import Session from "./pages/Session"; import { NPEDUserProvider } from "./context/providers/NPEDUserContextProvider"; import { AlertHitProvider } from "./context/providers/AlertHitProvider"; import { SoundProvider } from "react-sounds"; +import SoundContextProvider from "./context/providers/SoundContextProvider"; function App() { return ( - - - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - + + + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + ); } diff --git a/src/assets/sounds/ui/notification.mp3 b/src/assets/sounds/ui/notification.mp3 new file mode 100644 index 0000000..6fa81e8 Binary files /dev/null and b/src/assets/sounds/ui/notification.mp3 differ diff --git a/src/components/SettingForms/Sound/SoundSettingsCard.tsx b/src/components/SettingForms/Sound/SoundSettingsCard.tsx new file mode 100644 index 0000000..e2b904c --- /dev/null +++ b/src/components/SettingForms/Sound/SoundSettingsCard.tsx @@ -0,0 +1,14 @@ +import Card from "../../UI/Card"; +import CardHeader from "../../UI/CardHeader"; +import SoundSettingsFields from "./SoundSettingsFields"; + +const SoundSettingsCard = () => { + return ( + + + + + ); +}; + +export default SoundSettingsCard; diff --git a/src/components/SettingForms/Sound/SoundSettingsFields.tsx b/src/components/SettingForms/Sound/SoundSettingsFields.tsx new file mode 100644 index 0000000..af5e8f1 --- /dev/null +++ b/src/components/SettingForms/Sound/SoundSettingsFields.tsx @@ -0,0 +1,117 @@ +import { Field, FieldArray, Form, Formik } from "formik"; +import FormGroup from "../components/FormGroup"; +import type { FormValues, Hotlist } from "../../../types/types"; +import { useSoundContext } from "../../../context/SoundContext"; +import { toast } from "sonner"; + +const SoundSettingsFields = () => { + const { state, dispatch } = useSoundContext(); + const hotlists: Hotlist[] = [ + { name: "hotlist0", sound: "" }, + { name: "hotlist1", sound: "" }, + { name: "hotlist2", sound: "" }, + ]; + + const soundOptions = state?.soundOptions?.map((soundOption) => ({ + value: soundOption?.name, + label: soundOption?.name, + })); + + const initialValues: FormValues = { + sightingSound: state.sightingSound ?? "switch", + NPEDsound: state.NPEDsound ?? "popup", + hotlists, + }; + + const handleSubmit = (values: FormValues) => { + dispatch({ type: "UPDATE", payload: values }); + toast.success("Sound settings updated"); + }; + return ( + + {({ values }) => ( + + + Sighting Sound + + {soundOptions?.map(({ value, label }) => { + return ( + + {label} + + ); + })} + + + + NPED notification Sound + + {soundOptions?.map(({ value, label }) => ( + + {label} + + ))} + + + + Hotlist Sounds + + ( + + {values.hotlists.length > 0 ? ( + values.hotlists.map((hotlist, index) => ( + + + {hotlist.name} + + + {soundOptions?.map(({ value, label }) => ( + + {label} + + ))} + + + )) + ) : ( + No hotlists yet, Add one + )} + + )} + /> + + + + Save Settings + + + )} + + ); +}; + +export default SoundSettingsFields; diff --git a/src/components/SettingForms/Sound/SoundUpload.tsx b/src/components/SettingForms/Sound/SoundUpload.tsx new file mode 100644 index 0000000..4326746 --- /dev/null +++ b/src/components/SettingForms/Sound/SoundUpload.tsx @@ -0,0 +1,68 @@ +import { Form, Formik } from "formik"; +import FormGroup from "../components/FormGroup"; +import type { SoundUploadValue } from "../../../types/types"; +import { useSoundContext } from "../../../context/SoundContext"; +import { toast } from "sonner"; + +const SoundUpload = () => { + const { dispatch } = useSoundContext(); + const initialValues: SoundUploadValue = { + name: "", + soundFile: null, + }; + + const handleSubmit = (values: SoundUploadValue) => { + if (!values.soundFile) { + toast.warning("Please select an audio file"); + } else { + dispatch({ type: "ADD", payload: values }); + toast.success("Sound file upload successfully"); + } + }; + + return ( + + {({ setFieldValue, errors, setFieldError }) => ( + + + Sound File + { + if ( + e.target?.files && + e.target?.files[0]?.type === "audio/mpeg" + ) { + setFieldValue("name", e.target.files[0].name); + setFieldValue("soundFile", e.target.files[0]); + } else { + setFieldError("soundFile", "Not an mp3 file"); + toast.error("Not an mp3 file"); + } + }} + /> + + {errors.soundFile && ( + Not an mp3 file + )} + + Upload + + + )} + + ); +}; + +export default SoundUpload; diff --git a/src/components/SettingForms/Sound/SoundUploadCard.tsx b/src/components/SettingForms/Sound/SoundUploadCard.tsx new file mode 100644 index 0000000..e751959 --- /dev/null +++ b/src/components/SettingForms/Sound/SoundUploadCard.tsx @@ -0,0 +1,14 @@ +import Card from "../../UI/Card"; +import CardHeader from "../../UI/CardHeader"; +import SoundUpload from "./SoundUpload"; + +const SoundUploadCard = () => { + return ( + + + + + ); +}; + +export default SoundUploadCard; diff --git a/src/components/SightingsWidget/SightingWidget.tsx b/src/components/SightingsWidget/SightingWidget.tsx index 0fd8c81..ee5936d 100644 --- a/src/components/SightingsWidget/SightingWidget.tsx +++ b/src/components/SightingsWidget/SightingWidget.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { SightingType } from "../../types/types"; -import { BLANK_IMG } from "../../utils/utils"; +import { BLANK_IMG, getSoundFileURL } from "../../utils/utils"; import NumberPlate from "../PlateStack/NumberPlate"; import Card from "../UI/Card"; import CardHeader from "../UI/CardHeader"; @@ -15,6 +15,7 @@ import NPED_CAT_C from "/NPED_Cat_C.svg"; import popup from "../../assets/sounds/ui/popup_open.mp3"; import { useSound } from "react-sounds"; import { useNPEDContext } from "../../context/NPEDUserContext"; +import { useSoundContext } from "../../context/SoundContext"; function useNow(tickMs = 1000) { const [, setNow] = useState(() => Date.now()); @@ -39,7 +40,13 @@ export default function SightingHistoryWidget({ title, }: SightingHistoryProps) { useNow(1000); - const { play } = useSound(popup); + const { state } = useSoundContext(); + + const soundSrc = useMemo(() => { + return getSoundFileURL(state.NPEDsound) ?? popup; + }, [state.NPEDsound]); + + const { play } = useSound(soundSrc); const { sightings, setSelectedSighting, diff --git a/src/components/UI/NavigationArrow.tsx b/src/components/UI/NavigationArrow.tsx index fd59956..f7b9935 100644 --- a/src/components/UI/NavigationArrow.tsx +++ b/src/components/UI/NavigationArrow.tsx @@ -9,6 +9,7 @@ type NavigationArrowProps = { const NavigationArrow = ({ side, settingsPage }: NavigationArrowProps) => { const navigate = useNavigate(); + const navigationDest = (side: string | undefined) => { if (settingsPage) { navigate("/"); diff --git a/src/context/SoundContext.ts b/src/context/SoundContext.ts new file mode 100644 index 0000000..d115d42 --- /dev/null +++ b/src/context/SoundContext.ts @@ -0,0 +1,18 @@ +import { createContext, useContext, type Dispatch } from "react"; +import type { SoundAction, SoundState } from "../types/types"; + +type SoundContextType = { + state: SoundState; + dispatch: Dispatch; +}; + +export const SoundContext = createContext( + undefined +); + +export const useSoundContext = () => { + const ctx = useContext(SoundContext); + if (!ctx) + throw new Error("useSoundContext must be used within "); + return ctx; +}; diff --git a/src/context/providers/SoundContextProvider.tsx b/src/context/providers/SoundContextProvider.tsx new file mode 100644 index 0000000..20211df --- /dev/null +++ b/src/context/providers/SoundContextProvider.tsx @@ -0,0 +1,17 @@ +import { useMemo, useReducer, type ReactNode } from "react"; +import { SoundContext } from "../SoundContext"; +import { initialState, reducer } from "../reducers/SoundContextReducer"; + +type SoundContextProviderProps = { + children: ReactNode; +}; + +const SoundContextProvider = ({ children }: SoundContextProviderProps) => { + const [state, dispatch] = useReducer(reducer, initialState); + const value = useMemo(() => ({ state, dispatch }), [state, dispatch]); + return ( + {children} + ); +}; + +export default SoundContextProvider; diff --git a/src/context/reducers/SoundContextReducer.ts b/src/context/reducers/SoundContextReducer.ts new file mode 100644 index 0000000..d5b4b2c --- /dev/null +++ b/src/context/reducers/SoundContextReducer.ts @@ -0,0 +1,38 @@ +import type { SoundAction, SoundState } from "../../types/types"; + +export const initialState: SoundState = { + sightingSound: "switch", + NPEDsound: "popup", + hotlists: [], + soundOptions: [ + { name: "switch (Default)", soundFile: null }, + { name: "popup", soundFile: null }, + { name: "notification", soundFile: null }, + ], +}; + +export function reducer(state: SoundState, action: SoundAction): SoundState { + switch (action.type) { + case "UPDATE": { + return { + ...state, + sightingSound: action.payload.sightingSound, + NPEDsound: action.payload.NPEDsound, + hotlists: action.payload.hotlists.map((hotlist) => ({ + name: hotlist.name, + sound: hotlist.sound, + })), + }; + } + + case "ADD": { + return { + ...state, + soundOptions: [...(state.soundOptions ?? []), action.payload], + }; + } + + default: + return state; + } +} diff --git a/src/hooks/useSightingFeed.ts b/src/hooks/useSightingFeed.ts index e880f07..8f742ff 100644 --- a/src/hooks/useSightingFeed.ts +++ b/src/hooks/useSightingFeed.ts @@ -1,7 +1,9 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import type { SightingType } from "../types/types"; import { useSoundOnChange } from "react-sounds"; +import { useSoundContext } from "../context/SoundContext"; +import { getSoundFileURL } from "../utils/utils"; import switchSound from "../assets/sounds/ui/switch.mp3"; async function fetchSighting( @@ -14,6 +16,7 @@ async function fetchSighting( } export function useSightingFeed(url: string | undefined) { + const { state } = useSoundContext(); const [sightings, setSightings] = useState([]); const [selectedRef, setSelectedRef] = useState(null); const [sessionStarted, setSessionStarted] = useState(false); @@ -23,8 +26,22 @@ export function useSightingFeed(url: string | undefined) { const [selectedSighting, setSelectedSighting] = useState( null ); + const first = useRef(true); - useSoundOnChange(switchSound, latestRef, { + const trigger = useMemo(() => { + if (latestRef == null) return null; + if (first.current) { + first.current = false; + return Symbol("skip"); + } + return latestRef; + }, [latestRef]); + const soundSrc = useMemo(() => { + return getSoundFileURL(state.sightingSound) ?? switchSound; + }, [state.sightingSound]); + + //use latestref instead of trigger to revert back + useSoundOnChange(soundSrc, trigger, { volume: 1, }); diff --git a/src/hooks/useSound.ts b/src/hooks/useSound.ts index 21f3455..e69de29 100644 --- a/src/hooks/useSound.ts +++ b/src/hooks/useSound.ts @@ -1,64 +0,0 @@ -// useBeep.ts -import { useEffect, useRef } from "react"; -import { useSoundEnabled } from "react-sounds"; // so it respects your SoundBtn toggle - -/** - * Plays a sound whenever `latestRef` changes. - * - * @param src Path to the sound file - * @param latestRef The primitive value to watch (e.g. sighting.ref) - * @param opts volume: 0..1, enabledOverride: force enable/disable, minGapMs: throttle interval - */ -export function useBeep( - src: string, - latestRef: number | null, - opts?: { volume?: number; enabledOverride?: boolean; minGapMs?: number } -) { - const audioRef = useRef(undefined); - const prevRef = useRef(null); - const lastPlay = useRef(0); - const [enabled] = useSoundEnabled(); - - const minGap = opts?.minGapMs ?? 250; // don’t play more than 4 times/sec - - // Create the audio element once - useEffect(() => { - const a = new Audio(src); - a.preload = "auto"; - if (opts?.volume !== undefined) a.volume = opts.volume; - audioRef.current = a; - return () => { - a.pause(); - }; - }, [src, opts?.volume]); - - // Watch for ref changes - useEffect(() => { - if (latestRef == null) return; - - const canPlay = - (opts?.enabledOverride ?? enabled) && - document.visibilityState === "visible"; - if (!canPlay) { - prevRef.current = latestRef; // consume the change - return; - } - - if (prevRef.current !== null && latestRef !== prevRef.current) { - const now = Date.now(); - if (now - lastPlay.current >= minGap) { - const a = audioRef.current; - if (a) { - try { - a.currentTime = 0; // restart from beginning - void a.play(); // fire and forget - lastPlay.current = now; - } catch (err) { - console.warn("Audio play failed:", err); - } - } - } - } - prevRef.current = latestRef; - }, [latestRef, enabled, opts?.enabledOverride, minGap]); -} diff --git a/src/pages/SystemSettings.tsx b/src/pages/SystemSettings.tsx index ec6d4bd..138070c 100644 --- a/src/pages/SystemSettings.tsx +++ b/src/pages/SystemSettings.tsx @@ -8,6 +8,8 @@ import ModemCard from "../components/SettingForms/WiFi&Modem/ModemCard"; import SystemCard from "../components/SettingForms/System/SystemCard"; import { Toaster } from "sonner"; import { useNPEDAuth } from "../hooks/useNPEDAuth"; +import SoundSettingsCard from "../components/SettingForms/Sound/SoundSettingsCard"; +import SoundUploadCard from "../components/SettingForms/Sound/SoundUploadCard"; const SystemSettings = () => { useNPEDAuth(); @@ -20,6 +22,7 @@ const SystemSettings = () => { Output Integrations WiFi and Modem + Sound @@ -43,6 +46,12 @@ const SystemSettings = () => { + + + + + + diff --git a/src/types/types.ts b/src/types/types.ts index 234d015..bb1361e 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -262,6 +262,46 @@ export type ZoomLevel = { level?: number; }; +export type SoundValue = string; + +export type Hotlist = { + name: string; + sound: SoundValue; +}; + +export type FormValues = { + sightingSound: SoundValue; + NPEDsound: SoundValue; + hotlists: Hotlist[]; +}; + +export type SoundUploadValue = { + name: string; + soundFile: File | null; +}; + +export type SoundState = { + sightingSound: SoundValue; + NPEDsound: SoundValue; + hotlists: Hotlist[]; + soundOptions?: SoundUploadValue[]; +}; + +type UpdateAction = { + type: "UPDATE"; + payload: { + sightingSound: SoundValue; + NPEDsound: SoundValue; + hotlists: Hotlist[]; + }; +}; + +type AddAction = { + type: "ADD"; + payload: SoundUploadValue; +}; + +export type SoundAction = UpdateAction | AddAction; export type WifiSettingValues = { ssid: string; password: string; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index c3e7155..ba8ea90 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,3 +1,16 @@ +import switchSound from "../assets/sounds/ui/switch.mp3"; +import popup from "../assets/sounds/ui/popup_open.mp3"; +import notification from "../assets/sounds/ui/notification.mp3"; + +export function getSoundFileURL(name: string) { + const sounds: Record = { + switch: switchSound, + popup: popup, + notification: notification, + }; + return sounds[name] ?? null; +} + const randomChars = () => { const uppercaseAsciiStart = 65; const letterIndex = Math.floor(Math.random() * 26);
No hotlists yet, Add one
Not an mp3 file