// Settings Panel // Purpose: Defines the Settings Panel 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 { useMemo } from 'react'; import { useControlActions, useControlSelector } from '../../controls/index.js'; import AuthPanel from '../AuthPanel/index.jsx'; import AdminPanel from '../AdminPanel/index.jsx'; import KeymapSettings from '../KeymapSettings/index.jsx'; import GamepadMappingSettings from '../GamepadMappingSettings/index.jsx'; import OvercurrentLimiterPanel from '../OvercurrentLimiterPanel/index.jsx'; import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../Tabs/index.jsx'; import SessionSnapshot from '../SessionSnapshot/index.jsx'; import SocketLogPanel from '../SocketLogPanel/index.jsx'; import CardFrame from '../CardFrame/index.jsx'; import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx'; import { useHudMapSetting } from '../../hooks/useHudMapSetting.js'; import { useSettingsNamespace } from '../../settings/index.js'; import { useSocket } from '../../context/SocketContext.jsx'; import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js'; import { formatKeyLabel } from '../../controls/keymapUtils.js'; import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../analytics/index.js'; const manualTabs = [ { key: 'start', label: 'Start OI' }, { key: 'safe', label: 'Safe' }, { key: 'full', label: 'Full' }, { key: 'passive', label: 'Passive' }, { key: 'dock', label: 'Dock' }, ]; const VIDEO_FILTER_OPTIONS = [ // "Color" is the pass-through mode users can return to when the scene has usable color. { key: 'none', label: 'Color' }, // Grayscale removes the pink IR-contaminated cast while preserving luminance detail. { key: 'grayscale', label: 'Gray' }, // Greenscale keeps the same luminance-first idea as grayscale, then tints it green for // users who find green-on-black easier to visually parse in bright outdoor scenes. { key: 'greenscale', label: 'Green' }, ]; function normalizeVideoFilter(value) { // Settings are stored in a browser cookie and can contain stale or hand-edited values. // Falling back here keeps the dropdown and media player predictable instead of rendering // with an unknown mode. return VIDEO_FILTER_OPTIONS.some((option) => option.key === value) ? value : VIDEO_SETTINGS_DEFAULTS.colorFilter; } function SettingRow({ children, className = '' }) { // Page settings are changed one row at a time, so each row gets a subtle container and a // max width. This keeps the label and control together instead of stretching them across // the full settings pane. return ( ); } function SettingHelp({ children }) { // Helper text is still secondary, but it no longer uses the very small microcopy scale that // made the settings panel difficult to read from a normal driving distance. return

{children}

