// Horn Control // Purpose: Defines the Horn Control module and the local helpers/components used in this file. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSettingsNamespace } from '../../settings/index.js'; import { HORN_SETTINGS_DEFAULTS } from '../../settings/namespaces.js'; import { HORN_MAX_FREQUENCY } from '../../controls/constants.js'; function clampFreq(value) { const num = Number(value); if (!Number.isFinite(num)) return 0; if (num <= 0) return 0; return Math.min(HORN_MAX_FREQUENCY, Math.round(num)); } export default function HornControl({ disabled, onStart, onStop, keyLabel, active, heat = 0, className = '', defaultShowSettings = true, showSettingsToggle = false, compactSettings = false, }) { const [pressed, setPressed] = useState(false); const [showSettings, setShowSettings] = useState(defaultShowSettings); const { value: hornSettings, save: saveHornSettings } = useSettingsNamespace( 'horn', HORN_SETTINGS_DEFAULTS, ); const [waveform, setWaveform] = useState(hornSettings?.waveform || HORN_SETTINGS_DEFAULTS.waveform); const settingsRootRef = useRef(null); const [freqs, setFreqs] = useState(() => { const base = Array.isArray(hornSettings?.freqs) ? hornSettings.freqs : HORN_SETTINGS_DEFAULTS.freqs; return [...base, 0, 0, 0, 0].slice(0, 4).map((f) => clampFreq(f)); }); useEffect(() => { setWaveform(hornSettings?.waveform || HORN_SETTINGS_DEFAULTS.waveform); if (Array.isArray(hornSettings?.freqs)) { setFreqs([...hornSettings.freqs, 0, 0, 0, 0].slice(0, 4).map((f) => clampFreq(f))); } }, [hornSettings?.freqs, hornSettings?.waveform]); const isActive = Boolean(active); const clampedHeat = Math.max(0, Math.min(1, Number(heat) || 0)); const buttonClasses = useMemo(() => { // The horn can be held for continuous input, which makes it especially prone // to long-press selection/callout behavior on mobile browsers. const base = 'mobile-touch-control group relative flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-1 py-1.5 text-xs font-semibold select-none no-touch-select'; const active = 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50'; const inactive = 'border-cyan-300/70 bg-cyan-900 text-cyan-50 hover:bg-cyan-800'; return [base, isActive ? active : inactive, 'disabled:opacity-50', className] .filter(Boolean) .join(' '); }, [className, isActive]); const start = () => { if (disabled) return; if (!pressed) { setPressed(true); onStart?.(); } }; const stop = () => { if (pressed) { setPressed(false); onStop?.(); } }; useEffect(() => { if (!isActive && pressed) { setPressed(false); } }, [isActive, pressed]); const formattedWaveform = waveform === 'sine' ? 'sine' : 'saw'; const updateWaveform = useCallback( (event) => { const next = event.target.value === 'sine' ? 'sine' : 'saw'; setWaveform(next); saveHornSettings((current) => ({ ...(current ?? {}), waveform: next })); }, [saveHornSettings], ); const updateFreq = useCallback( (index, value) => { setFreqs((prev) => { const next = [...prev]; next[index] = clampFreq(value); saveHornSettings((current) => ({ ...(current ?? {}), freqs: next })); return next; }); }, [saveHornSettings], ); useEffect(() => { const root = settingsRootRef.current; if (!root) return undefined; const handleWheel = (event) => { const target = event.target; if (!(target instanceof Element)) return; const input = target.closest('input[data-horn-freq-index]'); if (!input) return; const index = Number(input.dataset.hornFreqIndex); if (!Number.isInteger(index) || index < 0 || index >= freqs.length) return; event.preventDefault(); event.stopPropagation(); const direction = event.deltaY < 0 ? 1 : -1; const current = Number(freqs[index]) || 0; updateFreq(index, current + direction * 25); }; root.addEventListener('wheel', handleWheel, { passive: false, capture: true }); return () => { root.removeEventListener('wheel', handleWheel, { capture: true }); }; }, [freqs, updateFreq]); const handlePointerDown = (event) => { if (disabled) return; const tag = event.target?.tagName?.toLowerCase(); if (tag === 'input' || tag === 'select' || tag === 'option' || tag === 'label' || tag === 'button') return; event.preventDefault(); start(); }; const handlePointerUp = (event) => { const tag = event.target?.tagName?.toLowerCase(); if (tag === 'input' || tag === 'select' || tag === 'option' || tag === 'label' || tag === 'button') return; event.preventDefault(); stop(); }; const settingsToggle = showSettingsToggle ? ( ) : null; return (
event.preventDefault()} onBlur={stop} className={buttonClasses} >
Horn {keyLabel ? ( {keyLabel} ) : null} {settingsToggle} {isActive ? 'Honk' : 'Hold'}
{showSettings ? ( compactSettings ? (
{freqs.map((freq, idx) => ( ))}
) : (
{freqs.map((freq, idx) => ( ))}
) ) : null}
); }