// 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 { useEffect, useMemo, useState } 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 VolumeSettingsCard from '../VolumeSettingsCard/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 ControlHint from '../ControlHint/index.jsx';
import {
DEFAULT_PAGE_THEME_KEY,
PAGE_THEME_OPTIONS,
getPageTheme,
getPageThemeClass,
normalizePageThemeKey,
} from '../../themes/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, inline = false, 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 ThemePreviewCard({ title, className = '' }) {
// Use the production CardFrame rather than a lookalike rectangle. This makes the demonstration
// honest about borders, opaque card surfaces, and the exact amount of theme visible in a gap.
return (
{/* Neutral placeholder lines suggest real panel content without making the preview look like
an interactive control surface or tying it to any one driver/PTZ layout. */}
);
}
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 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,
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
});
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 savedPageThemeKey = normalizePageThemeKey(pageSettings?.backgroundTheme);
const [previewPageThemeKey, setPreviewPageThemeKey] = useState(savedPageThemeKey);
const previewPageTheme = getPageTheme(previewPageThemeKey);
const savedPageTheme = getPageTheme(savedPageThemeKey);
const hasUnsavedPageTheme = previewPageThemeKey !== savedPageThemeKey;
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 = ;
useEffect(() => {
// Settings load after the provider mounts and can also be replaced by an incoming inter-instance
// transfer. Resynchronize only when the persisted key changes; browsing the local preview does
// not touch pageSettings, so Previous/Next choices are not accidentally reset.
setPreviewPageThemeKey(savedPageThemeKey);
}, [savedPageThemeKey]);
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 }));
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 }));
};
const handleMainBrushDuckEnabled = (event) => {
const checked = Boolean(event.target.checked);
saveAudioSettings((current) => ({ ...(current ?? {}), mainBrushDuckEnabled: checked }));
};
const handleSwapMobileControlColumns = (event) => {
const checked = Boolean(event.target.checked);
savePageSettings((current) => ({ ...(current ?? {}), swapMobileControlColumns: checked }));
};
const handleDriveMacroBackoffEnabled = (event) => {
const checked = Boolean(event.target.checked);
savePageSettings((current) => ({ ...(current ?? {}), driveMacroBackoffEnabled: 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 }));
};
const movePageThemePreview = (direction) => {
const currentIndex = PAGE_THEME_OPTIONS.findIndex((theme) => theme.key === previewPageThemeKey);
const safeIndex = currentIndex >= 0 ? currentIndex : 0;
// Theme browsing wraps in both directions so the Back and Next buttons remain useful at the
// ends of the catalog instead of forcing the user to reverse through every previous option.
const nextIndex = (safeIndex + direction + PAGE_THEME_OPTIONS.length) % PAGE_THEME_OPTIONS.length;
setPreviewPageThemeKey(PAGE_THEME_OPTIONS[nextIndex].key);
};
const handlePageThemeSelect = (event) => {
setPreviewPageThemeKey(normalizePageThemeKey(event.target.value));
};
const handlePageThemeSave = () => {
// Browsing is intentionally local. Persist only this explicit choice so opening Page settings
// and experimenting with patterns cannot unexpectedly alter the driver or PTZ page background.
savePageSettings((current) => ({
...(current ?? {}),
backgroundTheme: previewPageThemeKey,
}));
};
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,
}));
};
// Settings owns grids outside its individual CardFrames. This boundary makes
// those grids react to the panel's allocated width in any desktop column.
return (
{/* Four long labels cannot remain readable in one narrow sidebar row.
Each tab claims half the row until this Settings container is wide,
where the minimum is removed and the original single row returns. */}
KeybindingsControllerPage settingsAdmin
{/* The normal two-column settings page returns at the width where two
useful cards actually fit. The former 48rem threshold was unreachable
in the old driver pane and incorrectly made its fallback permanent. */}
{/* The selector owns a full row at narrow widths. Navigation is a
separate flex row so neither button can squeeze the select text. */}
{/* This miniature layout contains both a full-height vertical seam and horizontal
seams between stacked cards. It exercises the exact gap directions the artwork
must serve on the driver and PTZ pages. */}
Previewing {previewPageTheme.label}
{hasUnsavedPageTheme ? (
; saved theme is {savedPageTheme.label}.
) : (
; this is your saved theme.
)}
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.
{/* Volume is server-backed rather than cookie-backed: it changes what the
rover plays for everyone in the room, so the server owns the limits. */}
TransportSwitching reconnects your session.Transfer settings when opening external servers
Sends this browser's saved identity and page settings to the destination server automatically.