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:
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -12,8 +12,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<!-- site-metadata:inject -->
|
<!-- site-metadata:inject -->
|
||||||
<!-- analytics:inject -->
|
<!-- analytics:inject -->
|
||||||
<script type="module" crossorigin src="/assets/index-D9i-ZujG.js"></script>
|
<script type="module" crossorigin src="/assets/index-BUo1IlRS.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D5gzYj1F.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BITzjVQo.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { createElement, useMemo } from 'react';
|
import { createElement, useMemo } from 'react';
|
||||||
import { FaArrowDown, FaArrowUp, FaBatteryHalf, FaBolt, FaExclamationTriangle, FaMemory, FaThermometerHalf, FaWifi } from 'react-icons/fa';
|
import { FaArrowDown, FaArrowUp, FaBatteryHalf, FaBolt, FaExclamationTriangle, FaMemory, FaThermometerHalf, FaWifi } from 'react-icons/fa';
|
||||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
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 { hostStatsEqual, selectHostStats, selectSpectatorTelemetry, spectatorTelemetryEqual } from '../../../../context/telemetryViews.js';
|
||||||
import CornerPodToggle from './CornerPodToggle.jsx';
|
import CornerPodToggle from './CornerPodToggle.jsx';
|
||||||
import ExpansionPanel from './ExpansionPanel.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));
|
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
|
||||||
return rover?.batteryState || null;
|
return rover?.batteryState || null;
|
||||||
});
|
});
|
||||||
const electrical = useTelemetrySelector(roverId, selectSpectatorTelemetry, spectatorTelemetryEqual);
|
// These values drive gauges and informational bars only; no control decision
|
||||||
const host = useTelemetrySelector(powerOpen ? roverId : null, selectHostStats, hostStatsEqual);
|
// 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 percent = Math.max(0, Math.min(100, finite(batteryState?.percentDisplay) ?? 0));
|
||||||
const current = finite(electrical?.currentMa) ?? 0;
|
const current = finite(electrical?.currentMa) ?? 0;
|
||||||
const currentPercent = Math.max(0, Math.min(1, Math.abs(current) / 2500));
|
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 justDocked = docked && !wasDockedRef.current;
|
||||||
const justUndocked = !docked && wasDockedRef.current;
|
const justUndocked = !docked && wasDockedRef.current;
|
||||||
if (active && justDocked) {
|
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 });
|
actions.sendSong([{ note: 84, duration: 6 }], { slot: 1 });
|
||||||
} else if (active && justUndocked) {
|
} else if (active && justUndocked) {
|
||||||
actions.sendSong([{ note: 72, duration: 6 }], { slot: 1 });
|
actions.sendSong([{ note: 72, duration: 6 }], { slot: 1 });
|
||||||
|
|||||||
@@ -23,11 +23,12 @@ export default function MobileLandscapeLayout() {
|
|||||||
<MobileLayoutFrame>
|
<MobileLayoutFrame>
|
||||||
<div className={`flex flex-col ${themeGapClass}`}>
|
<div className={`flex flex-col ${themeGapClass}`}>
|
||||||
{/* The center column deliberately contains only the driver video. */}
|
{/* 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}
|
{firstColumn}
|
||||||
<div className="min-w-0 self-start">
|
<div className="min-w-0 self-start">
|
||||||
{/* The video stage adapts through DriverLayoutContext while this layout keeps
|
{/* The grid owns the 4:3 height constraint so its center track always
|
||||||
ownership of the existing landscape touch-control columns. */}
|
matches the video. Any width the video cannot use is reassigned to
|
||||||
|
the control columns instead of becoming empty gutters around it. */}
|
||||||
<NewDriveVideo />
|
<NewDriveVideo />
|
||||||
</div>
|
</div>
|
||||||
{secondColumn}
|
{secondColumn}
|
||||||
|
|||||||
@@ -1,4 +1,27 @@
|
|||||||
@media (max-width: 1023px) {
|
@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) {
|
html:has(.driver-mobile-layout) {
|
||||||
/* These three driver-only positions deliberately use strict page snapping. */
|
/* These three driver-only positions deliberately use strict page snapping. */
|
||||||
scroll-snap-type: y mandatory;
|
scroll-snap-type: y mandatory;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Settings Constants
|
// Settings Constants
|
||||||
// Purpose: Defines keys/defaults used by the persisted settings subsystem. Scope: Centralizes stable identifiers and fallback values for settings storage.
|
// 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_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
|
// Settings Persistence
|
||||||
// Purpose: Implements local persistence read/write behavior for settings namespaces. Scope: Encapsulates storage IO, parsing guards, and migration-safe defaults.
|
// 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 {
|
try {
|
||||||
const decoded = decodeURIComponent(raw ?? '');
|
return JSON.parse(raw);
|
||||||
return JSON.parse(decoded);
|
|
||||||
} catch (error) {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadSettings() {
|
function loadCookieSettings() {
|
||||||
if (typeof document === 'undefined') return {};
|
|
||||||
const cookiePrefix = `${SETTINGS_COOKIE}=`;
|
const cookiePrefix = `${SETTINGS_COOKIE}=`;
|
||||||
const entry = document.cookie
|
const entry = document.cookie
|
||||||
.split(';')
|
.split(';')
|
||||||
.map((part) => part.trim())
|
.map((part) => part.trim())
|
||||||
.find((part) => part.startsWith(cookiePrefix));
|
.find((part) => part.startsWith(cookiePrefix));
|
||||||
if (!entry) return {};
|
if (!entry) return null;
|
||||||
|
|
||||||
const raw = entry.substring(cookiePrefix.length);
|
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) {
|
export function saveSettings(settings) {
|
||||||
if (typeof document === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const serialized = encodeURIComponent(JSON.stringify(settings ?? {}));
|
const serialized = JSON.stringify(settings ?? {});
|
||||||
const cookie = `${SETTINGS_COOKIE}=${serialized}; path=/; max-age=${SETTINGS_MAX_AGE}; samesite=strict`;
|
window.localStorage.setItem(SETTINGS_STORAGE_KEY, serialized);
|
||||||
document.cookie = cookie;
|
|
||||||
return true;
|
// 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) {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user