// 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 '../MobileControls/mobileControls.css'; import { useSettingsNamespace } from '../../settings/index.js'; import { HORN_SETTINGS_DEFAULTS } from '../../settings/namespaces.js'; import { HORN_MAX_FREQUENCY } from '../../controls/constants.js'; import { triggerTouchHaptic } from '../../lib/touchHaptics.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 [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 activePointerIdRef = 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 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; if (activePointerIdRef.current !== null) return; const tag = event.target?.tagName?.toLowerCase(); if (tag === 'input' || tag === 'select' || tag === 'option' || tag === 'label' || tag === 'button') return; /* Horn is a hold control, so it should claim exactly the finger that started it. Capturing that pointer prevents tiny mobile finger drift or nearby controls from making the horn feel delayed or randomly interrupted. */ event.preventDefault(); activePointerIdRef.current = event.pointerId; event.currentTarget.setPointerCapture?.(event.pointerId); // Starting the held horn is the meaningful action; release only stops the // same action and therefore should not generate a second confirmation. triggerTouchHaptic('button'); onStart?.(); }; const handlePointerUp = (event) => { if (activePointerIdRef.current !== event.pointerId) return; const tag = event.target?.tagName?.toLowerCase(); if (tag === 'input' || tag === 'select' || tag === 'option' || tag === 'label' || tag === 'button') return; event.preventDefault(); activePointerIdRef.current = null; event.currentTarget.releasePointerCapture?.(event.pointerId); onStop?.(); }; const handlePointerCancel = (event) => { if (activePointerIdRef.current !== event.pointerId) return; activePointerIdRef.current = null; onStop?.(); }; const handleBlur = () => { if (activePointerIdRef.current === null) return; activePointerIdRef.current = null; onStop?.(); }; const settingsToggle = showSettingsToggle ? ( ) : null; return (