mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
many small improvements
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { createElement, useMemo } from 'react';
|
||||
import { FaArrowDown, FaArrowUp, FaBatteryHalf, FaBolt, FaExclamationTriangle, FaMemory, FaThermometerHalf, FaWifi } from 'react-icons/fa';
|
||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
||||
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
|
||||
import { useVisualTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
|
||||
import { hostStatsEqual, selectHostStats, selectSpectatorTelemetry, spectatorTelemetryEqual } from '../../../../context/telemetryViews.js';
|
||||
import CornerPodToggle from './CornerPodToggle.jsx';
|
||||
import ExpansionPanel from './ExpansionPanel.jsx';
|
||||
@@ -73,8 +73,13 @@ export default function TopRightPod({ roverId }) {
|
||||
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
|
||||
return rover?.batteryState || null;
|
||||
});
|
||||
const electrical = useTelemetrySelector(roverId, selectSpectatorTelemetry, spectatorTelemetryEqual);
|
||||
const host = useTelemetrySelector(powerOpen ? roverId : null, selectHostStats, hostStatsEqual);
|
||||
// These values drive gauges and informational bars only; no control decision
|
||||
// depends on their render cadence. The visual subscription remains immediate
|
||||
// on desktop while honoring MobileLayoutFrame's shared telemetry throttle on
|
||||
// phones, preventing analog current/voltage noise from repainting this pod at
|
||||
// the rover sensor-stream rate.
|
||||
const electrical = useVisualTelemetrySelector(roverId, selectSpectatorTelemetry, spectatorTelemetryEqual);
|
||||
const host = useVisualTelemetrySelector(powerOpen ? roverId : null, selectHostStats, hostStatsEqual);
|
||||
const percent = Math.max(0, Math.min(100, finite(batteryState?.percentDisplay) ?? 0));
|
||||
const current = finite(electrical?.currentMa) ?? 0;
|
||||
const currentPercent = Math.max(0, Math.min(1, Math.abs(current) / 2500));
|
||||
|
||||
@@ -31,6 +31,8 @@ export function useManualDockAssist(options = {}) {
|
||||
const justDocked = docked && !wasDockedRef.current;
|
||||
const justUndocked = !docked && wasDockedRef.current;
|
||||
if (active && justDocked) {
|
||||
// Stop on the first home-base contact so continued input cannot push the rover against the dock.
|
||||
actions.stopAllMotion();
|
||||
actions.sendSong([{ note: 84, duration: 6 }], { slot: 1 });
|
||||
} else if (active && justUndocked) {
|
||||
actions.sendSong([{ note: 72, duration: 6 }], { slot: 1 });
|
||||
|
||||
@@ -23,11 +23,12 @@ export default function MobileLandscapeLayout() {
|
||||
<MobileLayoutFrame>
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
{/* The center column deliberately contains only the driver video. */}
|
||||
<section className={`grid grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] ${themeGapClass}`}>
|
||||
<section className={`mobile-landscape-driver-grid grid ${themeGapClass}`}>
|
||||
{firstColumn}
|
||||
<div className="min-w-0 self-start">
|
||||
{/* The video stage adapts through DriverLayoutContext while this layout keeps
|
||||
ownership of the existing landscape touch-control columns. */}
|
||||
{/* The grid owns the 4:3 height constraint so its center track always
|
||||
matches the video. Any width the video cannot use is reassigned to
|
||||
the control columns instead of becoming empty gutters around it. */}
|
||||
<NewDriveVideo />
|
||||
</div>
|
||||
{secondColumn}
|
||||
|
||||
@@ -1,4 +1,27 @@
|
||||
@media (max-width: 1023px) {
|
||||
.driver-mobile-layout {
|
||||
/* Sixty viewport-width units preserves the landscape layout's original
|
||||
0.7 / 2.1 / 0.7 proportions. The height-derived limit prevents that
|
||||
center track from producing a 4:3 video taller than the small viewport. */
|
||||
--mobile-landscape-video-width: min(60vw, 133.333svh);
|
||||
}
|
||||
|
||||
.driver-mobile-layout:has(> .panel-section) {
|
||||
/* GlobalObjectiveBanner is a direct panel-section child when visible. Only
|
||||
then reserve its compact mobile height; a dismissed or absent banner no
|
||||
longer leaves permanent space beneath and beside the video. */
|
||||
--mobile-landscape-video-width: min(60vw, calc(133.333svh - 3.333rem));
|
||||
}
|
||||
|
||||
.driver-mobile-layout .mobile-landscape-driver-grid {
|
||||
/* The side controls consume all width not used by the exact video track.
|
||||
This removes the empty center-column gutters created by max-width alone. */
|
||||
grid-template-columns:
|
||||
minmax(0, 1fr)
|
||||
minmax(0, var(--mobile-landscape-video-width))
|
||||
minmax(0, 1fr);
|
||||
}
|
||||
|
||||
html:has(.driver-mobile-layout) {
|
||||
/* These three driver-only positions deliberately use strict page snapping. */
|
||||
scroll-snap-type: y mandatory;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Settings Constants
|
||||
// Purpose: Defines keys/defaults used by the persisted settings subsystem. Scope: Centralizes stable identifiers and fallback values for settings storage.
|
||||
export const SETTINGS_COOKIE = 'roverSettings';
|
||||
export const SETTINGS_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||
export const SETTINGS_STORAGE_KEY = 'roverSettings';
|
||||
|
||||
@@ -1,38 +1,81 @@
|
||||
// Settings Persistence
|
||||
// Purpose: Implements local persistence read/write behavior for settings namespaces. Scope: Encapsulates storage IO, parsing guards, and migration-safe defaults.
|
||||
import { SETTINGS_COOKIE, SETTINGS_MAX_AGE } from './constants.js';
|
||||
import { SETTINGS_COOKIE, SETTINGS_STORAGE_KEY } from './constants.js';
|
||||
|
||||
function parseCookieValue(raw) {
|
||||
function parseSettings(raw, source) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(raw ?? '');
|
||||
return JSON.parse(decoded);
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse settings cookie', error); // eslint-disable-line no-console
|
||||
console.warn(`Failed to parse settings from ${source}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSettings() {
|
||||
if (typeof document === 'undefined') return {};
|
||||
function loadCookieSettings() {
|
||||
const cookiePrefix = `${SETTINGS_COOKIE}=`;
|
||||
const entry = document.cookie
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(cookiePrefix));
|
||||
if (!entry) return {};
|
||||
if (!entry) return null;
|
||||
|
||||
const raw = entry.substring(cookiePrefix.length);
|
||||
return parseCookieValue(raw) ?? {};
|
||||
try {
|
||||
return parseSettings(decodeURIComponent(raw ?? ''), 'cookie');
|
||||
} catch (error) {
|
||||
// URI decoding can fail before JSON parsing when an old cookie is truncated or corrupt.
|
||||
// Treat that cookie as unavailable so it cannot prevent the Web UI from starting cleanly.
|
||||
console.warn('Failed to decode settings cookie', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function removeSettingsCookie() {
|
||||
// The cookie used path=/, so deletion must use the same path. Removal happens only after
|
||||
// localStorage has accepted and verified the migrated value, preserving the recoverable copy
|
||||
// when storage is disabled by a browser policy.
|
||||
document.cookie = `${SETTINGS_COOKIE}=; path=/; max-age=0; samesite=strict`;
|
||||
}
|
||||
|
||||
export function loadSettings() {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return {};
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
if (stored !== null) {
|
||||
return parseSettings(stored, 'localStorage') ?? {};
|
||||
}
|
||||
} catch (error) {
|
||||
// Some privacy modes expose localStorage but throw when it is accessed. Continue to the
|
||||
// legacy cookie so existing users retain usable settings in those restricted environments.
|
||||
console.warn('Failed to read settings from localStorage', error);
|
||||
return loadCookieSettings() ?? {};
|
||||
}
|
||||
|
||||
const cookieSettings = loadCookieSettings();
|
||||
if (cookieSettings === null) return {};
|
||||
|
||||
// This is a one-time migration for browsers that already have roverSettings. Reuse the normal
|
||||
// verified writer so the legacy cookie is removed only when the complete value is safely stored.
|
||||
if (saveSettings(cookieSettings)) {
|
||||
removeSettingsCookie();
|
||||
}
|
||||
return cookieSettings;
|
||||
}
|
||||
|
||||
export function saveSettings(settings) {
|
||||
if (typeof document === 'undefined') return false;
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
try {
|
||||
const serialized = encodeURIComponent(JSON.stringify(settings ?? {}));
|
||||
const cookie = `${SETTINGS_COOKIE}=${serialized}; path=/; max-age=${SETTINGS_MAX_AGE}; samesite=strict`;
|
||||
document.cookie = cookie;
|
||||
return true;
|
||||
const serialized = JSON.stringify(settings ?? {});
|
||||
window.localStorage.setItem(SETTINGS_STORAGE_KEY, serialized);
|
||||
|
||||
// setItem can be intercepted or fail unusually in embedded/privacy-constrained browsers.
|
||||
// Reading the exact value back keeps the provider from claiming success unless persistence
|
||||
// really contains the complete settings payload that was just written.
|
||||
return window.localStorage.getItem(SETTINGS_STORAGE_KEY) === serialized;
|
||||
} catch (error) {
|
||||
console.warn('Failed to write settings cookie', error); // eslint-disable-line no-console
|
||||
console.warn('Failed to write settings to localStorage', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user