// 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 (Assign a rover to toggle streams.
}