diff --git a/apps/scouting/frontend/package.json b/apps/scouting/frontend/package.json index b129e6e..afe2ca9 100644 --- a/apps/scouting/frontend/package.json +++ b/apps/scouting/frontend/package.json @@ -16,11 +16,11 @@ "dependencies": { "@tailwindcss/vite": "^4.1.16", "heatmap.js": "^2.0.5", + "http-proxy-middleware": "^3.0.5", "react": "^19.1.1", "react-dom": "^19.1.1", "tailwindcss": "^4.1.16", - "tsx": "^4.21.0", - "http-proxy-middleware": "^3.0.5" + "tsx": "^4.21.0" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/apps/scouting/frontend/src/scouter/components/startFeedback.ts b/apps/scouting/frontend/src/scouter/components/startFeedback.ts new file mode 100644 index 0000000..ef50047 --- /dev/null +++ b/apps/scouting/frontend/src/scouter/components/startFeedback.ts @@ -0,0 +1,81 @@ +/** + * Browser feedback when a stopwatch cycle starts. + * Uses vibration when available and always attempts a short click sound. + */ + +type AudioContextedWindow = Window & { + AudioContext?: typeof AudioContext; + webkitAudioContext?: typeof AudioContext; +}; + +const VIBRATE_PULSE_MS = 120; +const VIBRATE_PAUSE_MS = 40; + +const CLICK_FREQUENCY_HZ = 520; +const CLICK_GAIN_PEAK = 0.12; +const CLICK_GAIN_FLOOR = 0.001; +const CLICK_DURATION_S = 0.04; + +const getAudioContext = (() => { + let ctx: AudioContext | null = null; + return (): AudioContext | null => { + if (typeof window === "undefined") return null; + const audioWindow = window as AudioContextedWindow; + const Ctx = audioWindow.AudioContext ?? audioWindow.webkitAudioContext; + if (!Ctx) return null; + if (!ctx) { + ctx = new Ctx(); + } + return ctx; + }; +})(); + +const playTouchSound = ( + osc: OscillatorNode, + gain: GainNode, + ctx: AudioContext, +) => { + osc.connect(gain); + gain.connect(ctx.destination); + osc.frequency.value = CLICK_FREQUENCY_HZ; + osc.type = "sine"; + gain.gain.setValueAtTime(CLICK_GAIN_PEAK, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime( + CLICK_GAIN_FLOOR, + ctx.currentTime + CLICK_DURATION_S, + ); + osc.start(ctx.currentTime); + osc.stop(ctx.currentTime + CLICK_DURATION_S); +}; + +const playClick = (): void => { + try { + const ctx = getAudioContext(); + if (!ctx) return; + const play = () => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + playTouchSound(osc, gain, ctx); + }; + if (ctx.state !== "suspended") { + play(); + return; + } + ctx.resume().then(play); + } catch { + /* ignore */ + } +}; + +export function playStartFeedback(): void { + if (typeof window === "undefined") return; + + try { + if (typeof navigator.vibrate === "function") { + navigator.vibrate([VIBRATE_PULSE_MS, VIBRATE_PAUSE_MS, VIBRATE_PULSE_MS]); + } + } catch { + /* ignore */ + } + playClick(); +} diff --git a/apps/scouting/frontend/src/scouter/components/stopwatch.tsx b/apps/scouting/frontend/src/scouter/components/stopwatch.tsx index b6bb534..9bc212e 100644 --- a/apps/scouting/frontend/src/scouter/components/stopwatch.tsx +++ b/apps/scouting/frontend/src/scouter/components/stopwatch.tsx @@ -2,6 +2,7 @@ import type React from "react"; import { useEffect, useRef, useState, type Dispatch } from "react"; import type { Interval } from "@repo/scouting_types"; +import { playStartFeedback } from "./startFeedback"; const MILLLISECONDS_IN_A_SECOND = 1000; const SECOND_IN_A_MINUTE = 60; @@ -31,14 +32,8 @@ const Stopwatch: React.FC = ({ const [elapsedTime, setElapsedTime] = useState(INITIAL_TIME_MILLISECONDS); const startTimeRef = useRef(INITIAL_TIME_MILLISECONDS); - const startCurrentCycleTime = useRef(INITIAL_TIME_MILLISECONDS); - const reset = () => { - setElapsedTime(INITIAL_TIME_MILLISECONDS); - setIsRunning(false); - }; - const calculateSeconds = () => { return Math.floor( (elapsedTime / MILLLISECONDS_IN_A_SECOND) % SECOND_IN_A_MINUTE, @@ -70,33 +65,34 @@ const Stopwatch: React.FC = ({ setElapsedTime(Date.now() - startTimeRef.current); }, CYCLE_TIME_MILLISECONDS); - return () => { - clearInterval(intervalId); - }; + return () => clearInterval(intervalId); }, [isRunning]); const start = () => { if (isRunning || disabled) { return; } + playStartFeedback(); const relativeTime = getCurrentRelativeTime(); startCurrentCycleTime.current = relativeTime; - startTimeRef.current = Date.now() - elapsedTime; setIsRunning(true); onStart?.(); }; + const reset = () => { + setElapsedTime(INITIAL_TIME_MILLISECONDS); + setIsRunning(false); + }; + const stop = () => { if (!isRunning) { return; } - const cycleStopwatchCounter: Interval = { start: startCurrentCycleTime.current, end: getCurrentRelativeTime(), }; - addCycleTimeSeconds(cycleStopwatchCounter); setIsRunning(false); @@ -114,7 +110,7 @@ const Stopwatch: React.FC = ({ >