new chat layout and stuff

This commit is contained in:
legop3
2026-01-15 14:43:55 -05:00
parent 5b9167ab2a
commit d184590bf2
12 changed files with 258 additions and 180 deletions
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
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-5t6wOuB4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BY_dmM98.css">
<script type="module" crossorigin src="/assets/index-BNq14-R7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cmh8Rw7S.css">
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -102,7 +102,7 @@ function MobileFeatureTabs({
</TabPanel>
<TabPanel id="roomcontrols">
<div className="space-y-0.5">
{showTelemetry ? <TelemetryPanel /> : null}
{/* {showTelemetry ? <TelemetryPanel /> : null} */}
<HomeAssistantControls />
<RoomCameraPanel panelId={roomPanelId} />
</div>
+4
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import ChatMessageRow from './ChatMessageRow.jsx';
const LIFETIME_MS = 5000;
const DEFAULT_COLOR = '#2196f3';
@@ -52,6 +53,9 @@ function hexToRgb(hex) {
}
function AlertToast({ alert }) {
if (alert.kind === 'chat' && alert.payload) {
return <ChatMessageRow message={alert.payload} />;
}
const rgb = hexToRgb(alert.color) || hexToRgb(DEFAULT_COLOR);
const backgroundColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.25)` : 'rgba(33, 150, 243, 0.25)';
const borderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.7)` : 'rgba(33, 150, 243, 0.7)';
+99
View File
@@ -0,0 +1,99 @@
import { FaDiscord } from 'react-icons/fa';
function roleColors(role) {
switch (role) {
case 'admin':
case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300';
case 'spectator':
return 'text-slate-400';
default:
return 'text-sky-300';
}
}
function formatTime(ts) {
const date = new Date(ts);
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
function displayName(message) {
return message.nickname || message.socketId?.slice(0, 6) || 'unknown';
}
function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
if (!guildIconUrl && !userAvatarUrl) return null;
return (
<span
className="flex h-4 w-4 overflow-hidden rounded-full border border-slate-700/80"
title={label}
>
<span
className={`h-full w-1/2 bg-slate-700/70 ${guildIconUrl ? 'bg-cover' : ''}`}
style={
guildIconUrl
? {
backgroundImage: `url(${guildIconUrl})`,
backgroundPosition: 'left center',
backgroundSize: '200% 100%',
}
: undefined
}
/>
<span
className={`h-full w-1/2 bg-slate-700/70 ${userAvatarUrl ? 'bg-cover' : ''}`}
style={
userAvatarUrl
? {
backgroundImage: `url(${userAvatarUrl})`,
backgroundPosition: 'right center',
backgroundSize: '200% 100%',
}
: undefined
}
/>
</span>
);
}
export default function ChatMessageRow({ message }) {
const isAdmin =
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
const discordLabel = message.fromDiscord
? `${message.discordGuildName || 'Discord'} · ${displayName(message)}`
: null;
return (
<div
className={`surface-muted relative flex flex-wrap items-start gap-1 text-sm ${
isAdmin
? 'border border-amber-400/30'
: message.fromDiscord
? 'border border-indigo-400/30 bg-indigo-900/20'
: ''
}`}
>
{message.fromDiscord ? (
<>
<FaDiscord className="h-3.5 w-3.5 text-indigo-200" />
<DiscordAvatar
guildIconUrl={message.discordGuildIconUrl}
userAvatarUrl={message.discordUserAvatarUrl}
label={discordLabel}
/>
</>
) : null}
<span className={`font-semibold text-[0.85rem] ${roleColors(message.role)}`}>
{displayName(message)}
</span>
{message.roverId && (
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">rover {message.roverId}</span>
)}
<span className="text-slate-100 break-words leading-tight whitespace-pre-wrap">{message.text}</span>
<span className="absolute bottom-0.5 right-1 text-[0.65rem] text-slate-400/60">
{formatTime(message.ts)}
</span>
</div>
);
}
+9 -99
View File
@@ -1,65 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { FaDiscord } from 'react-icons/fa';
import { useChat } from '../context/ChatContext.jsx';
import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
function roleColors(role) {
switch (role) {
case 'admin':
case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300';
case 'spectator':
return 'text-slate-400';
default:
return 'text-sky-300';
}
}
function formatTime(ts) {
const date = new Date(ts);
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
function displayName(message) {
return message.nickname || message.socketId?.slice(0, 6) || 'unknown';
}
function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
if (!guildIconUrl && !userAvatarUrl) return null;
return (
<span
className="flex h-4 w-4 overflow-hidden rounded-full border border-slate-700/80"
title={label}
>
<span
className={`h-full w-1/2 bg-slate-700/70 ${guildIconUrl ? 'bg-cover' : ''}`}
style={
guildIconUrl
? {
backgroundImage: `url(${guildIconUrl})`,
backgroundPosition: 'left center',
backgroundSize: '200% 100%',
}
: undefined
}
/>
<span
className={`h-full w-1/2 bg-slate-700/70 ${userAvatarUrl ? 'bg-cover' : ''}`}
style={
userAvatarUrl
? {
backgroundImage: `url(${userAvatarUrl})`,
backgroundPosition: 'right center',
backgroundSize: '200% 100%',
}
: undefined
}
/>
</span>
);
}
import ChatMessageRow from './ChatMessageRow.jsx';
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl'];
const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
@@ -146,46 +89,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
{sorted.length === 0 ? (
<p className="text-sm text-slate-500">No messages yet.</p>
) : (
sorted.map((msg) => {
const isAdmin =
msg.role === 'admin' || msg.role === 'lockdown' || msg.role === 'lockdown-admin';
const discordLabel = msg.fromDiscord
? `${msg.discordGuildName || 'Discord'} · ${displayName(msg)}`
: null;
return (
<div
key={msg.id}
className={`surface-muted relative flex flex-wrap items-start gap-1 text-sm ${
isAdmin
? 'border border-amber-400/30'
: msg.fromDiscord
? 'border border-indigo-400/30 bg-indigo-900/20'
: ''
}`}
>
{msg.fromDiscord ? (
<>
<FaDiscord className="h-3.5 w-3.5 text-indigo-200" />
<DiscordAvatar
guildIconUrl={msg.discordGuildIconUrl}
userAvatarUrl={msg.discordUserAvatarUrl}
label={discordLabel}
/>
</>
) : null}
<span className={`font-semibold text-[0.85rem] ${roleColors(msg.role)}`}>
{displayName(msg)}
</span>
{msg.roverId && (
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">{msg.roverId}</span>
)}
<span className="text-slate-100 break-words leading-tight whitespace-pre-wrap">{msg.text}</span>
<span className="absolute bottom-0.5 right-1 text-[0.65rem] text-slate-400/60">
{formatTime(msg.ts)}
</span>
</div>
);
})
sorted.map((msg) => <ChatMessageRow key={msg.id} message={msg} />)
)}
</div>
{!hideInput && (
@@ -196,7 +100,13 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
onChange={(e) => setDraft(e.target.value)}
onFocus={onInputFocus}
onBlur={onInputBlur}
ref={(el) => registerInputRef(el)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !draft.trim()) {
event.preventDefault();
blurChat();
}
}}
ref={(el) => registerInputRef(el, { target: 'panel' })}
placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'}
disabled={!canChat}
/>
+110 -56
View File
@@ -2,20 +2,13 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { WhepPlayer } from '../lib/whepPlayer.js';
import TopDownMap from './TopDownMap.jsx';
import { useHudMapSetting } from '../hooks/useHudMapSetting.js';
import { useChat } from '../context/ChatContext.jsx';
import { useSession } from '../context/SessionContext.jsx';
const RESTART_DELAY_MS = 2000;
const UNMUTE_RETRY_MS = 3000;
const AUDIO_RETRY_MS = 3000;
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
function formatNoteLabel(note) {
if (typeof note !== 'number' || !Number.isFinite(note)) return '--';
const name = NOTE_NAMES[note % 12] || '?';
const octave = Math.floor(note / 12) - 1;
return `${name}${octave}`;
}
function buildBatteryVisual(charge, config) {
const full = config?.Full;
const warn = config?.Warn;
@@ -55,7 +48,6 @@ export default function VideoTile({
layoutFormat = 'desktop',
hudVariant = 'default',
driverLabel = null,
songNote = null,
hudForceMap = false,
hudMapPosition = 'top-right',
fitParent = false,
@@ -405,18 +397,22 @@ export default function VideoTile({
variant={hudVariant}
driverLabel={driverLabel}
battery={batteryVisual}
songNote={songNote}
showTopDown={showHudMap}
mobileHud={mobileHud}
mapPosition={hudMapPosition}
/>
<OvercurrentOverlay motors={overcurrentMotors} />
<LowBatteryOverlay charge={batteryCharge} config={batteryConfig} />
<HudChatInput compact={mobileHud} />
<OvercurrentOverlay motors={overcurrentMotors} compact={mobileHud} />
<LowBatteryOverlay charge={batteryCharge} config={batteryConfig} compact={mobileHud} />
{showVerticalBattery && batteryVisual.available ? (
<BatteryBarVertical visual={batteryVisual} />
) : null}
{qualityNotice ? (
<div className="pointer-events-none absolute left-1 top-1 rounded bg-black/70 px-1 py-0.5 text-xs font-semibold text-amber-200">
<div
className={`pointer-events-none absolute rounded bg-black/70 font-semibold text-amber-200 ${
mobileHud ? 'left-0.5 top-0.5 px-0.5 py-0.25 text-[0.55rem]' : 'left-1 top-1 px-1 py-0.5 text-xs'
}`}
>
{qualityNotice}
</div>
) : null}
@@ -527,7 +523,6 @@ function HudOverlay({
variant = 'default',
driverLabel = null,
battery,
songNote = null,
showTopDown = false,
mobileHud = false,
mapPosition = 'top-right',
@@ -543,10 +538,13 @@ function HudOverlay({
const pulse = frame?.receivedAt ? now - frame.receivedAt < 200 : false;
const isMobile = mobileHud;
const portraitMobile = layoutFormat === 'mobile-portrait';
const statusTextClass = isMobile ? 'text-[0.55rem]' : 'text-[0.65rem]';
const statusPadClass = isMobile ? 'px-0.5 py-0.25' : 'px-1 py-0.5';
const labelPadClass = isMobile ? 'px-0.5 py-0.25' : 'px-0.5 py-0.5';
const labelTextClass = isMobile ? 'text-[0.7rem]' : 'text-[0.8rem]';
const statusTextClass = isMobile ? 'text-[0.45rem]' : 'text-[0.65rem]';
const statusPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-1 py-0.5';
const labelPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-0.5 py-0.5';
const labelTextClass = isMobile ? 'text-[0.55rem]' : 'text-[0.8rem]';
const statusPosClass = isMobile ? 'left-0.5 top-0.5' : 'left-1 top-1';
const telemetryPosClass = isMobile ? 'left-0.5 top-1/2' : 'left-1 top-1/2';
const labelPosClass = isMobile ? 'bottom-0.5' : 'bottom-0.5';
const mapSize = '240px';
const mapScale = portraitMobile ? 0.36 : isMobile ? 0.45 : 0.7;
const mapOpacity = isMobile ? 0.85 : 0.7;
@@ -570,25 +568,17 @@ function HudOverlay({
];
return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div
className={`absolute left-1 top-1 bg-black/70 font-medium text-slate-100 ${statusTextClass} ${statusPadClass}`}
>
<span>Status: {status}</span>
{audioStatus ? <div>Audio: {audioStatus}</div> : null}
</div>
{songNote != null ? (
<div
className={`absolute right-1 top-1 rounded bg-black/70 font-semibold text-emerald-200 ${statusTextClass} ${statusPadClass}`}
>
Song {formatNoteLabel(songNote)} <span className="text-slate-400">({songNote})</span>
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
<div className="flex flex-col gap-[1px] leading-none">
<span>Status: {status}</span>
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
</div>
) : null}
</div>
<div
className={`absolute left-1 top-1/2 flex -translate-y-1/2 flex-col gap-0.35 bg-black/70 text-slate-100 ${statusTextClass} ${statusPadClass}`}
className={`absolute ${telemetryPosClass} flex -translate-y-1/2 flex-col gap-0.35 bg-black/70 text-slate-100 ${statusTextClass} ${statusPadClass}`}
>
<div className="space-y-0.1 leading-tight">
<span className={`${isMobile ? 'text-[0.55rem]' : 'text-[0.6rem]'} uppercase tracking-wide text-slate-400`}>
<span className={`${isMobile ? 'text-[0.45rem]' : 'text-[0.6rem]'} uppercase tracking-wide text-slate-400`}>
Telemetry
</span>
{telemetryEntries.map(([labelText, value]) => (
@@ -617,7 +607,7 @@ function HudOverlay({
</div>
<div
className={`absolute bottom-0.5 left-1/2 flex -translate-x-1/2 items-center gap-1 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
className={`absolute ${labelPosClass} left-1/2 flex -translate-x-1/2 items-center gap-1 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
>
<span className="font-semibold text-white">{label || 'Unnamed Rover'}</span>
{driverLabel ? <span className="text-slate-300"> {driverLabel}</span> : null}
@@ -628,22 +618,14 @@ function HudOverlay({
return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div
className={`absolute left-1 top-1 bg-black/70 font-medium text-slate-100 ${statusTextClass} ${statusPadClass}`}
>
<span>Status: {status}</span>
{audioStatus ? <div>Audio: {audioStatus}</div> : null}
</div>
{songNote != null ? (
<div
className={`absolute bottom-1 right-1 rounded bg-black/70 font-semibold text-emerald-200 ${statusTextClass} ${statusPadClass}`}
>
Song {formatNoteLabel(songNote)} <span className="text-slate-400">({songNote})</span>
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
<div className="flex flex-col gap-[1px] leading-none">
<span>Status: {status}</span>
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
</div>
) : null}
</div>
<div
className={`absolute bottom-0.5 left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
className={`absolute ${labelPosClass} left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
>
<span>Rover: "{label || 'Unnamed Rover'}"</span>
{/* <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */}
@@ -672,21 +654,26 @@ const OVERCURRENT_LABELS = {
sideBrush: 'Side brush',
};
function OvercurrentOverlay({ motors }) {
function OvercurrentOverlay({ motors, compact = false }) {
if (!motors?.length) return null;
const labels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
const containerClass = compact ? 'p-2' : 'p-4';
const textClass = compact ? 'text-lg' : 'text-4xl';
const subTextClass = compact ? 'text-xs' : 'text-xl';
return (
<div className="pointer-events-none absolute flex items-center justify-center bg-red-900/60 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 p-4">
<div className="text-center text-4xl font-semibold text-white animate-pulse">
<div
className={`pointer-events-none absolute flex items-center justify-center bg-red-900/60 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ${containerClass}`}
>
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
<div>OVERCURRENT</div>
<div className="mt-0.5 text-xl font-medium text-white">{labels.join(', ')}</div>
<div className={`mt-0.5 font-medium text-white ${subTextClass}`}>{labels.join(', ')}</div>
</div>
</div>
);
}
// low battery overlay, change text based on warn / urgent. use percentage calculated same as BatteryBar. change text based on warn or urgent.
function LowBatteryOverlay({ charge, config }) {
function LowBatteryOverlay({ charge, config, compact = false }) {
if (charge == null || config == null) return null;
const full = config.Full;
const warn = config.Warn;
@@ -701,11 +688,78 @@ function LowBatteryOverlay({ charge, config }) {
const message = depleted ? 'Battery low! please dock and charge the rover soon.' : 'BATTERY VERY LOW, PLEASE DOCK THE ROVER AND CHARGE IMMEDIATELY!!';
const containerClass = compact ? 'p-2 top-6' : 'p-4 top-10';
const textClass = compact ? 'text-sm' : 'text-2xl';
return (
<div className="pointer-events-none absolute flex items-center justify-center bg-amber-900/60 top-10 left-1/2 -translate-x-1/2 p-4">
<div className="text-center text-2xl font-semibold text-white animate-pulse">
<div
className={`pointer-events-none absolute flex items-center justify-center bg-amber-900/60 left-1/2 -translate-x-1/2 ${containerClass}`}
>
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
<div>{message}</div>
</div>
</div>
);
}
function HudChatInput({ compact = false }) {
const { session } = useSession();
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef } = useChat();
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const canChat = session?.role !== 'spectator';
const containerClass = compact
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.25 rounded bg-black/70 px-0.4 py-0.2'
: 'pointer-events-auto absolute bottom-1 right-1 flex w-[12rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25';
const inputClass = compact
? 'min-w-0 flex-1 bg-transparent text-[0.55rem] text-slate-100 placeholder:text-slate-400 focus:outline-none'
: 'min-w-0 flex-1 bg-transparent text-[0.7rem] text-slate-100 placeholder:text-slate-400 focus:outline-none';
const buttonClass = compact
? 'rounded bg-cyan-500/80 px-0.35 py-0.2 text-[0.55rem] font-semibold text-black disabled:opacity-50'
: 'rounded bg-cyan-500/80 px-0.5 py-0.25 text-[0.7rem] font-semibold text-black disabled:opacity-50';
async function handleSend(event) {
event.preventDefault();
if (!canChat) return;
const clean = draft.trim();
if (!clean) return;
setSending(true);
try {
await sendMessage(clean, null);
setDraft('');
blurChat();
} catch (err) {
alert(err.message);
} finally {
setSending(false);
}
}
return (
<form onSubmit={handleSend} className={containerClass}>
<input
className={inputClass}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onFocus={onInputFocus}
onBlur={onInputBlur}
onKeyDown={(event) => {
if (event.key === 'Enter' && !draft.trim()) {
event.preventDefault();
blurChat();
}
}}
ref={(el) => registerInputRef(el, { target: 'hud' })}
placeholder={canChat ? 'Chat…' : 'Spectator'}
disabled={!canChat}
/>
<button
type="submit"
disabled={!canChat || sending}
className={buttonClass}
>
Send
</button>
</form>
);
}
+19 -6
View File
@@ -18,10 +18,11 @@ const ChatContext = createContext({
export function ChatProvider({ children }) {
const socket = useSocket();
const { session } = useSession();
const { session, pushAlert } = useSession();
const [messages, setMessages] = useState([]);
const [isChatFocused, setIsChatFocused] = useState(false);
const inputRef = useRef(null);
const panelInputRef = useRef(null);
const hudInputRef = useRef(null);
const audioRef = useRef(null);
useEffect(() => {
@@ -43,6 +44,12 @@ export function ChatProvider({ children }) {
return;
}
playSound();
pushAlert?.({
kind: 'chat',
payload,
id: `chat-${payload.id || Math.random().toString(36).slice(2)}`,
receivedAt: Date.now(),
});
}
socket.on('chat:message', handleMessage);
return () => {
@@ -82,18 +89,24 @@ export function ChatProvider({ children }) {
[socket],
);
const registerInputRef = useCallback((el) => {
inputRef.current = el;
const registerInputRef = useCallback((el, options = {}) => {
const target = options?.target === 'hud' ? 'hud' : 'panel';
if (target === 'hud') {
hudInputRef.current = el;
} else {
panelInputRef.current = el;
}
}, []);
const focusChat = useCallback(() => {
setIsChatFocused(true);
inputRef.current?.focus();
(hudInputRef.current || panelInputRef.current)?.focus();
}, []);
const blurChat = useCallback(() => {
setIsChatFocused(false);
inputRef.current?.blur();
hudInputRef.current?.blur();
panelInputRef.current?.blur();
}, []);
const onInputFocus = useCallback(() => setIsChatFocused(true), []);
@@ -351,9 +351,7 @@ export default function KeyboardInputManager() {
if (bindingActive(keymap.chatFocus, tokenSet)) {
event.preventDefault();
resetAll();
if (isChatFocused) {
blurChat();
} else {
if (!isChatFocused) {
focusChat();
}
return;