; } function RangeSetting({ label, value, disabled = false, onChange }) { // Range settings need enough horizontal room for accurate pointer input, so the slider spans // the row while the percentage value stays beside the label for quick feedback. return ( ); } function reconnectSocketWithTransport(socket, transport) { if (!socket?.io?.opts) return; /* Socket.IO reads its manager options when reconnecting. Keep this mutation in one helper instead of inside the component body so the settings handler only expresses the user-facing action: save preference, then reconnect. */ socket.io.opts.transports = transport === 'polling' ? ['polling'] : ['websocket', 'polling']; socket.disconnect(); socket.connect(); } export default function SettingsPanel() { const keymap = useControlSelector((control) => control.state.keymap); const roverId = useControlSelector((control) => control.state.roverId); const { sendOiCommand, setSensorStream } = useControlActions(); const canControl = Boolean(roverId); const [hudMapDesktop, setHudMapDesktop] = useHudMapSetting(); const socket = useSocket(); const { value: pageSettings, save: savePageSettings } = useSettingsNamespace('page', { hudMapDesktop: false, connectionTransport: 'websocket', swapMobileControlColumns: false, driveMacroBackoffEnabled: true, interInstanceTransferSettings: true, }); const { value: audioSettings, save: saveAudioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS); const { value: videoSettings, save: saveVideoSettings } = useSettingsNamespace('video', VIDEO_SETTINGS_DEFAULTS); const connectionTransport = pageSettings?.connectionTransport || 'websocket'; const swapMobileControlColumns = Boolean(pageSettings?.swapMobileControlColumns); const driveMacroBackoffEnabled = typeof pageSettings?.driveMacroBackoffEnabled === 'boolean' ? pageSettings.driveMacroBackoffEnabled : true; const interInstanceTransferSettings = pageSettings?.interInstanceTransferSettings !== false; const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume; const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume; const roverVolume = Number.isFinite(audioSettings?.roverVolume) ? audioSettings.roverVolume : AUDIO_SETTINGS_DEFAULTS.roverVolume; const mainBrushDuckEnabled = typeof audioSettings?.mainBrushDuckEnabled === 'boolean' ? audioSettings.mainBrushDuckEnabled : typeof audioSettings?.autoLevelEnabled === 'boolean' ? audioSettings.autoLevelEnabled : AUDIO_SETTINGS_DEFAULTS.mainBrushDuckEnabled; const mainBrushDuckAmount = Number.isFinite(audioSettings?.mainBrushDuckAmount) ? Math.max(0, Math.min(1, audioSettings.mainBrushDuckAmount)) : AUDIO_SETTINGS_DEFAULTS.mainBrushDuckAmount; const videoColorFilter = normalizeVideoFilter(videoSettings?.colorFilter); const videoFilterCycleKeyLabel = formatKeyLabel(keymap?.videoFilterCycle?.[0]); const sensorButtons = useMemo( () => [ { key: 'start', label: 'Enable stream', enable: true }, { key: 'stop', label: 'Disable stream', enable: false }, ], [], ); const handleSensorToggle = (enable) => { if (!roverId) return; setSensorStream(enable); }; const handleTransportChange = (event) => { const next = event.target.value; savePageSettings((current) => ({ ...(current ?? {}), connectionTransport: next })); trackAnalyticsEvent('settings_change', { setting: 'connection_transport', value: next }); reconnectSocketWithTransport(socket, next); }; const handleAudioRange = (key) => (event) => { const raw = Number(event.target.value); const next = Number.isFinite(raw) ? Math.max(0, Math.min(1, raw)) : 0; saveAudioSettings((current) => ({ ...(current ?? {}), [key]: next })); trackAnalyticsEventThrottled( 'settings_change', { setting: key, value: next }, { key: `audio:${key}`, throttleMs: 3 * 1000 }, ); }; const handleMainBrushDuckEnabled = (event) => { const checked = Boolean(event.target.checked); saveAudioSettings((current) => ({ ...(current ?? {}), mainBrushDuckEnabled: checked })); trackAnalyticsEvent('settings_change', { setting: 'mainBrushDuckEnabled', value: checked }); }; const handleSwapMobileControlColumns = (event) => { const checked = Boolean(event.target.checked); savePageSettings((current) => ({ ...(current ?? {}), swapMobileControlColumns: checked })); trackAnalyticsEvent('mobile_controls_swap', { enabled: checked }); trackAnalyticsEvent('settings_change', { setting: 'swapMobileControlColumns', value: checked }); }; const handleDriveMacroBackoffEnabled = (event) => { const checked = Boolean(event.target.checked); savePageSettings((current) => ({ ...(current ?? {}), driveMacroBackoffEnabled: checked })); trackAnalyticsEvent('settings_change', { setting: 'driveMacroBackoffEnabled', value: checked }); }; const handleInterInstanceTransferSettings = (event) => { const checked = Boolean(event.target.checked); /* This replaces the old per-click transfer confirmation. Keeping the choice in Page settings makes external-server navigation immediate while still letting users opt out of sending their current settings cookie. */ savePageSettings((current) => ({ ...(current ?? {}), interInstanceTransferSettings: checked })); trackAnalyticsEvent('settings_change', { setting: 'interInstanceTransferSettings', value: checked }); }; const handleVideoFilterChange = (event) => { const nextFilter = normalizeVideoFilter(event.target.value); // Merge into the current video namespace so future video preferences can coexist with this // filter choice. This follows the same persisted-settings shape used by page/audio options. saveVideoSettings((current) => ({ ...(current ?? {}), colorFilter: nextFilter, })); trackAnalyticsEvent('settings_change', { setting: 'videoColorFilter', value: nextFilter }); }; return ( Keybindings Controller Page settings Admin
{/* Page settings use a responsive card grid so unrelated settings do not form one long, stretched column. Audio spans both columns because volume sliders need extra width for comfortable pointer control. */}
setHudMapDesktop(e.target.checked)} /> Show top-down map in HUD (desktop) Mobile HUD keeps the map on by default. {/* The dropdown mirrors the other Page settings controls and writes through the existing cookie-backed settings provider, so the choice survives reloads without adding rover-side state. */} Rover video filter {/* Use the live keymap value here instead of hardcoding the default key because this shortcut is user-configurable in the Keybindings tab. */} Use {videoFilterCycleKeyLabel ? : null} to cycle filters. Applies only to rover camera pixels; HUD overlays stay full color. Swap control columns (put joystick on the left) Enable backward bump in drive macro {/* Audio sliders are stacked inside the wider card so the value chip, label, and slider remain easy to compare while preserving enough drag distance. */} Main brush ducking Lowers rover audio only while the main brush is running. Transport Switching reconnects your session. Transfer settings when opening external servers Sends this browser's saved identity and page settings to the destination server automatically.
{manualTabs.map((tab) => ( ))}
{sensorButtons.map((btn) => ( ))}
{!canControl &&

Assign a rover to toggle streams.

}
); }